aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat.go11
-rw-r--r--internal/casino/big_six.go2
-rw-r--r--internal/casino/blackjack.go12
-rw-r--r--internal/casino/blackjack_rules.go2
-rw-r--r--internal/casino/cash_hunt.go7
-rw-r--r--internal/casino/casino_review_regression_test.go2
-rw-r--r--internal/casino/core.go2
-rw-r--r--internal/casino/craps.go3
-rw-r--r--internal/casino/craps_clock_test.go10
-rw-r--r--internal/casino/craps_regression_test.go2
-rw-r--r--internal/casino/craps_test.go2
-rw-r--r--internal/casino/manager_test.go23
-rw-r--r--internal/casino/pai_gow.go13
-rw-r--r--internal/casino/pai_gow_rules.go14
-rw-r--r--internal/casino/plinko.go6
-rw-r--r--internal/casino/roulette.go2
-rw-r--r--internal/casino/table_ux.go2
-rw-r--r--internal/casino/types.go13
-rw-r--r--internal/casino/video_poker_lifecycle_test.go7
19 files changed, 98 insertions, 37 deletions
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 }