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
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
|
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", "result"} {
s.Phase = phase
s.Pockets = [5]string{"25", "26", "0", "32", "15"}
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" {
pocketBgs := []int{0, 1, 2}
if phase == "playing" {
pocketBgs = []int{rouletteWheelBlack, 1, 2}
}
for _, bg := range pocketBgs {
if !strings.Contains(art, color.BgCode(mode, bg)) {
t.Fatalf("phase=%s missing pocket background %d", phase, bg)
}
}
if !strings.Contains(art, color.FgCode(mode, 15)) || !strings.Contains(art, color.StyleCode("bold")) {
t.Fatal("missing bold white text")
}
if !strings.HasSuffix(strings.TrimRight(art, "\n"), 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]), "00") || !strings.Contains(string([]rune(rows[5])[:7]), "0") {
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{rouletteWheelBlack, 1, 2} {
if !strings.Contains(wheelArt, color.BgCode(mode, bg)) {
t.Fatalf("wheel missing pocket background %d", bg)
}
}
}
result := plainRoulette(renderRouletteResult([5]string{"36", "1", "00", "27", "13"}, 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 the four call-bet areas (not Neighbors), 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{"Tiers du", "Cylindre", "Orphelins", "Voisins du Zero", "Jeu"} {
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") {
t.Fatal("racetrack snapshot did not draw the panel")
}
snap.Racetrack = false
if strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins") {
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([5]string{"36", "1", "00", "27", "13"}, "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) != 5*width+4 {
t.Fatalf("unequal pocket widths: %q", line)
}
}
top := plainRoulette(strings.Split(art, "\n")[1])
last := -1
for _, pocket := range []string{"36", "1", "00", "27", "13"} {
at := strings.Index(top, pocket)
if at < 0 || at < last {
t.Fatalf("wrong pocket order for %s: %q", pocket, top)
}
last = at
}
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 := [5]string{"1", "10", "27", "2", "3"} // RED, BLACK, 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", SpinFrame: 1, 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)
}
// A lone player's spin starts on the bet, so the first tick settles.
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)
}
}
}
// Read the perimeter clockwise, not in terminal row order. Every displayed
// pocket must match the actual European wheel, including the wraparound.
func TestRouletteRacetrackPhysicalOrder(t *testing.T) {
for _, unicode := range []bool{false, true} {
art := renderRouletteRacetrack("none", unicode)
rows := strings.Split(art, "\n")
top, bottom := racetrackNumbers(rows[1]), racetrackNumbers(rows[9])
if len(top) != 16 || len(bottom) != 15 {
t.Fatalf("missing track pockets:\n%s", art)
}
order := append([]string(nil), top...)
for _, i := range []int{3, 5, 7} {
side := racetrackNumbers(rows[i])
if len(side) != 2 {
t.Fatalf("missing curved-end pockets:\n%s", art)
}
order = append(order, side[1])
}
for i := len(bottom) - 1; i >= 0; i-- {
order = append(order, bottom[i])
}
for _, i := range []int{7, 5, 3} {
order = append(order, racetrackNumbers(rows[i])[0])
}
wheel := casino.WheelOrder(false)
if len(order) != len(wheel) {
t.Fatalf("got %d pockets", len(order))
}
for i, p := range order {
if p != wheel[(19+i)%len(wheel)] {
t.Errorf("pocket %d: %s", i, p)
}
}
if got := racetrackNumbers(rows[5]); got[1] != "26" {
t.Fatalf("right tip is %s, want 26", got[1])
}
}
}
func racetrackNumbers(row string) []string {
var out []string
for _, match := range regexp.MustCompile(`([0-9]+)[RBG]`).FindAllStringSubmatch(row, -1) {
out = append(out, match[1])
}
return out
}
// The sections must touch their actual bet coverage, not merely contain
// labels. Zero is a nested part of Voisins, not a fourth disjoint sector.
func TestRouletteRacetrackSections(t *testing.T) {
for _, unicode := range []bool{false, true} {
rows := strings.Split(renderRouletteRacetrack("none", unicode), "\n")
segment := func(y, left, right int) string { return string([]rune(rows[y])[left:right]) }
nums := func(y, left, right int) []string { return racetrackNumbers(segment(y, left, right)) }
sections := map[string][]string{
"tiers": append(nums(1, 4, 20), nums(9, 4, 24)...),
"orphelins": append(nums(1, 20, 40), nums(9, 24, 40)...),
"voisins": append(nums(1, 40, 60), nums(9, 40, 60)...),
"jeuzero": append(nums(1, 60, 68), nums(9, 60, 68)...),
}
for _, y := range []int{3, 5, 7} {
sections["tiers"] = append(sections["tiers"], nums(y, 0, 4)...)
sections["jeuzero"] = append(sections["jeuzero"], nums(y, 68, 73)...)
}
sections["voisins"] = append(sections["voisins"], sections["jeuzero"]...)
for _, bet := range casino.RacetrackBets() {
got := sections[bet.Syntax]
if len(got) != len(bet.Series) {
t.Fatalf("%s: coverage %v, want %v", bet.Syntax, got, bet.Series)
}
seen := map[string]bool{}
for _, n := range got {
seen[n] = true
}
for _, n := range bet.Series {
if !seen[n] {
t.Errorf("%s does not touch %s", bet.Syntax, n)
}
}
}
// Labels wrap onto the rows around the middle so no glyph touches a
// divider; every field keeps a blank cell on both sides of its text.
for _, label := range []struct {
row, left, right int
text string
}{
{4, 5, 20, "Tiers du"}, {6, 5, 22, "Cylindre"},
{5, 23, 40, "Orphelins"}, {5, 41, 58, "Voisins du Zero"},
{4, 60, 68, "Jeu"}, {6, 60, 68, "Zero"},
} {
field := segment(label.row, label.left, label.right)
if strings.TrimSpace(field) != label.text {
t.Errorf("misplaced %q label on row %d: %q", label.text, label.row, field)
}
if !strings.HasPrefix(field, " ") || !strings.HasSuffix(field, " ") {
t.Errorf("%q label touches a divider on row %d: %q", label.text, label.row, field)
}
}
// Every step joins orthogonally; no font-dependent diagonal gaps.
b := rouletteBorders(unicode)
for _, step := range []struct {
x, y int
text string
}{
{20, 3, b.v}, {20, 4, b.bl + b.h + b.tr}, {22, 5, b.v},
{22, 6, b.bl + b.h + b.tr}, {24, 7, b.v},
} {
if got := segment(step.y, step.x, step.x+len([]rune(step.text))); got != step.text {
t.Errorf("disconnected step at %d,%d: %q", step.x, step.y, got)
}
}
// The Zero boundary bows into Voisins then returns to the same column.
for _, p := range [][2]int{{60, 2}, {59, 3}, {58, 4}, {58, 5}, {58, 6}, {59, 7}, {60, 8}} {
if []rune(rows[p[1]])[p[0]] == ' ' {
t.Errorf("gap in Zero curve at %v", p)
}
}
}
for _, mode := range []string{"none", "ansi", "xterm256"} {
for _, unicode := range []bool{false, true} {
art := renderRouletteRacetrack(mode, unicode)
if strings.Contains(strings.ToLower(art), "neighbor") {
t.Fatal("Neighbors belongs in help, not on the racetrack")
}
rows := strings.Split(art, "\n")
for y, row := range rows {
wantWidth := 73
if y == 0 || y == 10 {
wantWidth = 69
}
if y == 1 || y == 9 {
wantWidth = 72
}
if color.VisibleLen(row) != wantWidth {
t.Errorf("mode=%s unicode=%v: row %d width=%d, want %d", mode, unicode, y, color.VisibleLen(row), wantWidth)
}
}
}
}
}
// TestRouletteWheelBlackAndSpacing locks in the visual tweaks: the spinning
// wheel uses a visible dark blue for its black pockets while the betting board
// keeps true black, and the wheel and result graphics get blank line framing.
func TestRouletteWheelBlackAndSpacing(t *testing.T) {
wheelArt := renderRouletteWheel(casino.WheelOrder(false), "xterm256", true)
if !strings.Contains(wheelArt, color.BgCode("xterm256", rouletteWheelBlack)) {
t.Fatal("spinning wheel lost its dark blue black pockets")
}
if strings.Contains(wheelArt, color.BgCode("xterm256", 0)) {
t.Fatal("spinning wheel should not use pure black")
}
board := renderRouletteLayout(false, false, "xterm256", true)
if !strings.Contains(board, color.BgCode("xterm256", 0)) {
t.Fatal("betting board should keep true black pockets")
}
for _, tc := range []struct {
phase string
want string
}{
{"result", renderRouletteResult([5]string{"25", "26", "0", "32", "15"}, "none", true, 7)},
} {
s := &casino.RouletteSnapshot{Wheel: casino.WheelOrder(false), Phase: tc.phase, Pockets: [5]string{"25", "26", "0", "32", "15"}}
art := renderRouletteSnapshot(s, "none", true, 7)
if !strings.HasPrefix(art, "\n") || !strings.HasSuffix(art, "\n") {
t.Fatalf("phase=%s: graphic is not blank-line framed: %q", tc.phase, art)
}
if !strings.Contains(art, tc.want) {
t.Fatalf("phase=%s: framed art lost the graphic", tc.phase)
}
}
}
// The roulette menu draws the board before the wager reminder so the
// instructions follow the table they describe, and the tracker gets a blank
// line above it instead of butting into the heading.
func TestRouletteMenuPutsReminderAfterBoard(t *testing.T) {
g := &Game{Casino: casino.NewManager(1), Hub: net.NewHub(), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}}
p := player.New("alice")
p.RoomID, p.Credits = 1, 1000
p.Options = map[string]any{"color": "none", "unicode": true}
conn := &casinoTestConn{}
sess := &net.Session{Player: p, Conn: conn}
g.loggedInChars = map[string]*net.Session{"alice": sess}
g.Hub.EnterRoom(sess, 1)
cfg := casino.TableConfig{ID: "r", Game: casino.GameRoulette, Roulette: casino.RouletteConfig{Racetrack: true}}
g.emitCasinoEvents(g.Casino.Join(1, cfg, p.Name))
out := strings.ReplaceAll(string(conn.data), "\r\n", "\n")
tracker := strings.Index(out, "History: last 0 spins")
board := strings.Index(out, "R = red, B = black, G = green")
reminder := strings.Index(out, "bet <n> on <bet>")
if tracker < 0 || board < 0 || reminder < 0 {
t.Fatalf("missing tracker, board, or reminder:\n%s", out)
}
if !(tracker < board && board < reminder) {
t.Fatalf("expected tracker before board before reminder, got %d/%d/%d:\n%s", tracker, board, reminder, out)
}
if !strings.Contains(out, "\n\n┌─ History: last 0 spins") {
t.Fatalf("tracker is not preceded by a blank line:\n%s", out)
}
}
|