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, mode, unicode) layout := plainRoulette(layoutArt) rows := strings.Split(layout, "\n") if double { if !strings.Contains(string([]rune(rows[2])[:7]), "0") || !strings.Contains(string([]rune(rows[6])[:7]), "00") { t.Fatal("double zero not left of grid", layout) } } else if !strings.Contains(string([]rune(rows[4])[: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, "xterm256", true)) rows := strings.Split(art, "\n") // rows[7] 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[7]) 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{3, 5} { 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[9] 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[9]) 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, "xterm256", true) rows := strings.Split(colored, "\n") // Interior rows 2-6: top numbered row, separator, middle, separator, // bottom numbered row. Each must contain the green background. for row := 2; row <= 6; 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[1], "╭") || !strings.HasPrefix(plainRows[7], "╰") { t.Fatalf("double=%v: zero panel corners are not rounded:\n%s", double, plainRoulette(colored)) } ascii := plainRoulette(renderRouletteLayout(double, "none", false)) for _, r := range ascii { if r > 127 { t.Fatalf("double=%v: non-ASCII rune %q in ASCII mode", double, r) } } } } 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) } } }