aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/cash_hunt.go4
-rw-r--r--internal/casino/craps_ux.go2
-rw-r--r--internal/casino/craps_wagers.go8
-rw-r--r--internal/casino/pai_gow.go19
-rw-r--r--internal/casino/pai_gow_rules.go5
-rw-r--r--internal/casino/pai_gow_test.go28
-rw-r--r--internal/casino/plinko.go4
-rw-r--r--internal/casino/registry.go2
-rw-r--r--internal/casino/roulette.go14
-rw-r--r--internal/casino/roulette_test.go8
-rw-r--r--internal/casino/settlement_test.go2
-rw-r--r--internal/casino/table_hints.go2
-rw-r--r--internal/casino/table_ux.go2
-rw-r--r--internal/casino/video_poker_config.go6
14 files changed, 83 insertions, 23 deletions
diff --git a/internal/casino/cash_hunt.go b/internal/casino/cash_hunt.go
index 7c20a5a..a07b09a 100644
--- a/internal/casino/cash_hunt.go
+++ b/internal/casino/cash_hunt.go
@@ -36,10 +36,10 @@ func cashHuntPrize(risk, ticket int) int {
func validateCashHunt(c TableConfig) error {
if c.MaxPlayers > 1 {
- return fmt.Errorf("Cash Hunt has one seat")
+ return fmt.Errorf("cash hunt has one seat")
}
if c.MaxBet > MaxCredits/cashHuntMaxMultiplier {
- return fmt.Errorf("Cash Hunt max_bet must not exceed %d", MaxCredits/cashHuntMaxMultiplier)
+ return fmt.Errorf("cash hunt max_bet must not exceed %d", MaxCredits/cashHuntMaxMultiplier)
}
return nil
}
diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go
index 9538e7c..a4495cb 100644
--- a/internal/casino/craps_ux.go
+++ b/internal/casino/craps_ux.go
@@ -56,7 +56,7 @@ func (t *crapsTable) commands(name string) []MenuCommand {
// "rules" stays available to a seated player at every phase of every
// variant; "list" rides along whenever the wager table names more
// than one bet.
- commands = withTableHints(commands, t.wagerRows(name))
+ commands = withTableHints(commands, t.wagerRows())
}
return append(commands, MenuCommand{Command: "table", Desc: "View the felt"})
}
diff --git a/internal/casino/craps_wagers.go b/internal/casino/craps_wagers.go
index beb8722..33b465b 100644
--- a/internal/casino/craps_wagers.go
+++ b/internal/casino/craps_wagers.go
@@ -9,7 +9,7 @@ import (
// where two bets share the same payout (pass/don't pass, the place numbers,
// all free odds) so the list stays compact and the Win/action column rarely
// wraps. Payouts are profit odds; winning stakes return alongside the profit.
-func (t *crapsTable) wagerRows(name string) []WagerRow {
+func (t *crapsTable) wagerRows() []WagerRow {
c := t.Config.Craps
rows := []WagerRow{}
passWin := "Come out: 7/11 wins, 2/3/12 loses / Point on: Roll the point to win."
@@ -52,7 +52,7 @@ func (t *crapsTable) wagerRows(name string) []WagerRow {
WagerRow{"hard 6/8", "9:1", "Roll this pair before next 7"},
)
}
- rows = append(rows, crapsPropRows(c, false)...)
+ rows = append(rows, crapsPropRows(false)...)
default: // bank and diceless
rows = append(rows,
WagerRow{"pass", "1:1", passWin},
@@ -71,7 +71,7 @@ func (t *crapsTable) wagerRows(name string) []WagerRow {
WagerRow{"hard 6/8", "9:1", "Roll this pair before next 7"},
)
}
- rows = append(rows, crapsPropRows(c, !t.rolled)...)
+ rows = append(rows, crapsPropRows(!t.rolled)...)
}
return rows
}
@@ -87,7 +87,7 @@ func crapsBuyLayRows() []WagerRow {
// crapsPropRows is the one-roll section of the list: field, the proposition
// bets and (when bonuses are allowed and still open) the Fire and ATS bets.
-func crapsPropRows(c CrapsConfig, bonuses bool) []WagerRow {
+func crapsPropRows(bonuses bool) []WagerRow {
rows := []WagerRow{
{"field", "1:1", "Next roll is 2-4 or 9-12"},
{"seven", "4:1", "Next roll totals 7"},
diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go
index a2dafcf..8f581db 100644
--- a/internal/casino/pai_gow.go
+++ b/internal/casino/pai_gow.go
@@ -747,8 +747,11 @@ func (t *paiGowTable) tick() []Event {
if t.allBetLocked() {
return t.dealLocked()
}
- // A lone player has no time limit; wait for their bet instead.
- if t.activePlayersLocked() <= 1 {
+ // A lone seated player has no time limit; wait for their bet instead.
+ // Count seated players, not bettors: with one bettor and one idle seat
+ // activePlayersLocked() is 1 forever, so the window never closes and
+ // the round stalls.
+ if t.seatedPlayersLocked() <= 1 {
return nil
}
t.ticks--
@@ -901,6 +904,18 @@ func (t *paiGowTable) activePlayersLocked() int {
return n
}
+// seatedPlayersLocked counts connected seats, bettors or not. The betting
+// window is bounded by seats present, so an idle seat still lets it expire.
+func (t *paiGowTable) seatedPlayersLocked() int {
+ n := 0
+ for _, p := range t.players {
+ if !p.disconnected {
+ n++
+ }
+ }
+ return n
+}
+
// unbetLocked takes back every wager before the deal. Refunds go through
// PayCredits with the original stake handles so the ledger entries resolve in
// the same save; wagers are only cleared on success.
diff --git a/internal/casino/pai_gow_rules.go b/internal/casino/pai_gow_rules.go
index 494cde7..edfa38c 100644
--- a/internal/casino/pai_gow_rules.go
+++ b/internal/casino/pai_gow_rules.go
@@ -393,8 +393,9 @@ type FortuneRow struct {
Odds, Envy int
}
-// Wizard of Odds Fortune table 8, the lowest-edge published fixed schedule
-// among its eight tables. With Envy disabled, quads receive a custom +5.
+// FortunePayTable returns the Wizard of Odds Fortune table 8, the lowest-edge
+// published fixed schedule among its eight tables. With Envy disabled, quads
+// receive a custom +5.
func FortunePayTable(envy bool) []FortuneRow {
rows := []FortuneRow{{"Natural seven-card straight flush", 1000, 500}, {"Royal flush + royal match", 750, 250}, {"Wild seven-card straight flush", 500, 150}, {"Five aces", 250, 100}, {"Royal flush", 150, 50}, {"Straight flush", 50, 20}, {"Four of a kind", 25, 5}, {"Full house", 5, 0}, {"Flush", 4, 0}, {"Three of a kind", 3, 0}, {"Straight", 2, 0}, {"Three pair", 1, 0}}
if !envy {
diff --git a/internal/casino/pai_gow_test.go b/internal/casino/pai_gow_test.go
index 7be9e8a..100b87d 100644
--- a/internal/casino/pai_gow_test.go
+++ b/internal/casino/pai_gow_test.go
@@ -838,3 +838,31 @@ func TestPaiGowBettingWindowTimeoutNotifiesNonBettor(t *testing.T) {
t.Fatal("non-bettor was not told the betting window closed")
}
}
+
+// Regression: with one bettor and one idle seated player the betting window
+// must still close. activePlayersLocked counts bettors, so it used to stay at 1
+// forever and the round stalled until the idle seat bet or left.
+func TestPaiGowBettingWindowExpiresWithOneBettorAndIdleSeat(t *testing.T) {
+ tab := pgTestTable(false) // BettingTicks 2
+ w := &testWallet{chips: 100}
+ tab.join("a")
+ tab.join("b")
+ tab.bet("a", "main", 10, w)
+ if tab.phase != "betting" {
+ t.Fatalf("setup: wanted betting, got %s", tab.phase)
+ }
+ notified := false
+ for i := 0; i < 4; i++ {
+ for _, e := range tab.tick() {
+ if e.PrivateTo == "b" && strings.Contains(e.Message, "betting window closed") {
+ notified = true
+ }
+ }
+ }
+ if tab.phase == "betting" {
+ t.Fatal("round stalled in betting with one bettor and one idle seat")
+ }
+ if !notified {
+ t.Fatal("idle seat was not sat out when the window closed")
+ }
+}
diff --git a/internal/casino/plinko.go b/internal/casino/plinko.go
index 412b72c..d50cf6f 100644
--- a/internal/casino/plinko.go
+++ b/internal/casino/plinko.go
@@ -12,8 +12,8 @@ import (
const plinkoRows = 16
-// RTP is the target return percentage for the one-decimal paytables.
-// Fixed expert edge prizes alone return 30.517578125%.
+// PlinkoConfig holds the RTP target for the one-decimal paytables. Fixed
+// expert edge prizes alone return 30.517578125%.
type PlinkoConfig struct {
RTP float64 `yaml:"rtp,omitempty"`
}
diff --git a/internal/casino/registry.go b/internal/casino/registry.go
index b13125d..716782e 100644
--- a/internal/casino/registry.go
+++ b/internal/casino/registry.go
@@ -107,7 +107,7 @@ func init() {
houseEdge: bigSixHouseEdge,
validate: func(c TableConfig) error {
if c.BettingTicks != 0 && c.BettingTicks != 25 {
- return fmt.Errorf("Big 6 betting_ticks must be 25")
+ return fmt.Errorf("big 6 betting_ticks must be 25")
}
return nil
},
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
index ad0a706..1069a68 100644
--- a/internal/casino/roulette.go
+++ b/internal/casino/roulette.go
@@ -822,11 +822,19 @@ func (t *rouletteTable) markDisconnected(name string) []Event {
if p == nil {
return nil
}
- // No outcome has been sampled or revealed during the spin. Disconnect
- // refunds are safe, just as pre-deal baccarat refunds are.
+ // Once the spin starts the winning pocket is fixed, and from frame 5 it is
+ // on screen, so a disconnect refund would allow selective losses. Forfeit
+ // the stake while the wheel is spinning, matching Big 6; refund only before
+ // the spin starts.
var events []Event
if p.Wallet != nil {
- if _, _, err := p.Wallet.RefundOutstanding(); err != nil {
+ var err error
+ if p.HasBet && t.phase == phasePlaying {
+ err = p.Wallet.PayCredits(0, p.Wager.ID)
+ } else {
+ _, _, err = p.Wallet.RefundOutstanding()
+ }
+ if err != nil {
events = append(events, t.private(name, "Your stake remains pending for startup recovery."))
}
}
diff --git a/internal/casino/roulette_test.go b/internal/casino/roulette_test.go
index 97bff2a..a0e04d4 100644
--- a/internal/casino/roulette_test.go
+++ b/internal/casino/roulette_test.go
@@ -356,7 +356,13 @@ func TestRouletteRefundAndPersistenceFailure(t *testing.T) {
table.markDisconnected("a")
table.refundAll()
table.tick()
- if w.chips != 100 || len(w.open) != 0 || w.paid != 0 {
+ if spinning {
+ // Once the spin starts the winning pocket is fixed; disconnecting
+ // forfeits the stake instead of allowing a selective refund.
+ if w.chips != 90 || len(w.open) != 0 || w.paid != 0 {
+ t.Fatalf("disconnect forfeit: %+v", w)
+ }
+ } else if w.chips != 100 || len(w.open) != 0 || w.paid != 0 {
t.Fatalf("disconnect refund: %+v", w)
}
}
diff --git a/internal/casino/settlement_test.go b/internal/casino/settlement_test.go
index 9c110d6..79954bc 100644
--- a/internal/casino/settlement_test.go
+++ b/internal/casino/settlement_test.go
@@ -17,7 +17,7 @@ func (w *recoveringSettlementWallet) PayCredits(n int, ids ...string) error {
return w.testWallet.PayCredits(n, ids...)
}
-func receiptLines(events []Event, name string) []SettlementLine {
+func receiptLines(events []Event, name string) []SettlementLine { //nolint:unparam // keeps the recipient explicit at every call site
var lines []SettlementLine
for _, e := range events {
if e.PrivateTo == name {
diff --git a/internal/casino/table_hints.go b/internal/casino/table_hints.go
index 5b1d86e..3a87c94 100644
--- a/internal/casino/table_hints.go
+++ b/internal/casino/table_hints.go
@@ -8,7 +8,7 @@ import "fmt"
// table controls never need a per-game switch. All entries are seated only;
// a standing player is not told how to bet at a game they have not joined.
-func (t *crapsTable) wagers(name string) []WagerRow { return t.wagerRows(name) }
+func (t *crapsTable) wagers(name string) []WagerRow { return t.wagerRows() }
func (t *crapsTable) hints(name string) []MenuCommand { return t.commands(name) }
func (t *rouletteTable) wagers(name string) []WagerRow { return t.wagerRows() }
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index 5d70aaf..6ba76f2 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -283,7 +283,7 @@ func (t *blackjackTable) insuranceMenuLocked(p *blackjackPlayer) Event {
return e
}
-// settlementLine is one settled bet within a round. Label names the bet for
+// SettlementLine is one settled bet within a round. Label names the bet for
// the itemized line (empty for a lone anonymous hand); Result is the engine's
// settlement word ("win", "loss", "bust", "push", "blackjack",
// "surrender", "insurance").
diff --git a/internal/casino/video_poker_config.go b/internal/casino/video_poker_config.go
index 88fcbc4..aa01389 100644
--- a/internal/casino/video_poker_config.go
+++ b/internal/casino/video_poker_config.go
@@ -5,8 +5,10 @@ import (
"strings"
)
-// Cabinet and Payout are authored settings. The remaining fields identify the
-// selected game and its resolved schedule; schedules are never separate games.
+// VideoPokerConfig is the authored cabinet/payout pair plus the rest of the
+// cabinet settings. Cabinet and Payout are authored settings; the remaining
+// fields identify the selected game and its resolved schedule; schedules are
+// never separate games.
type VideoPokerConfig struct {
Cabinet string `yaml:"cabinet,omitempty"`
Payout string `yaml:"payout,omitempty"`