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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
|
package game
import (
"fmt"
"regexp"
"strings"
"testing"
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/color"
"thehouseoficarus/internal/combat"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
"thehouseoficarus/internal/world"
)
var rouletteSGR = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func plainRoulette(s string) string { return rouletteSGR.ReplaceAllString(s, "") }
func TestRouletteArtModes(t *testing.T) {
for _, mode := range []string{"none", "ansi", "xterm256"} {
for _, unicode := range []bool{false, true} {
for _, double := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/unicode=%v/double=%v", mode, unicode, double), func(t *testing.T) {
wheelOrder := casino.WheelOrder(double)
s := &casino.RouletteSnapshot{DoubleZero: double, Wheel: wheelOrder}
for _, phase := range []string{"betting", "playing", "result"} {
s.Phase = phase
s.Pockets = [3]string{"26", "0", "32"}
art := renderRouletteSnapshot(s, mode, unicode, 7)
plain := plainRoulette(art)
if mode == "xterm256" && unicode && double {
t.Logf("%s preview (colors stripped):\n%s", phase, plain)
}
if strings.Contains(plain, "\x1b") {
t.Fatal("malformed ANSI")
}
if mode == "none" && art != plain {
t.Fatal("color disabled but ANSI present")
}
for _, r := range plain {
if !unicode && r > 127 {
t.Fatalf("non-ASCII rune %q in ASCII mode", r)
}
}
for _, line := range strings.Split(art, "\n") {
if color.VisibleLen(line) > 78 {
t.Fatalf("line exceeds terminal width: %q", plainRoulette(line))
}
}
if mode != "none" {
for _, bg := range []int{0, 1, 2} {
if !strings.Contains(art, color.BgCode(mode, bg)) {
t.Fatalf("missing pocket background %d", bg)
}
}
if !strings.Contains(art, color.FgCode(mode, 15)) || !strings.Contains(art, color.StyleCode("bold")) {
t.Fatal("missing bold white text")
}
if !strings.HasSuffix(art, color.Reset) {
t.Fatal("art leaves terminal colored")
}
}
}
layoutArt := renderRouletteLayout(double, false, mode, unicode)
layout := plainRoulette(layoutArt)
rows := strings.Split(layout, "\n")
if double {
if !strings.Contains(string([]rune(rows[1])[:7]), "0") || !strings.Contains(string([]rune(rows[5])[:7]), "00") {
t.Fatal("double zero not left of grid", layout)
}
} else if !strings.Contains(string([]rune(rows[3])[:7]), "0") {
t.Fatal("zero not left of grid", layout)
}
for _, target := range []string{"1to12", "13to24", "25to36", "row1", "row2", "row3", "red", "black", "odd", "even", "low", "high"} {
if !strings.Contains(layout, target) {
t.Fatal("missing bet", target)
}
}
// The odds reminder belongs to "table", never the board.
for _, reminder := range []string{"Numbers 35:1", "Low: 1-18", "bet 10 on"} {
if strings.Contains(layout, reminder) {
t.Fatal("reminder text leaked onto the betting board")
}
}
if mode != "none" {
// The whole 0/00 lane is green: the three numbered rows plus
// the two separator rows that run through the lane.
wantGreen := 5
if got := strings.Count(layoutArt, color.BgCode(mode, 2)); got != wantGreen {
t.Fatalf("green cells on board = %d, want %d", got, wantGreen)
}
}
wheelArt := renderRouletteWheel(wheelOrder, mode, unicode)
plainWheel := plainRoulette(wheelArt)
lines := strings.Split(plainWheel, "\n")
if len(lines) != 9 {
t.Fatalf("wheel height = %d, want 9", len(lines))
}
if strings.ContainsAny(plainWheel, "0123456789") {
t.Fatalf("wheel should not show numbers:\n%s", plainWheel)
}
if mode == "none" || mode == "" {
for _, r := range "RBG" {
if !strings.ContainsRune(plainWheel, r) {
t.Fatalf("colorless wheel missing %q:\n%s", r, plainWheel)
}
}
// The top and bottom rows must be several cells wide so the
// disc reads as a circle rather than a pointed oval.
for _, idx := range []int{0, 8} {
if got := len(strings.TrimSpace(lines[idx])); got != 5 {
t.Fatalf("wheel row %d = %d wide, want 5:\n%s", idx, got, plainWheel)
}
}
} else {
for _, bg := range []int{0, 1, 2} {
if !strings.Contains(wheelArt, color.BgCode(mode, bg)) {
t.Fatalf("wheel missing pocket background %d", bg)
}
}
}
result := plainRoulette(renderRouletteResult([3]string{"1", "00", "27"}, mode, unicode, 7))
for _, banned := range []string{"PREV", "BALL", "NEXT", "WINNER"} {
if strings.Contains(result, banned) {
t.Fatalf("result prose %q should be removed:\n%s", banned, result)
}
}
})
}
}
}
}
// TestRouletteWheelGreenPockets checks that the single-zero wheel has one green
// pocket and the American wheel adds the diametrically opposite one.
func TestRouletteWheelGreenPockets(t *testing.T) {
single := plainRoulette(renderRouletteWheel(casino.WheelOrder(false), "none", true))
double := plainRoulette(renderRouletteWheel(casino.WheelOrder(true), "none", true))
singleGreen := strings.Count(single, "G")
doubleGreen := strings.Count(double, "G")
if singleGreen == 0 {
t.Fatalf("single-zero wheel has no green:\n%s", single)
}
if doubleGreen <= singleGreen {
t.Fatalf("double-zero green = %d, want more than single-zero %d:\n%s", doubleGreen, singleGreen, double)
}
}
// TestRouletteLayoutConnections guards the box-drawing joints: the dozen-row
// boxes must connect to the numbered grid above and be closed under, the
// row-bet cells must be boxed on the right, and the bottom rule must tee under
// each outside-bet divider.
func TestRouletteLayoutConnections(t *testing.T) {
for _, double := range []bool{false, true} {
art := plainRoulette(renderRouletteLayout(double, false, "xterm256", true))
rows := strings.Split(art, "\n")
// rows[6] is the numbered grid's bottom edge, directly above the dozen
// labels. The dozen boundaries and the grid/row-lane joint cross; the
// row-bet lane closes with a bottom-right corner.
gridBottom := []rune(rows[6])
for _, col := range []int{6, 26, 46, 66} {
if gridBottom[col] != '┼' {
t.Fatalf("double=%v: grid bottom col %d = %q, want ┼:\n%s", double, col, gridBottom[col], art)
}
}
if gridBottom[73] != '┘' {
t.Fatalf("double=%v: grid bottom row-lane corner = %q, want ┘:\n%s", double, gridBottom[73], art)
}
// The row-bet lane is boxed: its separators cross at the grid edge and
// close on the right.
for _, idx := range []int{2, 4} {
sep := []rune(rows[idx])
if sep[66] != '┼' || sep[73] != '┤' {
t.Fatalf("double=%v: row lane separator %q lacks box joints:\n%s", double, rows[idx], art)
}
}
// rows[8] is the dozen boxes' bottom edge: tees point down where the
// even-money dividers sit and crosses pass the dozen dividers through.
dozenBottom := []rune(rows[8])
wantDozen := map[int]rune{6: '├', 16: '┬', 26: '┼', 36: '┬', 46: '┼', 56: '┬', 66: '┤'}
for col, want := range wantDozen {
if dozenBottom[col] != want {
t.Fatalf("double=%v: dozen bottom col %d = %q, want %q:\n%s", double, col, dozenBottom[col], want, art)
}
}
// The last line is the table's bottom rule; it tees upward under each
// outside-bet divider.
bottom := []rune(rows[len(rows)-1])
for _, col := range []int{16, 26, 36, 46, 56} {
if bottom[col] != '┴' {
t.Fatalf("double=%v: table bottom col %d = %q, want ┴:\n%s", double, col, bottom[col], art)
}
}
}
}
// TestRouletteZeroPanel checks the two zero-panel fixes: the whole five-row
// lane carries the green background, and its outer corners are rounded in
// Unicode mode while ASCII mode stays square.
func TestRouletteZeroPanel(t *testing.T) {
for _, double := range []bool{false, true} {
colored := renderRouletteLayout(double, false, "xterm256", true)
rows := strings.Split(colored, "\n")
// Interior rows 1-5: top numbered row, separator, middle, separator,
// bottom numbered row. Each must contain the green background.
for row := 1; row <= 5; row++ {
if !strings.Contains(rows[row], color.BgCode("xterm256", 2)) {
t.Fatalf("double=%v: zero lane row %d is not green:\n%s", double, row, plainRoulette(colored))
}
}
plainRows := strings.Split(plainRoulette(colored), "\n")
if !strings.HasPrefix(plainRows[0], "╭") || !strings.HasPrefix(plainRows[6], "╰") {
t.Fatalf("double=%v: zero panel corners are not rounded:\n%s", double, plainRoulette(colored))
}
ascii := plainRoulette(renderRouletteLayout(double, false, "none", false))
for _, r := range ascii {
if r > 127 {
t.Fatalf("double=%v: non-ASCII rune %q in ASCII mode", double, r)
}
}
}
}
// TestRouletteRacetrackPanel checks that an enabled racetrack adds the call-bet
// panel above the board, names all four call bets and the neighbours bet, and
// keeps the same width and ASCII/Unicode guarantees as the board.
func TestRouletteRacetrackPanel(t *testing.T) {
for _, mode := range []string{"none", "ansi", "xterm256"} {
for _, unicode := range []bool{false, true} {
art := renderRouletteLayout(false, true, mode, unicode)
plain := plainRoulette(art)
for _, want := range []string{"Voisins du Zero", "Tiers du Cylindre", "Orphelins", "Zero Spiel", "Neighbors (9)"} {
if !strings.Contains(plain, want) {
t.Fatalf("mode=%s unicode=%v: missing %q:\n%s", mode, unicode, want, plain)
}
}
for _, line := range strings.Split(art, "\n") {
if color.VisibleLen(line) > 78 {
t.Fatalf("mode=%s unicode=%v: line exceeds terminal width: %q", mode, unicode, plainRoulette(line))
}
if !unicode {
for _, r := range plainRoulette(line) {
if r > 127 {
t.Fatalf("mode=%s: non-ASCII rune %q in ASCII mode", mode, r)
}
}
}
}
}
}
// The panel comes from the snapshot flag, and a plain board omits it.
snap := &casino.RouletteSnapshot{Phase: "betting", Wheel: casino.WheelOrder(false), Racetrack: true}
if !strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins du Zero") {
t.Fatal("racetrack snapshot did not draw the panel")
}
snap.Racetrack = false
if strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins du Zero") {
t.Fatal("plain snapshot drew the racetrack panel")
}
}
func TestRouletteResultSizesAndNeighbors(t *testing.T) {
for _, unicode := range []bool{false, true} {
for _, size := range []int{1, 5, 7, 9} {
art := plainRoulette(renderRouletteResult([3]string{"1", "00", "27"}, "xterm256", unicode, size))
lines := strings.Split(art, "\n")
height := max(5, cardRenderSize(unicode, size))
width := height + 2
if len(lines) != height {
t.Fatalf("size=%d, height=%d want %d", size, len(lines), height)
}
for _, line := range lines {
if color.VisibleLen(line) != 3*width+2 {
t.Fatalf("unequal pocket widths: %q", line)
}
}
center := []rune(lines[1])
if !strings.Contains(string(center[:width]), "1") || !strings.Contains(string(center[width+1:2*width+1]), "00") || !strings.Contains(string(center[2*width+2:]), "27") {
t.Fatal("wrong pocket order", art)
}
ball := "o"
if unicode {
ball = "●"
}
if strings.Count(art, ball) != 1 {
t.Fatal("missing or duplicate ball", art)
}
}
}
}
// TestRouletteResultLayout checks the new card shape: number on top, a rule
// under it, the ball one row above centre, and the color spelled out on the
// bottom row only when color is off. cardRenderSize caps non-Unicode cards at
// five rows, so the full nine-row shape is exercised through Unicode.
func TestRouletteResultLayout(t *testing.T) {
pockets := [3]string{"1", "10", "27"} // RED, BLACK, RED
for _, tc := range []struct {
unicode bool
size int
height int
}{
{true, 9, 9},
{true, 7, 7},
{true, 5, 5},
{false, 9, 5},
} {
unicode, height := tc.unicode, tc.height
ball := "o"
rule := "-"
if unicode {
ball = "●"
rule = "─"
}
ballRow := height / 2
colorRow := height - 2
hasRule := ballRow > 2
colored := plainRoulette(renderRouletteResult(pockets, "xterm256", unicode, tc.size))
lines := strings.Split(colored, "\n")
if len(lines) != height {
t.Fatalf("unicode=%v size=%d: height=%d, want %d", unicode, tc.size, len(lines), height)
}
for i, want := range pockets {
if !strings.Contains(lines[1], want) {
t.Fatalf("unicode=%v size=%d: top row missing pocket %d (%s):\n%s", unicode, tc.size, i, want, colored)
}
}
if hasRule {
if !strings.Contains(lines[2], rule) {
t.Fatalf("unicode=%v size=%d: missing rule under the number:\n%s", unicode, tc.size, colored)
}
}
if !strings.Contains(lines[ballRow], ball) {
t.Fatalf("unicode=%v size=%d: ball should be on row %d:\n%s", unicode, tc.size, ballRow, colored)
}
if strings.Contains(colored, "RED") || strings.Contains(colored, "BLACK") {
t.Fatalf("unicode=%v size=%d: color label shown while color is on:\n%s", unicode, tc.size, colored)
}
plain := plainRoulette(renderRouletteResult(pockets, "none", unicode, tc.size))
plainLines := strings.Split(plain, "\n")
if strings.Contains(plainLines[1], "RED") || strings.Contains(plainLines[1], "BLACK") {
t.Fatalf("unicode=%v size=%d: color label should not be on the top row:\n%s", unicode, tc.size, plain)
}
if !strings.Contains(plainLines[colorRow], "RED") || !strings.Contains(plainLines[colorRow], "BLACK") {
t.Fatalf("unicode=%v size=%d: missing color label on row %d:\n%s", unicode, tc.size, colorRow, plain)
}
}
}
func TestRouletteEventColorsDoNotWrapArt(t *testing.T) {
for _, public := range []bool{false, true} {
for _, mode := range []string{"none", "ansi", "xterm256"} {
g := &Game{Casino: casino.NewManager(1), Hub: net.NewHub()}
p := player.New("alice")
p.RoomID = 1
p.Options = map[string]any{"color": mode, "unicode": true}
conn := &casinoTestConn{}
sess := &net.Session{Player: p, Conn: conn}
g.loggedInChars = map[string]*net.Session{"alice": sess}
g.Hub.EnterRoom(sess, 1)
// Use an emphatic blue background to detect whole-message color wrapping.
sess.Account = &player.Account{Colors: map[string]string{"casino_action": "0F bg:04 bold"}}
g.Casino.Join(1, casino.TableConfig{ID: "r", Game: casino.GameRoulette}, p.Name)
e := casino.Event{RoomID: 1, TableID: "r", Message: "Spinning now", Color: "casino_action", Public: public, Snapshot: &casino.RouletteSnapshot{Phase: "playing", Players: []string{p.Name}}}
if !public {
e.PrivateTo = p.Name
}
g.emitCasinoEvents([]casino.Event{e})
output := strings.ReplaceAll(string(conn.data), "\r\n", "\n")
expected := g.colorize(sess, "casino_action", e.Message) + "\n" + g.renderCasinoSnapshot(sess, e.Snapshot) + "\n"
if output != expected {
t.Fatalf("public=%v mode=%s: prose color leaked into art or snapshot missing\n%q\nwant\n%q", public, mode, output, expected)
}
}
}
}
func TestRouletteSettlementShowsBalanceOnce(t *testing.T) {
for _, action := range []string{"", "sitout", "leave_after"} {
g := &Game{Casino: casino.NewManager(7), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}}
p := player.New("alice")
p.Credits = 1000
p.Options = map[string]any{"color": "none"}
conn := &casinoTestConn{}
sess := &net.Session{Player: p, Conn: conn}
g.loggedInChars = map[string]*net.Session{"alice": sess}
cfg := casino.TableConfig{ID: "r", Game: casino.GameRoulette, BettingTicks: 1, Roulette: casino.RouletteConfig{SpinTicks: 1}}
g.Casino.Join(1, cfg, p.Name)
g.Casino.Bet(1, "r", p.Name, "dozen2", 10, casinoWallet{g: g, p: p, saveCharacter: func(*player.Player) error { return nil }})
if action != "" {
g.Casino.Action(1, "r", p.Name, action)
}
g.Casino.Tick()
g.emitCasinoEvents(g.Casino.Tick())
if got := strings.Count(string(conn.data), "Available "); got != 1 {
t.Fatalf("action=%q: balance printed %d times\n%s", action, got, conn.data)
}
}
}
|