From 9666676766bf21364f4fc4d0d1d336e72f2a91b4 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 6 Oct 2026 12:57:23 -0400 Subject: refactor: split casino.go, dedup helpers, remove audit docs --- internal/casino/baccarat.go | 11 ++++++++--- internal/casino/big_six.go | 2 +- internal/casino/blackjack.go | 12 +++++++++--- internal/casino/blackjack_rules.go | 2 +- internal/casino/cash_hunt.go | 7 +++++++ internal/casino/casino_review_regression_test.go | 2 +- internal/casino/core.go | 2 +- internal/casino/craps.go | 3 +-- internal/casino/craps_clock_test.go | 10 +++++----- internal/casino/craps_regression_test.go | 2 +- internal/casino/craps_test.go | 2 +- internal/casino/manager_test.go | 23 +++++++++++++++++++++++ internal/casino/pai_gow.go | 13 +++++++++++-- internal/casino/pai_gow_rules.go | 14 -------------- internal/casino/plinko.go | 6 ++++++ internal/casino/roulette.go | 2 +- internal/casino/table_ux.go | 2 +- internal/casino/types.go | 13 +++++++++++++ internal/casino/video_poker_lifecycle_test.go | 7 +++++++ 19 files changed, 98 insertions(+), 37 deletions(-) (limited to 'internal/casino') diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index d385834..94d960a 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -264,6 +264,11 @@ func (t *baccaratTable) rebetAll(name string, wallet Wallet) []Event { if !p.hasLastBet() { return []Event{t.private(name, "Bet on what? Try bet N on "+t.Rules.betChoices()+".")} } + // Require the main wager before committing remembered side bets: a + // side-only memory would otherwise charge the sides and never deal. + if p.mainSpot == "" || p.lastBets[p.mainSpot] <= 0 { + return []Event{t.private(name, "Your last bet had no main wager. Use bet N on " + t.Rules.betChoices() + ".")} + } var events []Event for _, spot := range t.Rules.spots() { if baccaratIsMainSpot(spot) { @@ -374,7 +379,7 @@ func (t *baccaratTable) unbetAllLocked(name string) []Event { return []Event{t.private(name, "Nothing to take back.")} } if p.Wallet != nil { - if err := p.Wallet.PayCredits(total, ids...); err != nil { + if err := p.Wallet.RefundStakes(ids...); err != nil { return []Event{t.private(name, payoutFailedMessage())} } } @@ -399,7 +404,7 @@ func (t *baccaratTable) refundSidesLocked(p *baccaratPlayer) []Event { return nil } if p.Wallet != nil { - if err := p.Wallet.PayCredits(total, ids...); err != nil { + if err := p.Wallet.RefundStakes(ids...); err != nil { return []Event{t.private(p.Name, "Your side bets could not be returned just now; the stake stays pending and will be refunded after the next server restart.")} } } @@ -476,7 +481,7 @@ func (t *baccaratTable) deal() []Event { settlement := t.playerAction(p.Name, publicResult, privateResult) // Returning players get their balance in the next bet menu; only // departures/sit-outs need it attached to the settlement. - settlement.ShowBalance = !payoutFailed && (p.LeaveAfter || p.SitOutAfter || !p.Connected) + settlement.ShowBalance = p.SettlementBalanceNeeded(payoutFailed) if !payoutFailed { settlement.Settlement = lines switch { diff --git a/internal/casino/big_six.go b/internal/casino/big_six.go index b17bde4..36e9c1d 100644 --- a/internal/casino/big_six.go +++ b/internal/casino/big_six.go @@ -433,7 +433,7 @@ func (t *bigSixTable) settle() []Event { // The next-spin menu already shows the balance for seats that remain. // Only a departing or disconnected seat needs it here, otherwise the // wallet would print twice after a result. - ev.ShowBalance = !failed && (p.LeaveAfter || p.SitOutAfter || !p.Connected) + ev.ShowBalance = p.SettlementBalanceNeeded(failed) events = append(events, ev) } t.reset() diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index b5beb1e..83c183f 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -187,6 +187,9 @@ func (t *blackjackTable) bet(name, spot string, amount int, wallet Wallet) []Eve if amount < t.Config.MinBet || amount > t.Config.MaxBet { return []Event{t.private(name, fmt.Sprintf("Blackjack bets must be %s.", betRangeText(t.Config.MinBet, t.Config.MaxBet)))} } + if wallet == nil { + return []Event{t.private(name, "Wallet unavailable.")} + } wager, err := wallet.Wager(amount) if err != nil { return []Event{t.private(name, walletErrorMessage(err))} @@ -244,6 +247,7 @@ func (t *blackjackTable) tick() []Event { if active == nil { // Unreachable while mid-round leaves are blocked; keep the game moving. t.activePlayer++ + t.activeHand = 0 return t.finishHand() } // A lone bettor has no time limit at all. With two or more bettors @@ -581,10 +585,12 @@ func (t *blackjackTable) turnMenuLocked(name string) Event { e := t.private(name, "Your turn.") e.Color = "casino_menu" deadline := t.timer.remaining() + heading := fmt.Sprintf("Your turn | %d ticks remaining", deadline) if p.Connected && t.bettorCount() <= 1 { - deadline = t.ceiling.remaining() + // A lone bettor's clock never ticks, so a countdown would be frozen. + heading = "Your turn | no time limit" } - e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn | %d ticks remaining", deadline), Commands: withTableHints(blackjackActionCommands(actions), nil)} + e.Menu = &MenuView{Heading: heading, Commands: withTableHints(blackjackActionCommands(actions), nil)} for i := range e.Menu.Commands { c := &e.Menu.Commands[i] if c.Command == "double" || c.Command == "split" { @@ -693,7 +699,7 @@ func (t *blackjackTable) settle() []Event { if !p.HasBet { continue } - needsBalance := p.LeaveAfter || p.SitOutAfter || !p.Connected + needsBalance := p.SettlementBalanceNeeded(false) lines := make([]settlementLine, 0, len(p.hands)+1) for i, hand := range p.hands { result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) diff --git a/internal/casino/blackjack_rules.go b/internal/casino/blackjack_rules.go index 8bf63a8..50a5786 100644 --- a/internal/casino/blackjack_rules.go +++ b/internal/casino/blackjack_rules.go @@ -47,7 +47,7 @@ func (c TableConfig) BlackjackRulesText() string { "Surrender: " + surrender, "Insurance: Offered against an ace; costs half the original bet; pays " + payoutRatio(b.InsurancePayout), "Fractional credits: Rounded down (including insurance stake and surrender return)", - fmt.Sprintf("Shuffle threshold: %g%% of shoe remaining; may shuffle earlier for round coverage", b.ShuffleAt*100), + fmt.Sprintf("Shuffle threshold: %.6g%% of shoe remaining; may shuffle earlier for round coverage", b.ShuffleAt*100), "Shoe exhaustion: Reshuffle mid-round if necessary", "Bet limits: " + betRangeText(c.MinBet, c.MaxBet) + " credits", fmt.Sprintf("Seats: %d; betting window: %d ticks; action window: %d ticks", c.MaxPlayers, c.BettingTicks, b.ActionTicks), diff --git a/internal/casino/cash_hunt.go b/internal/casino/cash_hunt.go index a07b09a..c04d644 100644 --- a/internal/casino/cash_hunt.go +++ b/internal/casino/cash_hunt.go @@ -267,6 +267,13 @@ func (t *cashHuntTable) action(n, action string) []Event { if len(f) != 1 { return []Event{t.private(n, "Choose a square with pick N or a number from 1 to 100.")} } + // A non-numeric action while a hunt is live (e.g. the stop path's + // leave_after token) must not fall through to the square parser. + if t.active { + if _, err := strconv.Atoi(f[0]); err != nil { + return []Event{t.private(n, "Finish your hunt before leaving.")} + } + } square, err := strconv.Atoi(f[0]) if err != nil || square < 1 || square > CashHuntSquares { return []Event{t.private(n, "Choose a square from 1 to 100.")} diff --git a/internal/casino/casino_review_regression_test.go b/internal/casino/casino_review_regression_test.go index 8860bd7..c66dee6 100644 --- a/internal/casino/casino_review_regression_test.go +++ b/internal/casino/casino_review_regression_test.go @@ -44,7 +44,7 @@ func TestCrapsFailedUnbetPreservesAllLiveWagers(t *testing.T) { if retry { table.action("a", "unbet") table.action("a", "unbet") - if w.paid != 22 || len(w.open) != 1 || table.players.get("a").bets[0].odds.Total != 0 { + if w.chips != 990 || len(w.open) != 1 || table.players.get("a").bets[0].odds.Total != 0 { t.Fatalf("refund retry was not exact: %+v", w) } } else { diff --git a/internal/casino/core.go b/internal/casino/core.go index d04e046..6bcd4f9 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -78,7 +78,7 @@ func (c *tableCore[P]) closeIfEmptyLocked(events []Event) []Event { // openBettingLocked opens the timed betting window once a second player has a // wager down, and announces it. func (c *tableCore[P]) openBettingLocked(events []Event) []Event { - if c.players.count() > 1 && c.phase == phaseWaiting { + if c.players.count() > 1 && c.phase == phaseWaiting && !allBet(c.players.participants()) { c.phase = phaseBetting c.window.open(c.Config.BettingTicks) events = append(events, c.event(fmt.Sprintf("%s betting is open for %d ticks.", gameTitle(c.Config.Game), c.Config.BettingTicks), true)) diff --git a/internal/casino/craps.go b/internal/casino/craps.go index 0abf24b..208799e 100644 --- a/internal/casino/craps.go +++ b/internal/casino/craps.go @@ -51,7 +51,6 @@ type CrapsSnapshot struct { Queue []string Made, Seen []int Bets []CrapsBetView - Remaining int // legacy snapshot field; presentation uses ticks below ShootIn int DealerIn int AutoRoll bool @@ -585,7 +584,7 @@ func (t *crapsTable) unbetAllLocked(p *crapsPlayer) []Event { } // Keep the live wagers intact until the atomic refund succeeds. On a // failed save they still belong to the next roll, or a later unbet retry. - if err := p.Wallet.PayCredits(total, ids...); err != nil { + if err := p.Wallet.RefundStakes(ids...); err != nil { return []Event{t.private(p.Name, "The house could not take down your bets. All wagers remain live; try unbet again.")} } for _, b := range live { diff --git a/internal/casino/craps_clock_test.go b/internal/casino/craps_clock_test.go index 97758f8..3a68bf8 100644 --- a/internal/casino/craps_clock_test.go +++ b/internal/casino/craps_clock_test.go @@ -236,14 +236,14 @@ func TestCrapsUnbetAllAndRollTableFormat(t *testing.T) { tb.bet("alice", "odds pass", 20, w) tb.bet("alice", "place 4", 10, w) tb.bet("alice", "field", 10, w) - before := w.paid + before := w.chips events := tb.action("alice", "unbet") text := crapsEventText(events) if !strings.Contains(text, "take down {casino_chips}40{/}") || !strings.Contains(text, "contracts stay up") { t.Fatal(text) } - if w.paid != before+40 { - t.Fatal("refund amount", w.paid-before) + if w.chips != before+40 { + t.Fatal("in-kind refund amount", w.chips-before) } bets := tb.players.get("alice").bets if len(bets) != 2 || bets[0].kind != "fire" || bets[1].kind != "pass" || bets[1].odds.Total != 0 { @@ -253,8 +253,8 @@ func TestCrapsUnbetAllAndRollTableFormat(t *testing.T) { t.Fatal("pass and fire stakes must stay pending", w.open) } tb.action("alice", "unbet") - if w.paid != before+40 { - t.Fatal("second unbet all moved money", w.paid) + if w.chips != before+40 { + t.Fatal("second unbet all moved money", w.chips) } // Roll outcome line: every total once, single-level tags only. tb2 := crapsFixture() diff --git a/internal/casino/craps_regression_test.go b/internal/casino/craps_regression_test.go index 0bf8ba6..e18108a 100644 --- a/internal/casino/craps_regression_test.go +++ b/internal/casino/craps_regression_test.go @@ -28,7 +28,7 @@ func TestCrapsHopIsUnordered(t *testing.T) { t.Fatal("duplicate unordered hop accepted") } tb.action("a", "unbet") - if len(w.open) != 0 || w.paid != 10 || w.chips != 90 { + if len(w.open) != 0 || w.paid != 0 || w.chips != 100 { t.Fatal("reverse-order removal failed") } } diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go index 8e6e669..622e541 100644 --- a/internal/casino/craps_test.go +++ b/internal/casino/craps_test.go @@ -310,7 +310,7 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) { } tb.bet("a", "place 6", 12, w) tb.action("a", "unbet") - if w.paid != 12 || tb.players.get("a").HasBet { + if w.chips != 100 || w.paid != 0 || tb.players.get("a").HasBet { t.Fatal("optional removal") } tb.bet("a", "pass", 10, w) diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go index 889618c..1e77b0b 100644 --- a/internal/casino/manager_test.go +++ b/internal/casino/manager_test.go @@ -74,6 +74,29 @@ func (w *testWallet) RefundOutstanding() (chips, credits int, err error) { return chips, credits, nil } +// RefundStakes returns the named outstanding stakes to the balances in kind, +// mirroring the production wallet's take-back path. +func (w *testWallet) RefundStakes(stakeIDs ...string) error { + wanted := make(map[string]bool, len(stakeIDs)) + for _, id := range stakeIDs { + wanted[id] = true + } + var chips, credits int + kept := w.open[:0] + for _, wager := range w.open { + if wanted[wager.ID] { + chips += wager.Chips + credits += wager.Credits + continue + } + kept = append(kept, wager) + } + w.open = kept + w.chips += chips + w.paid += credits + return nil +} + func TestMixedWagerUsesChipsFirst(t *testing.T) { w := &testWallet{chips: 7, credits: 50} wager, err := w.Wager(12) diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go index 55dc601..8a7ddf3 100644 --- a/internal/casino/pai_gow.go +++ b/internal/casino/pai_gow.go @@ -88,6 +88,7 @@ type paiGowTable struct { dealer []card dealerSplit pgSplit tie [2]card + settleTried bool // guards against re-saving/re-messaging every tick while settling } func newPaiGowTable(room int, c TableConfig, rng *rand.Rand) tableGame { @@ -601,6 +602,7 @@ func (t *paiGowTable) action(name, action string) []Event { } func (t *paiGowTable) prepareSettlementLocked() []Event { t.phase = phaseSettling + t.settleTried = false for _, p := range t.players { if p.main.Total == 0 { continue @@ -742,6 +744,13 @@ func (t *paiGowTable) tick() []Event { defer t.mu.Unlock() switch t.phase { case phaseSettling: + // Try to settle once automatically. If storage is persistently failing, + // do not re-attempt the full save (and failure message) every tick; the + // explicit `settle` command retries instead. + if t.settleTried { + return nil + } + t.settleTried = true return t.payLocked() case phaseBetting: if t.allBetLocked() { @@ -868,7 +877,7 @@ func (t *paiGowTable) refundSideBetsLocked(p *pgPlayer) []Event { } e := t.private(p.name, fmt.Sprintf("Your %d in side bets is returned; no main wager was placed.", total)) if p.wallet != nil { - if err := p.wallet.PayCredits(total, ids...); err != nil { + if err := p.wallet.RefundStakes(ids...); err != nil { e.Message = payoutFailedMessage() } } @@ -938,7 +947,7 @@ func (t *paiGowTable) unbetLocked(p *pgPlayer) []Event { return []Event{t.private(p.name, "Nothing to take back.")} } if p.wallet != nil { - if err := p.wallet.PayCredits(total, ids...); err != nil { + if err := p.wallet.RefundStakes(ids...); err != nil { return []Event{t.private(p.name, payoutFailedMessage())} } } diff --git a/internal/casino/pai_gow_rules.go b/internal/casino/pai_gow_rules.go index edfa38c..231b3fb 100644 --- a/internal/casino/pai_gow_rules.go +++ b/internal/casino/pai_gow_rules.go @@ -153,13 +153,6 @@ func pgCompare(a, b pgRank) int { return 0 } -// pgNatural keeps the default-rules entry point used by tests and callers that -// do not thread a table configuration. Production evaluation goes through -// pgNaturalRules. -// -//nolint:unused // default-rules entry point -func pgNatural(cards []card) pgRank { return pgNaturalRules(cards, true) } - func pgNaturalRules(cards []card, a2345High bool) pgRank { counts := [15]int{} flush := len(cards) == 5 @@ -511,13 +504,6 @@ func pgFortuneRules(cards []card, envy, a2345High bool) (FortuneRow, bool) { return rows[bestIndex], true } -// pgBest keeps the default-rules entry point used by tests and callers that do -// not thread a table configuration. Production evaluation goes through -// pgBestRules. -// -//nolint:unused // default-rules entry point -func pgBest(cards []card) pgRank { return pgBestRules(cards, true) } - func pgBestRules(cards []card, a2345High bool) pgRank { var best pgRank for i := 0; i < 7; i++ { diff --git a/internal/casino/plinko.go b/internal/casino/plinko.go index 867978a..bcfae83 100644 --- a/internal/casino/plinko.go +++ b/internal/casino/plinko.go @@ -29,6 +29,12 @@ func validatePlinko(c TableConfig) error { if math.IsNaN(r) || math.IsInf(r, 0) || r < 31 || r > 100 { return fmt.Errorf("plinko rtp must be between 31 and 100 percent") } + // The one-decimal paytable rounding can push the realized expert return + // above 100%. Reject any target whose expert paytable pays more than it + // takes, so the house never runs a losing plinko board. + if ret := plinkoReturn(3, r); ret > 1.0 { + return fmt.Errorf("plinko rtp %.1f makes the expert paytable return %.3f%%; lower it", r, 100*ret) + } if c.MaxPlayers > 1 { return fmt.Errorf("plinko has one seat") } diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index 1069a68..71a0916 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -754,7 +754,7 @@ func (t *rouletteTable) settle() []Event { event := t.playerAction(p.Name, public, text) // Returning players get their balance in the next-spin menu. Only // departures/sit-outs need a balance attached to the settlement. - event.ShowBalance = !failed && (p.LeaveAfter || p.SitOutAfter || !p.Connected) + event.ShowBalance = p.SettlementBalanceNeeded(failed) if !failed { event.Settlement = []SettlementLine{{Label: p.lastSpot, Odds: rouletteSettlementOdds(wager, pocket), Stake: p.Wager.Total, Returned: payout, Result: result}} if result == "win" { diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index 6ba76f2..d23c8a2 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -144,7 +144,7 @@ func (c *tableCore[P]) unbetLocked(name string) ([]Event, bool) { ids = append(ids, p.Wager.ID) } if p.Wallet != nil { - if err := p.Wallet.PayCredits(total, ids...); err != nil { + if err := p.Wallet.RefundStakes(ids...); err != nil { return []Event{c.private(name, payoutFailedMessage())}, true } } diff --git a/internal/casino/types.go b/internal/casino/types.go index f6a828d..9a3f0a1 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -237,6 +237,11 @@ type Wallet interface { // refund could not be made durable: nothing was returned and the stakes // remain pending for a later attempt or startup recovery. RefundOutstanding() (chips, credits int, err error) + // RefundStakes returns the named pending stakes in kind — chips as chips, + // credits as credits — and removes them from the ledger in one save. It is + // the take-back path for a seat that holds specific handles; unlike + // PayCredits it never converts casino-only chips into spendable credits. + RefundStakes(stakeIDs ...string) error } type EventType string @@ -381,3 +386,11 @@ type Participant struct { SitOutAfter bool LeaveAfter bool } + +// SettlementBalanceNeeded reports whether a settlement should print the seat's +// balance: the seat is leaving, sitting out after the round, or disconnected, +// and the payout did not fail. Shared by every table so the rule lives in one +// place instead of being copy-pasted per game. +func (p *Participant) SettlementBalanceNeeded(payoutFailed bool) bool { + return !payoutFailed && (p.LeaveAfter || p.SitOutAfter || !p.Connected) +} diff --git a/internal/casino/video_poker_lifecycle_test.go b/internal/casino/video_poker_lifecycle_test.go index f817225..b9b6dd7 100644 --- a/internal/casino/video_poker_lifecycle_test.go +++ b/internal/casino/video_poker_lifecycle_test.go @@ -29,6 +29,13 @@ func (w *pokerTestWallet) PayCredits(n int, ids ...string) error { return w.testWallet.PayCredits(n, ids...) } +func (w *pokerTestWallet) RefundStakes(ids ...string) error { + if w.fail { + return ErrWalletPersistence + } + return w.testWallet.RefundStakes(ids...) +} + func (e Event) MessageStarts(prefix string) bool { return strings.HasPrefix(e.Message, prefix) } type pokerTestBank struct{ writes int } -- cgit v1.2.3