aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/casino_review_regression_test.go89
-rw-r--r--internal/casino/craps.go15
-rw-r--r--internal/casino/registry.go2
-rw-r--r--internal/casino/roulette.go77
-rw-r--r--internal/casino/roulette_animation_test.go103
-rw-r--r--internal/casino/video_poker.go22
-rw-r--r--internal/casino/video_poker_rules.go4
7 files changed, 289 insertions, 23 deletions
diff --git a/internal/casino/casino_review_regression_test.go b/internal/casino/casino_review_regression_test.go
new file mode 100644
index 0000000..8860bd7
--- /dev/null
+++ b/internal/casino/casino_review_regression_test.go
@@ -0,0 +1,89 @@
+package casino
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestCrapsRoundedOddsRespectTableMaximum(t *testing.T) {
+ for _, kind := range []string{"pass", "dontpass"} {
+ t.Run(kind, func(t *testing.T) {
+ table := crapsFixture()
+ table.Config.MaxBet = 11
+ table.join("a")
+ w := &testWallet{chips: 100}
+ table.bet("a", kind, 10, w)
+ table.resolveRoll(3, 3)
+ // Eleven rounds to 15 for pass odds, or 12 for don't odds.
+ events := table.bet("a", "odds "+kind, 11, w)
+ if !hasMessage(events, "above the table maximum") || len(w.open) != 1 || table.players.get("a").bets[0].odds.Total != 0 {
+ t.Fatalf("rounded odds bypassed maximum: %+v", events)
+ }
+ })
+ }
+}
+
+func TestCrapsFailedUnbetPreservesAllLiveWagers(t *testing.T) {
+ for _, retry := range []bool{false, true} {
+ t.Run(map[bool]string{false: "roll", true: "retry"}[retry], func(t *testing.T) {
+ table := crapsFixture()
+ table.join("a")
+ w := &pokerTestWallet{testWallet: testWallet{chips: 1000}}
+ table.bet("a", "pass", 10, w)
+ table.resolveRoll(3, 3)
+ table.bet("a", "odds pass", 10, w)
+ table.bet("a", "place 8", 12, w)
+ before := table.view().Bets
+ w.fail = true
+ events := table.action("a", "unbet")
+ if !hasMessage(events, "All wagers remain live") || !reflect.DeepEqual(before, table.view().Bets) || len(w.open) != 3 || !table.players.get("a").HasBet {
+ t.Fatalf("failed refund changed live wagers: %+v", events)
+ }
+ w.fail = false
+ 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 {
+ t.Fatalf("refund retry was not exact: %+v", w)
+ }
+ } else {
+ table.resolveRoll(3, 3)
+ if w.paid != 42 || len(w.open) != 1 {
+ t.Fatalf("preserved pass/odds failed to settle: %+v", w)
+ }
+ }
+ })
+ }
+}
+
+func TestPokerPreparationClearsPreviousBonusPresentation(t *testing.T) {
+ for _, command := range []string{"coins", "coins 1", "unbet", "denom 10", "hands 5", "game ddb", "feature off"} {
+ t.Run(command, func(t *testing.T) {
+ table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetHotRoll})
+ pokerDeal(t, table, w)
+ table.action("alice", "draw")
+ p := table.players["alice"]
+ p.multiplier, p.dice = 12, [2]int{6, 6}
+ p.loaded = 5
+ events := table.action("alice", command)
+ snap, ok := events[0].Snapshot.(*VideoPokerSnapshot)
+ if !ok {
+ t.Fatalf("command rejected: %+v", events)
+ }
+ if snap.Multiplier != 1 || snap.Dice != [2]int{} || snap.NoWin || snap.Held != [5]bool{} || len(snap.Cards) != 0 || len(snap.Lines) != 0 || len(snap.Current) != 0 {
+ t.Fatalf("stale result after %s: %+v", command, snap)
+ }
+ if len(w.open) != 0 {
+ t.Fatal("preparation created a wager")
+ }
+ })
+ }
+}
+
+func TestBonusPokerDeluxeRuleDescribesFlatQuadAward(t *testing.T) {
+ cfg := (VideoPokerConfig{Variant: "bonus_poker_deluxe"}).Normalize()
+ if rule := cfg.PairRule(); !strings.Contains(rule, "80 per coin") || strings.Contains(rule, "by rank") {
+ t.Fatalf("incorrect Deluxe rule: %s", rule)
+ }
+}
diff --git a/internal/casino/craps.go b/internal/casino/craps.go
index 90c95c9..6fa6c79 100644
--- a/internal/casino/craps.go
+++ b/internal/casino/craps.go
@@ -249,6 +249,9 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event {
}
requested := amount
amount = roundUpTo(amount, payoutDen)
+ if amount > t.Config.MaxBet {
+ return reject(fmt.Sprintf("That wager rounds up to %d, above the table maximum.", amount))
+ }
// Compare without multiplying a potentially very large stake by Nx.
if (amount-1)/limit >= b.stake.Total {
return reject("Odds already placed or above the free-odds limit.")
@@ -557,7 +560,6 @@ func (t *crapsTable) unbetAllLocked(p *crapsPlayer) []Event {
if b.odds.Total > 0 {
total += b.odds.Total
ids = append(ids, b.odds.ID)
- b.odds = Wager{}
}
live = append(live, b)
default:
@@ -571,12 +573,17 @@ func (t *crapsTable) unbetAllLocked(p *crapsPlayer) []Event {
if total == 0 {
return []Event{t.private(p.Name, "Nothing removable on the table.")}
}
+ // 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 {
+ 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 {
+ b.odds = Wager{}
+ }
p.bets = live
p.HasBet = len(live) > 0
e := t.private(p.Name, fmt.Sprintf("You take down {casino_chips}%d{/} credits of optional bets; contracts stay up.", total))
- if err := p.Wallet.PayCredits(total, ids...); err != nil {
- e.Message = payoutFailedMessage()
- }
events := []Event{e, t.betSummary(p)}
events = append(events, t.syncRoundClock()...)
if t.players.has(p.Name) {
diff --git a/internal/casino/registry.go b/internal/casino/registry.go
index dbf3b31..a27a9dd 100644
--- a/internal/casino/registry.go
+++ b/internal/casino/registry.go
@@ -97,7 +97,7 @@ func init() {
},
normalize: func(c TableConfig) TableConfig {
if c.Roulette.SpinTicks == 0 {
- c.Roulette.SpinTicks = 3
+ c.Roulette.SpinTicks = 8
}
return c
},
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
index fa87800..783d22f 100644
--- a/internal/casino/roulette.go
+++ b/internal/casino/roulette.go
@@ -474,6 +474,7 @@ type rouletteTable struct {
tableCore[*roulettePlayer]
rng *rand.Rand
spinRemaining int
+ winningIndex int // selected once at spin start, shared by final frame and settlement
lastResult *RouletteSnapshot
history []string
}
@@ -601,11 +602,7 @@ func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event {
e := t.event(fmt.Sprintf("Roulette betting is open for %d ticks.", t.Config.BettingTicks), true)
events = append(events, e)
} else {
- t.phase = phasePlaying
- t.spinRemaining = t.Config.Roulette.SpinTicks
- e := t.event("No more bets! The roulette wheel spins...", true)
- e.Snapshot = t.view()
- events = append(events, e)
+ events = append(events, t.startSpin())
}
}
return events
@@ -618,6 +615,14 @@ func (t *rouletteTable) tick() []Event {
if t.spinRemaining <= 0 {
return t.settle()
}
+ frame := t.Config.Roulette.SpinTicks - t.spinRemaining
+ if frame <= 5 {
+ // Cosmetic randomness must not consume the outcome RNG.
+ s := t.spinFrame(frame, rand.Intn(3) == 0, rand.Intn(10), 1+rand.Intn(36))
+ e := t.event("", true)
+ e.Snapshot = s
+ return []Event{e}
+ }
return nil
}
if t.phase != phaseBetting {
@@ -636,15 +641,64 @@ func (t *rouletteTable) tick() []Event {
t.resetRound()
return nil
}
+ return []Event{t.startSpin()}
+}
+
+func (t *rouletteTable) startSpin() Event {
t.phase = phasePlaying
t.spinRemaining = t.Config.Roulette.SpinTicks
- e := t.event("No more bets! The roulette wheel spins...", true)
- e.Snapshot = t.view()
- return []Event{e}
+ t.winningIndex = t.rng.Intn(len(rouletteWheel(t.Config.Roulette.DoubleZero)))
+ return t.event("No more bets! The roulette wheel spins...", true)
}
+
+// spinFrame keeps cosmetic motion separate from the committed outcome. The
+// final strip uses physical wheel neighbors, including American same-color
+// neighbors around zero pockets, rather than inventing alternating colors.
+func (t *rouletteTable) spinFrame(frame int, showBall bool, ball, green int) *RouletteSnapshot {
+ s := t.view()
+ s.SpinFrame, s.BallPosition = frame, -1
+ if frame == 5 {
+ s.BallPosition = 4
+ wheel := rouletteWheel(t.Config.Roulette.DoubleZero)
+ // Frame four starts black. Slide the final window to start red,
+ // keeping the ball and both adjacent pockets inside the strip.
+ for offset := 0; offset < 8; offset++ {
+ position := 1 + (3+offset)%8
+ start := wheel[(t.winningIndex-position+len(wheel))%len(wheel)]
+ n, _ := strconv.Atoi(start)
+ if rouletteRed(n) {
+ s.BallPosition = position
+ break
+ }
+ }
+ for i := range s.SpinPockets {
+ s.SpinPockets[i] = wheel[(t.winningIndex+i-s.BallPosition+len(wheel))%len(wheel)]
+ }
+ return s
+ }
+ if showBall {
+ s.BallPosition = ball
+ }
+ parity := frame
+ for i := range s.SpinPockets {
+ // Insert green between alternating neighbors, never replace one of
+ // them (which would leave equal colors on either side).
+ if i == green && i > 0 && i < len(s.SpinPockets)-1 {
+ s.SpinPockets[i] = "0"
+ continue
+ }
+ s.SpinPockets[i] = "1" // red
+ if parity%2 == 0 {
+ s.SpinPockets[i] = "2" // black
+ }
+ parity++
+ }
+ return s
+}
+
func (t *rouletteTable) settle() []Event {
wheel := rouletteWheel(t.Config.Roulette.DoubleZero)
- index := t.rng.Intn(len(wheel))
+ index := t.winningIndex
pocket := wheel[index]
t.history = append(t.history, pocket)
if len(t.history) > rouletteHistoryLimit {
@@ -786,7 +840,10 @@ func (t *rouletteTable) refundAll() []Event {
// RouletteSnapshot describes the view without coupling the engine to terminal
// colors, Unicode preferences, or card-art settings.
type RouletteSnapshot struct {
- DoubleZero bool
+ DoubleZero bool
+ SpinFrame int // 1–5 for animation events; zero for ordinary snapshots
+ BallPosition int // -1 when the ball is not visible
+ SpinPockets [10]string // cosmetic pockets, or actual wheel order on the final frame
// Racetrack reports that the table also offers the French call bets and
// neighbours bets, so the renderer draws the racetrack panel.
Racetrack bool
diff --git a/internal/casino/roulette_animation_test.go b/internal/casino/roulette_animation_test.go
new file mode 100644
index 0000000..33d0e32
--- /dev/null
+++ b/internal/casino/roulette_animation_test.go
@@ -0,0 +1,103 @@
+package casino
+
+import (
+ "strconv"
+ "testing"
+)
+
+func TestRouletteAnimationTiming(t *testing.T) {
+ for _, multiplayer := range []bool{false, true} {
+ table := rouletteFixture()
+ table.Config.Roulette.SpinTicks = 8
+ table.join("a")
+ if multiplayer {
+ table.join("b")
+ }
+ events := table.bet("a", "red", 10, &testWallet{chips: 100})
+ if multiplayer {
+ table.tick()
+ events = table.tick()
+ }
+ for _, e := range events {
+ if e.Snapshot != nil {
+ t.Fatal("spin announcement contains old wheel")
+ }
+ }
+ var final *RouletteSnapshot
+ for tick := 1; tick <= 8; tick++ {
+ events = table.tick()
+ switch {
+ case tick <= 5:
+ if len(events) != 1 {
+ t.Fatalf("tick %d: missing frame", tick)
+ }
+ s := events[0].Snapshot.(*RouletteSnapshot)
+ if s.SpinFrame != tick || s.BallPosition < -1 || s.BallPosition >= 10 {
+ t.Fatalf("bad frame: %+v", s)
+ }
+ final = s
+ case tick < 8:
+ if len(events) != 0 {
+ t.Fatalf("tick %d: pause interrupted", tick)
+ }
+ default:
+ if len(events) == 0 || events[0].Snapshot.(*RouletteSnapshot).Phase != "result" {
+ t.Fatal("missing result at tick 8")
+ }
+ result := events[0].Snapshot.(*RouletteSnapshot)
+ if final.BallPosition < 0 || final.SpinPockets[final.BallPosition] != result.Pockets[2] {
+ t.Fatal("final ball disagrees with result")
+ }
+ }
+ }
+ }
+}
+
+func TestRouletteSpinFramePockets(t *testing.T) {
+ table := rouletteFixture()
+ for frame := 1; frame <= 4; frame++ {
+ for green := 1; green <= 36; green++ {
+ for _, showBall := range []bool{false, true} {
+ s := table.spinFrame(frame, showBall, 3, green)
+ if (s.BallPosition == 3) != showBall || (!showBall && s.BallPosition != -1) {
+ t.Fatal("ball visibility ignored")
+ }
+ want := "1"
+ if frame%2 == 0 {
+ want = "2"
+ }
+ if s.SpinPockets[0] != want {
+ t.Fatal("starting color did not alternate")
+ }
+ for i, pocket := range s.SpinPockets {
+ if pocket == "0" && (i == 0 || i == 9 || s.SpinPockets[i-1] == s.SpinPockets[i+1]) {
+ t.Fatalf("green not between red and black: %v", s.SpinPockets)
+ }
+ }
+ }
+ }
+ }
+ for _, double := range []bool{false, true} {
+ table.Config.Roulette.DoubleZero = double
+ wheel := rouletteWheel(double)
+ for index, winner := range wheel {
+ table.winningIndex = index
+ s := table.spinFrame(5, false, 0, 0)
+ start, _ := strconv.Atoi(s.SpinPockets[0])
+ if !rouletteRed(start) {
+ t.Fatal("final strip must start red after the black-starting fourth strip")
+ }
+ if s.BallPosition < 1 || s.BallPosition >= 9 || s.SpinPockets[s.BallPosition] != winner {
+ t.Fatalf("missing winning ball for %s", winner)
+ }
+ for i, pocket := range s.SpinPockets {
+ if pocket != wheel[(index+i-s.BallPosition+len(wheel))%len(wheel)] {
+ t.Fatalf("wrong physical neighbors for %s", winner)
+ }
+ }
+ if !double && winner == "26" && s.SpinPockets[s.BallPosition+1] != "0" {
+ t.Fatal("26 must have green to its right")
+ }
+ }
+ }
+}
diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go
index 7e79b58..d2578b8 100644
--- a/internal/casino/video_poker.go
+++ b/internal/casino/video_poker.go
@@ -63,6 +63,15 @@ type pokerPlayer struct {
result, bankKey string
}
+// clearResult clears the previous hand's presentation when preparing a new
+// wager. Loaded coins and the cabinet's earned multiplier banks are unchanged.
+func (p *pokerPlayer) clearResult() {
+ p.lines, p.remaining, p.current, p.next = nil, nil, nil, nil
+ p.result, p.payout, p.drawn = "", 0, false
+ p.held = [5]bool{}
+ p.multiplier, p.dice = 1, [2]int{}
+}
+
// cost is the full currency price of dealing the loaded wager.
func (p *pokerPlayer) cost() int {
coins := p.loaded
@@ -378,8 +387,7 @@ func (t *videoPokerTable) action(name, action string) []Event {
return []Event{t.private(name, "Nothing to take back.")}
}
p.loaded = 0
- p.lines, p.remaining, p.result, p.current, p.next = nil, nil, "", nil, nil
- p.multiplier, p.dice = 1, [2]int{}
+ p.clearResult()
return t.statusLocked(name, true)
case "coins":
if len(fields) > 2 {
@@ -403,8 +411,7 @@ func (t *videoPokerTable) action(name, action string) []Event {
n = min(5, p.loaded+add)
}
p.loaded, p.lastBet = n, n
- p.lines, p.remaining, p.result, p.current, p.next = nil, nil, "", nil, nil
- p.multiplier, p.dice = 1, [2]int{}
+ p.clearResult()
return t.statusLocked(name, len(fields) > 1)
case "denom", "denomination":
if len(fields) != 2 {
@@ -415,7 +422,7 @@ func (t *videoPokerTable) action(name, action string) []Event {
break
}
p.denomination = n
- p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, ""
+ p.clearResult()
return t.statusLocked(name, true)
case "hands":
if len(fields) != 2 {
@@ -428,7 +435,7 @@ func (t *videoPokerTable) action(name, action string) []Event {
for _, allowed := range CabinetHandCounts(t.settings.Cabinet, p.variant) {
if n == allowed {
p.hands = n
- p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, ""
+ p.clearResult()
return t.statusLocked(name, true)
}
}
@@ -445,13 +452,14 @@ func (t *videoPokerTable) action(name, action string) []Event {
return []Event{t.private(name, "That game is not available on this cabinet. Games: "+t.settings.GameChoices())}
}
p.config, p.variant, p.hands = cfg, variant, CabinetHandCounts(cfg.Cabinet, variant)[0]
- p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, ""
+ p.clearResult()
return t.statusLocked(name, true)
case "feature":
if len(fields) != 2 || cabinetFeature(t.settings.Cabinet) == "" || fields[1] != "on" && fields[1] != "off" {
break
}
p.featureOn = fields[1] == "on"
+ p.clearResult()
return t.statusLocked(name, false)
}
return []Event{t.private(name, "Use bet [1-4|max], unbet, deal, game <name>, denom 1|10|50, hands <count>, paytable, or feature on|off on a bonus cabinet.")}
diff --git a/internal/casino/video_poker_rules.go b/internal/casino/video_poker_rules.go
index 29ab5c3..55cdf8e 100644
--- a/internal/casino/video_poker_rules.go
+++ b/internal/casino/video_poker_rules.go
@@ -149,7 +149,9 @@ func (c VideoPokerConfig) PairRule() string {
return "Every 2 is wild. Three of a kind is the lowest win; only the highest category pays."
case "joker_poker":
return "One joker is wild. A pair of kings or aces is the lowest win; a wild royal is not a natural royal."
- case "bonus_poker", "bonus_poker_deluxe", "double_bonus", "double_double_bonus":
+ case "bonus_poker_deluxe":
+ return "A pair of jacks or better is the lowest win; every four of a kind pays 80 per coin."
+ case "bonus_poker", "double_bonus", "double_double_bonus":
return "A pair of jacks or better is the lowest win; four of a kind carries bonus rates by rank."
}
return "A pair of jacks or better is the lowest win."