diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/casino_review_regression_test.go | 89 | ||||
| -rw-r--r-- | internal/casino/craps.go | 15 | ||||
| -rw-r--r-- | internal/casino/registry.go | 2 | ||||
| -rw-r--r-- | internal/casino/roulette.go | 77 | ||||
| -rw-r--r-- | internal/casino/roulette_animation_test.go | 103 | ||||
| -rw-r--r-- | internal/casino/video_poker.go | 22 | ||||
| -rw-r--r-- | internal/casino/video_poker_rules.go | 4 |
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." |
