aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/hardening_casino_validate_test.go
blob: 1d27d540902c7c2ae48bf57340aad3e38fbe61d3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
package casino

import (
	"strings"
	"testing"
)

// Slot geometry: CalculateRTP/ValidateRTP are generic over Rows/Reels/Ways
// while the runtime evaluator is pinned to the 3x5 grid, so a profile the
// runtime cannot play must be rejected rather than certified.

func TestSlotProfileGeometryIsValidated(t *testing.T) {
	if err := DefaultHuffAndPuffProfile().Validate(); err != nil {
		t.Fatalf("the built-in 3x5 profile was rejected: %v", err)
	}
	// The Ways model may differ from Rows^Reels (evaluateSlot divides by it),
	// but the grid may not: a 4-row profile would be certified by the RTP
	// gate and then played as a 3-row game.
	wide := SlotProfile{
		Rows: 4, Reels: 5, Ways: 256, TargetRTP: 3,
		Symbols: []SlotSymbol{{ID: "x", Weight: 1, Payout: map[int]float64{3: 1, 4: 2, 5: 3}}},
	}
	if err := ValidateRTP(wide, 1000); err == nil {
		t.Fatal("ValidateRTP certified a profile the runtime evaluator cannot play")
	}
	if err := (MachineConfig{ID: "slots", Game: GameSlots}).Validate(); err != nil {
		t.Fatalf("valid slots machine rejected: %v", err)
	}
	// A profile that only the RTP model supports must not become a machine.
	if err := wide.Validate(); err == nil || !strings.Contains(err.Error(), "geometry") {
		t.Fatalf("unsupported geometry accepted: %v", err)
	}
}

// Shoe coverage is a boundary heuristic, NOT a hard configuration floor. The
// old bound (11 cards per hand x hands) is not a true maximum — an all-ace
// hand can exceed eleven cards — and enforcing it rejected working tables (a
// one-deck eight-player table; a six-deck eight-player table that holds 312
// cards against the old 363-card floor). Because a room is refused whole on
// any validation error, that floor silently darkened every casino game in a
// room and blocked the room from being saved. Validation no longer rejects
// on coverage: shoe.ensureForRound reshuffles at the round boundary and
// draw()'s exhausted-shoe rebuild is a deliberate shuffle-through.

func TestShoeCoverageIsHeuristicNotAHardFloor(t *testing.T) {
	// The shipped eight-player geometry still validates at six decks (312
	// cards) even though the old bound demanded 363.
	sixDecksEightPlayers := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 8,
		Blackjack: BlackjackConfig{Decks: 6}}
	if err := sixDecksEightPlayers.Validate(); err != nil {
		t.Fatalf("a six-deck eight-player table was rejected: %v", err)
	}
	// A one-deck shoe, eight players: previously rejected, now plays with a
	// boundary reshuffle and draw()'s in-place rebuild.
	oneDeck := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 8,
		Blackjack: BlackjackConfig{Decks: 1}}
	if err := oneDeck.Validate(); err != nil {
		t.Fatalf("a one-deck eight-player table was rejected: %v", err)
	}
	// The common hand-edited room: a single-seat one-deck table.
	small := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 1,
		Blackjack: BlackjackConfig{Decks: 1}}
	if err := small.Validate(); err != nil {
		t.Fatalf("a one-deck single-seat table was rejected: %v", err)
	}
	if err := (TableConfig{ID: "bac", Game: GameBaccarat, Baccarat: BaccaratConfig{Decks: 1}}).Validate(); err != nil {
		t.Fatalf("one-deck baccarat rejected: %v", err)
	}

	// The boundary reshuffle still fires with the heuristic round size
	// (the dealer plus every seat's maximum split hands), and the heuristic
	// value is unchanged.
	if got, want := blackjackRoundCards(8, 4), 363; got != want {
		t.Fatalf("blackjackRoundCards(8,4) = %d, want %d", got, want)
	}
	need := blackjackRoundCards(1, 1)
	s := newShoe(1, 0.25, newTestRand())
	s.cards = s.cards[:need-1] // one card short of the heuristic round
	s.ensureForRound(need)
	if s.remaining() != 52 {
		t.Fatalf("shoe holding %d cards was not reshuffled for a %d-card round", need-1, need)
	}

	// draw() on an exhausted shoe rebuilds in place (shuffle-through) rather
	// than panicking — the reason coverage cannot be a hard guarantee.
	empty := &shoe{decks: 1, rng: newTestRand()}
	if card := empty.draw(); card.rank == "" {
		t.Fatal("draw() on an exhausted shoe returned an empty card")
	}
}

// Surrender returns half the bet with integer truncation, so a table whose
// minimum wager is one credit would consume the whole stake on a surrender.

func TestSurrenderRequiresATwoCreditMinimum(t *testing.T) {
	surrender := true
	oneCredit := TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 1,
		Blackjack: BlackjackConfig{Surrender: &surrender}}
	if err := oneCredit.Validate(); err == nil {
		t.Fatal("surrender accepted with a one-credit minimum; half a credit rounds to zero")
	}
	// min_bet 0 normalizes to 1, so it is rejected too.
	if err := (TableConfig{ID: "bj", Game: GameBlackjack,
		Blackjack: BlackjackConfig{Surrender: &surrender}}).Validate(); err == nil {
		t.Fatal("surrender accepted with the default one-credit minimum")
	}
	twoCredits := TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 2,
		Blackjack: BlackjackConfig{Surrender: &surrender}}
	if err := twoCredits.Validate(); err != nil {
		t.Fatalf("surrender with a two-credit minimum rejected: %v", err)
	}
	// A table without surrender keeps its one-credit minimum.
	if err := (TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 1}).Validate(); err != nil {
		t.Fatalf("plain one-credit table rejected: %v", err)
	}
}

// The shoe refills at a round boundary, never between draws: an exhausted
// shoe rebuilt mid-round would deal cards the round already holds.

func TestShoeRefillsOnlyAtRoundBoundaries(t *testing.T) {
	need := blackjackRoundCards(1, 1)
	shoe := newShoe(1, 0.25, newTestRand())
	shoe.cards = shoe.cards[:need-1] // one card short of a maximum round
	shoe.ensureForRound(need)
	if shoe.remaining() != 52 {
		t.Fatalf("shoe holding %d cards was not refilled for a %d-card round", need-1, need)
	}

	before := shoe.remaining()
	shoe.ensureForRound(need)
	if shoe.remaining() != before {
		t.Fatalf("a shoe that already covers the round was reshuffled: %d -> %d", before, shoe.remaining())
	}

	// A shoe without a deck composition is an explicit card script (tests);
	// it must never be replaced by a fresh shuffled deck.
	scripted := tableauShoe(card{rank: "A"})
	scripted.ensureForRound(need)
	if scripted.remaining() != 1 {
		t.Fatalf("scripted shoe was replaced: %d cards remain", scripted.remaining())
	}
}

// A room addresses one table/machine per game name ("play blackjack"), so a
// second entry for the same game is unreachable content, not a second table.

func TestRoomRejectsASecondEntryForOneGame(t *testing.T) {
	twoBlackjackTables := []TableConfig{
		{ID: "bj1", Game: GameBlackjack},
		{ID: "bj2", Game: GameBlackjack},
	}
	err := ValidateRoomConfigs(twoBlackjackTables, nil)
	if err == nil || !strings.Contains(err.Error(), "duplicate casino game") {
		t.Fatalf("two blackjack tables accepted: %v", err)
	}
	// Distinct games, and a machine whose variant name is also addressable.
	ok := ValidateRoomConfigs(
		[]TableConfig{{ID: "blackjack", Game: GameBlackjack}, {ID: "baccarat", Game: GameBaccarat}},
		[]MachineConfig{{ID: "slots", Game: GameSlots}},
	)
	if ok != nil {
		t.Fatalf("one entry per game rejected: %v", ok)
	}
	// A legacy slots table and a slots machine both answer to "slots".
	legacy := ValidateRoomConfigs(
		[]TableConfig{{ID: "legacy_slots", Game: GameSlots}},
		[]MachineConfig{{ID: "slots", Game: GameSlots}},
	)
	if legacy == nil || !strings.Contains(legacy.Error(), "duplicate casino game") {
		t.Fatalf("legacy slots table plus a slots machine accepted: %v", legacy)
	}
	// Two machines of the same variant are equally unreachable.
	twoVariants := ValidateRoomConfigs(nil, []MachineConfig{
		{ID: "slots_a", Game: GameSlots, Variant: "huff_and_even_more_puff"},
		{ID: "slots_b", Game: GameSlots, Variant: "huff_and_even_more_puff"},
	})
	if twoVariants == nil {
		t.Fatal("two machines of one variant accepted")
	}
}