aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat_test.go4
-rw-r--r--internal/casino/blackjack.go35
-rw-r--r--internal/casino/cards.go8
-rw-r--r--internal/casino/config_validate.go31
-rw-r--r--internal/casino/credits.go6
-rw-r--r--internal/casino/hardening_fixes_batch2_test.go108
-rw-r--r--internal/casino/slots.go3
-rw-r--r--internal/casino/types.go5
8 files changed, 187 insertions, 13 deletions
diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go
index 522f875..c5565b1 100644
--- a/internal/casino/baccarat_test.go
+++ b/internal/casino/baccarat_test.go
@@ -108,8 +108,8 @@ func TestBaccaratSettlement(t *testing.T) {
if result, payout := settleBaccaratBet(baccaratSpotBanker, 100, 4, 6, rules); result != "win" || payout != 195 {
t.Fatalf("banker win: result=%s payout=%d, want win/195 (5%% commission)", result, payout)
}
- if result, payout := settleBaccaratBet(baccaratSpotBanker, 1, 4, 6, rules); result != "win" || payout != 1 {
- t.Fatalf("banker win on bet 1: result=%s payout=%d, want win/1 (commission rounds up)", result, payout)
+ if result, payout := settleBaccaratBet(baccaratSpotBanker, 1, 4, 6, rules); result != "win" || payout != 2 {
+ t.Fatalf("banker win on bet 1: result=%s payout=%d, want win/2 (a winning stake never pays zero)", result, payout)
}
if result, payout := settleBaccaratBet(baccaratSpotTie, 100, 7, 7, rules); result != "win" || payout != 900 {
t.Fatalf("tie win: result=%s payout=%d, want win/900 (8:1)", result, payout)
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index 7b30699..16a480c 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -75,8 +75,14 @@ type blackjackTable struct {
ceiling countdown
activePlayer int
activeHand int
- dealer blackjackHand
- shoe *shoe
+ // turnName/turnHand identify the current turn so the absolute turn
+ // ceiling is restarted only when the turn actually changes. A third party
+ // joining or leaving mid-round re-prompts the same turn and must not
+ // extend a hung bettor's deadline.
+ turnName string
+ turnHand int
+ dealer blackjackHand
+ shoe *shoe
}
// loneTurnGrace multiplies the action window into an absolute per-turn
@@ -94,7 +100,7 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa
return &blackjackTable{
tableBase: tableBase{RoomID: roomID, Config: cfg}, Rules: rules,
players: newSeatSet[*blackjackPlayer](), phase: blackjackWaiting,
- shoe: newShoe(rules.Decks, rules.ShuffleAt, rng),
+ shoe: newShoe(rules.Decks, rules.ShuffleAt, rng), turnHand: -1,
}
}
@@ -465,6 +471,7 @@ func (t *blackjackTable) beginPlay() {
t.phase = blackjackPlaying
t.timer.start(t.Rules.ActionTicks)
t.activePlayer, t.activeHand = 0, 0
+ t.turnName, t.turnHand = "", -1
}
// roundCardCeiling is the heuristic number of cards this table's round is
@@ -475,19 +482,29 @@ func (t *blackjackTable) beginPlay() {
// guaranteed to hold it and draw() may still rebuild an exhausted shoe
// mid-round (a deliberate shuffle-through).
func (t *blackjackTable) roundCardCeiling() int {
- return blackjackRoundCards(t.Config.MaxPlayers, t.Rules.MaxSplitHands)
+ // Size the boundary reshuffle from the seats actually in the round, not
+ // the configured maximum: using MaxPlayers made ensureForRound dominate
+ // the cut card, so shuffle_at never fired on a table whose maximum seats
+ // implied more cards than the cut card allowed.
+ return blackjackRoundCards(t.bettorCount(), t.Rules.MaxSplitHands)
}
func (t *blackjackTable) prompt() []Event {
// Every freshly-prompted turn gets a full action window; rejected inputs
- // and timeouts can never extend the deadline. The turn ceiling restarts
- // with the window for the same reason.
+ // and timeouts can never extend the deadline.
t.timer.start(t.Rules.ActionTicks)
- t.ceiling.start(t.Rules.ActionTicks * loneTurnGrace)
name := t.activeName()
if name == "" {
return t.settle()
}
+ // The absolute turn ceiling belongs to the turn, not the prompt: restart
+ // it only when the active (player, hand) changes. A seat event that
+ // re-prompts the same turn must not reset a hung bettor's deadline.
+ if name != t.turnName || t.activeHand != t.turnHand {
+ t.ceiling.start(t.Rules.ActionTicks * loneTurnGrace)
+ t.turnName = name
+ t.turnHand = t.activeHand
+ }
p := t.players.get(name)
h := p.hands[t.activeHand]
ranks := make([]string, len(h.cards))
@@ -710,6 +727,7 @@ func (t *blackjackTable) resetRound() {
t.window.close()
t.timer.stop()
t.ceiling.stop()
+ t.turnName, t.turnHand = "", -1
for _, p := range t.players.ordered() {
p.HasBet, p.Wager, p.hands, p.insurance, p.insuranceStakeID, p.declinedInsurance = false, Wager{}, nil, 0, "", false
}
@@ -837,7 +855,8 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event {
}
switch {
case index < t.activePlayer:
- // The same player stays active, one seat earlier.
+ // The same player stays active, one seat earlier. prompt() sees the
+ // unchanged turn and keeps the existing ceiling.
t.activePlayer--
case index == t.activePlayer:
// The next bettor slides into the active seat.
diff --git a/internal/casino/cards.go b/internal/casino/cards.go
index 27da8aa..2d53b4a 100644
--- a/internal/casino/cards.go
+++ b/internal/casino/cards.go
@@ -73,6 +73,14 @@ func (s *shoe) remaining() int { return len(s.cards) }
func (s *shoe) draw() card {
if len(s.cards) == 0 {
+ if s.decks <= 0 {
+ // A synthetic, explicitly-scripted shoe (tests) has no decks to
+ // rebuild from and no cards left; return a visibly-invalid sentinel
+ // card rather than panicking. Production shoes always have Decks >
+ // 0 (Normalize), so this only surfaces a test fixture that drew
+ // past its script.
+ return card{rank: "?", suit: "?"}
+ }
// Shuffle-through: if the shoe runs out mid-round it is rebuilt from
// all decks, so a card already dealt this round can reappear. This
// matches real blackjack, where an exhausted shoe is shuffled through
diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go
index ee8e8c9..38d426d 100644
--- a/internal/casino/config_validate.go
+++ b/internal/casino/config_validate.go
@@ -141,6 +141,37 @@ func (p SlotProfile) Validate() error {
return fmt.Errorf("unsupported slot geometry %dx%d: the runtime evaluator supports %dx%d only",
profile.Rows, profile.Reels, slotGridRows, slotGridReels)
}
+ // A named trigger must exist, or the feature silently never fires while
+ // the RTP model also computes a zero trigger probability and reports green.
+ if profile.FreeSpinTrigger != "" {
+ found := false
+ positiveWeight := false
+ for _, symbol := range profile.Symbols {
+ if symbol.ID == profile.FreeSpinTrigger {
+ found = true
+ positiveWeight = symbol.Weight > 0
+ break
+ }
+ }
+ if !found {
+ return fmt.Errorf("free_spin_trigger %q names no symbol in symbols", profile.FreeSpinTrigger)
+ }
+ if !positiveWeight {
+ return fmt.Errorf("free_spin_trigger %q names a symbol with no reel weight, so the feature can never trigger", profile.FreeSpinTrigger)
+ }
+ }
+ // A paytable must not have a gap: evaluateSlot pays only the longest run,
+ // so Payout{3: 0.5} with no Payout[4] makes every 4-run pay zero — less
+ // than the 3-run — while the RTP model agrees and nothing else flags it.
+ // A symbol whose shorter runs also pay zero (e.g. only a 5-run pays) is a
+ // legitimate shape and is not flagged.
+ for _, symbol := range profile.Symbols {
+ for length := 4; length <= 5; length++ {
+ if symbol.Payout[length] <= 0 && symbol.Payout[length-1] > 0 {
+ return fmt.Errorf("symbol %q pays for a run of %d but not %d: a longer run would pay zero", symbol.ID, length-1, length)
+ }
+ }
+ }
return nil
}
diff --git a/internal/casino/credits.go b/internal/casino/credits.go
index 8b7cb7a..631a4bc 100644
--- a/internal/casino/credits.go
+++ b/internal/casino/credits.go
@@ -47,6 +47,12 @@ func MulPayout(amount int, multiplier float64) (int, error) {
return MaxCredits, fmt.Errorf("payout overflow: %d * %v exceeds %d", amount, multiplier, MaxCredits)
}
payout := int(math.Floor(float64(amount) * multiplier))
+ if payout == 0 && multiplier > 0 {
+ // A positive stake on a winning outcome must never pay nothing: a
+ // fractional multiplier (e.g. a 5% baccarat commission) at a 1-credit
+ // bet floors to zero, turning a win into a total loss.
+ payout = 1
+ }
if payout > MaxCredits {
return MaxCredits, fmt.Errorf("payout overflow: %d * %v exceeds %d", amount, multiplier, MaxCredits)
}
diff --git a/internal/casino/hardening_fixes_batch2_test.go b/internal/casino/hardening_fixes_batch2_test.go
new file mode 100644
index 0000000..1391143
--- /dev/null
+++ b/internal/casino/hardening_fixes_batch2_test.go
@@ -0,0 +1,108 @@
+package casino
+
+import "testing"
+
+// TestMulPayoutPositiveBetNeverZero: a positive stake on a winning outcome
+// must not floor to zero (a 1-credit banker win at 0.95 paid nothing).
+func TestMulPayoutPositiveBetNeverZero(t *testing.T) {
+ if got, err := MulPayout(1, 0.95); err != nil || got != 1 {
+ t.Fatalf("MulPayout(1, 0.95) = %d, %v; want 1", got, err)
+ }
+ if got, err := MulPayout(1, 0.5); err != nil || got != 1 {
+ t.Fatalf("MulPayout(1, 0.5) = %d, %v; want 1", got, err)
+ }
+ // A zero amount is still zero.
+ if got, err := MulPayout(0, 0.95); err != nil || got != 0 {
+ t.Fatalf("MulPayout(0, 0.95) = %d, %v; want 0", got, err)
+ }
+}
+
+// TestCalculateRTPIncludesTriggerPayout: the runtime pays a paying scatter
+// like any other symbol, so the RTP model must include it.
+func TestCalculateRTPIncludesTriggerPayout(t *testing.T) {
+ base := DefaultHuffAndPuffProfile()
+ withPay := DefaultHuffAndPuffProfile()
+ for i := range withPay.Symbols {
+ if withPay.Symbols[i].ID == withPay.FreeSpinTrigger {
+ withPay.Symbols[i].Payout = map[int]float64{3: 1, 4: 2, 5: 5}
+ }
+ }
+ if CalculateRTP(withPay) <= CalculateRTP(base) {
+ t.Fatalf("paying trigger did not raise RTP: %v <= %v", CalculateRTP(withPay), CalculateRTP(base))
+ }
+}
+
+// TestSlotProfileRejectsMissingTriggerSymbol: a named trigger that matches no
+// symbol silently disables the feature.
+func TestSlotProfileRejectsMissingTriggerSymbol(t *testing.T) {
+ p := DefaultHuffAndPuffProfile()
+ p.FreeSpinTrigger = "ghost"
+ if err := p.Validate(); err == nil {
+ t.Fatal("accepted a free_spin_trigger that names no symbol")
+ }
+}
+
+// TestSlotProfileRejectsPaytableGap: a positive payout for a shorter run with
+// no payout for a longer run makes the longer run pay zero.
+func TestSlotProfileRejectsPaytableGap(t *testing.T) {
+ p := DefaultHuffAndPuffProfile()
+ for i := range p.Symbols {
+ if p.Symbols[i].ID == "straw" {
+ p.Symbols[i].Payout = map[int]float64{3: 0.5}
+ }
+ }
+ if err := p.Validate(); err == nil {
+ t.Fatal("accepted a paytable with a gap")
+ }
+}
+
+// TestSlotProfileRejectsWeightlessTrigger: a trigger symbol with no reel
+// weight never appears, so the feature silently never fires.
+func TestSlotProfileRejectsWeightlessTrigger(t *testing.T) {
+ p := DefaultHuffAndPuffProfile()
+ for i := range p.Symbols {
+ if p.Symbols[i].ID == p.FreeSpinTrigger {
+ p.Symbols[i].Weight = 0
+ }
+ }
+ if err := p.Validate(); err == nil {
+ t.Fatal("accepted a free_spin_trigger with no reel weight")
+ }
+}
+
+// TestDisconnectingNonBettorDoesNotResetLoneTurnCeiling: a third party's
+// seat event re-prompts the same turn and must not extend a hung lone
+// bettor's absolute deadline.
+func TestDisconnectingNonBettorDoesNotResetLoneTurnCeiling(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 3,
+ Blackjack: BlackjackConfig{ActionTicks: 2}}, newTestRand())
+ table.shoe = tableauShoe(
+ card{rank: "10"}, card{rank: "7"}, // dealer 17
+ card{rank: "10"}, card{rank: "6"}, // alice 16
+ )
+ wallet := &testWallet{credits: 100}
+ table.join("alice")
+ table.join("bob")
+ table.bet("alice", "", 10, wallet)
+
+ // Let the betting window expire so the round deals with alice the only
+ // bettor and bob seated as a non-bettor.
+ for i := 0; i < 200 && table.phase != blackjackPlaying; i++ {
+ table.tick()
+ }
+ if table.phase != blackjackPlaying {
+ t.Fatalf("setup: round did not start (phase=%s)", table.phase)
+ }
+ for i := 0; i < 3; i++ {
+ table.tick()
+ }
+ before := table.ceiling.clock
+ if before == 0 {
+ t.Fatal("setup: turn ceiling did not advance")
+ }
+
+ table.markDisconnected("bob")
+ if table.ceiling.clock != before {
+ t.Fatalf("a non-bettor's disconnect reset the turn ceiling: %d -> %d", before, table.ceiling.clock)
+ }
+}
diff --git a/internal/casino/slots.go b/internal/casino/slots.go
index 64caf85..6be5004 100644
--- a/internal/casino/slots.go
+++ b/internal/casino/slots.go
@@ -182,9 +182,6 @@ func CalculateRTP(profile SlotProfile) float64 {
if symbol.ID == p.FreeSpinTrigger {
scatterProbability = probability
}
- if symbol.ID == p.FreeSpinTrigger {
- continue
- }
for length := 3; length <= 5; length++ {
ways := math.Pow(float64(p.Rows), float64(length))
nextReelNoMatch := 1.0
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 080e60a..71da01c 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -162,6 +162,11 @@ func (c TableConfig) Normalize() TableConfig {
if c.MaxBet < c.MinBet {
c.MaxBet = c.MinBet
}
+ // Normalize the nested game rule blocks too: a new game that reads
+ // t.Config.Blackjack/Baccarat directly (rather than only t.Rules) would
+ // otherwise see zero values and build an empty shoe.
+ c.Blackjack = c.Blackjack.Normalize()
+ c.Baccarat = c.Baccarat.Normalize()
return c
}