From 3db3071c444abb2133e1fcaf7e685095415c1adf Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 29 Sep 2026 14:15:06 -0400 Subject: feat: better roulette wheel design --- internal/game/render_roulette_test.go | 217 ++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 internal/game/render_roulette_test.go (limited to 'internal/game/render_roulette_test.go') diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go new file mode 100644 index 0000000..1dde5ad --- /dev/null +++ b/internal/game/render_roulette_test.go @@ -0,0 +1,217 @@ +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{"column1", "column2", "column3", "dozen1", "dozen2", "dozen3", "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" { + wantGreen := 1 + if double { + wantGreen = 2 + } + 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) != 23 { + t.Fatalf("wheel height = %d, want 23", len(lines)) + } + wantDoubleZeroLabel := 0 + if double { + wantDoubleZeroLabel = 1 + } + if got := strings.Count(plainWheel, "00"); got != wantDoubleZeroLabel { + t.Fatalf("double-zero labels = %d, want %d", got, wantDoubleZeroLabel) + } + if mode != "none" { + wantGreen := 1 + if double { + wantGreen = 2 + } + greens := strings.Count(wheelArt, color.BgCode(mode, 2)) + if greens != wantGreen { + t.Fatalf("green pockets = %d, want %d", greens, wantGreen) + } + total := greens + strings.Count(wheelArt, color.BgCode(mode, 0)) + strings.Count(wheelArt, color.BgCode(mode, 1)) + if total != len(wheelOrder) { + t.Fatalf("drawn pockets = %d, want %d", total, len(wheelOrder)) + } + } + + 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) + } + } + }) + } + } + } +} + +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[height/2]) + 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) + } + } + } +} + +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) + } + } +} -- cgit v1.2.3