package game import ( "strings" "testing" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) func TestCrapsHalfTableGeometryAndPuck(t *testing.T) { for _, unicode := range []bool{false, true} { for _, point := range []int{0, 4, 5, 6, 8, 9, 10} { s := &casino.CrapsSnapshot{Point: point, Field12: 3} art := renderCrapsFelt(s, "none", unicode) rows := strings.Split(strings.TrimSuffix(art, "\n"), "\n") if len(rows) != 21 { t.Fatal(len(rows)) } for _, row := range rows { if color.VisibleLen(row) != 77 { t.Fatalf("misaligned (%d): %s", color.VisibleLen(row), row) } } // Check connected box edges at the main rail and centre-action partition. if unicode { for _, r := range []rune{'┌', '┬', '┼', '├', '┤', '┴', '┘'} { if !strings.ContainsRune(art, r) { t.Fatalf("missing joint %c", r) } } } v := "|" if unicode { v = "│" } want := v + "OFF" + v if point != 0 { want = v + "ON " + v } if strings.Count(art, want) != 1 { t.Fatalf("missing/duplicate puck %d: %s", point, art) } if !strings.Contains(art, "12 PAYS TRIPLE") { t.Fatal("field variant invisible") } for _, bet := range []string{"DON'T COME", "C O M E", "F I E L D", "DON'T PASS", "PASS LINE", "HARD 4", "HARD 6", "HARD 8", "HARD 10", "SEVEN", "CRAPS", "ELEVEN", "ACES", "ACE 3", "BOXCARS", "HORN", "HOP"} { if strings.Count(art, bet) != 1 { t.Fatalf("missing or duplicate %q", bet) } } } } t.Log("\n" + renderCrapsSnapshot(&casino.CrapsSnapshot{Point: 6, Field12: 3, Dice: [2]int{3, 3}, Queue: []string{"Alice", "Bob"}, Made: []int{4, 5}, Seen: []int{2, 4, 5, 6, 9}, Phase: "betting", ShootIn: 10, DealerIn: 70}, "none", true)) } // TestCrapsFeltOddsLabelAndCompaction pins the compacted felt copy: the // free-odds cap is printed in parentheses, the old behind-the-line suffix is // gone, and FIELD is letter-spaced. func TestCrapsFeltOddsLabelAndCompaction(t *testing.T) { base := &casino.CrapsSnapshot{Point: 6, Field12: 3} art := plainRoulette(renderCrapsFelt(base, "none", true)) for _, want := range []string{"F I E L D", "FREE ODDS (3x-4x-5x)", "DON'T PASS", "BAR 12"} { if !strings.Contains(art, want) { t.Fatalf("felt missing %q", want) } } if strings.Count(art, "BAR 12") != 2 { t.Fatalf("expected BAR 12 on both don't rails: %q", art) } for _, gone := range []string{"BEHIND THE LINE", "DON'T PASS /", "FREE ODDS /"} { if strings.Contains(art, gone) { t.Fatalf("felt still shows %q", gone) } } custom := &casino.CrapsSnapshot{Point: 6, Field12: 3, Odds: "100x"} if !strings.Contains(plainRoulette(renderCrapsFelt(custom, "none", true)), "FREE ODDS (100x)") { t.Fatal("custom odds cap not shown") } } // TestCrapsTrackerBracketsRegularNumbersBold pins the mixed tracker weights: // the bracket glyphs are regular while the total itself stays bold. func TestCrapsTrackerBracketsRegularNumbersBold(t *testing.T) { art := renderCrapsTrackers(&casino.CrapsSnapshot{}, "xterm256", true) regular := color.Render("xterm256", color.ColorSpec{Fg: 229, Bg: 24}, "[") bold := color.Render("xterm256", color.ColorSpec{Fg: 229, Bg: 24, Bold: true}, " 4") if !strings.Contains(art, regular) { t.Fatal("tracker brackets are not regular weight") } if !strings.Contains(art, bold) { t.Fatal("tracker numbers are not bold") } } // TestCrapsFireTrackerHighlightsMadePoints pins the Fire half of the tracker: // made points highlight, un-made points do not, and no asterisk is involved. func TestCrapsFireTrackerHighlightsMadePoints(t *testing.T) { s := &casino.CrapsSnapshot{Point: 6, Made: []int{4, 9}} art := renderCrapsTrackers(s, "xterm256", true) fireRow := "" for _, line := range strings.Split(art, "\n") { if strings.Contains(line, "FIRE") { fireRow = line } } if got := strings.Count(fireRow, color.BgCode("xterm256", 220)); got != 2 { t.Fatalf("expected 2 highlighted fire points, got %d: %q", got, plainRoulette(art)) } plain := plainRoulette(art) for _, want := range []string{"[ 4]", "[ 9]"} { if !strings.Contains(plain, want) { t.Fatalf("missing fire point %s: %q", want, plain) } } if strings.Contains(plain, "*") { t.Fatalf("fire tracker still uses an asterisk: %q", plain) } } func TestCrapsTrackersAndDiceStyles(t *testing.T) { s := &casino.CrapsSnapshot{Point: 6, Made: []int{4, 6}, Seen: []int{2, 12}} art := renderCrapsTrackers(s, "xterm256", true) plain := plainRoulette(art) // Both trackers bracket every number; made/rolled numbers are shown by the // highlight alone, with no asterisk. for _, token := range []string{"[ 4]", "[ 5]", "[ 6]", "[ 2]", "[12]"} { if !strings.Contains(plain, token) { t.Fatal("tracker missing", token) } } if strings.Contains(plain, "*") || strings.Contains(plain, "= rolled") { t.Fatalf("tracker still uses asterisks or the rolled legend: %q", plain) } if strings.Count(art, color.BgCode("xterm256", 220)) != 4 { t.Fatal("wrong number of highlighted tracker tokens") } if !strings.Contains(art, color.BgCode("xterm256", 24)) { t.Fatal("tracker background is not blue") } if strings.Contains(art, color.BgCode("xterm256", 124)) { t.Fatal("tracker still uses a red background") } // The ATS payout panel now lives only on the main felt, and the fire odds // share a single row. if strings.Contains(plain, "DISTINCT POINTS") || strings.Contains(plain, "34:1") || strings.Contains(plain, "175:1") { t.Fatalf("tracker still shows a payout panel: %q", plain) } if !strings.Contains(plain, "4 POINTS 24:1") { t.Fatalf("fire odds not collapsed to one row: %q", plain) } feltArt := renderCrapsFelt(s, "xterm256", true) if !strings.Contains(feltArt, color.BgCode("xterm256", 24)) { t.Fatal("felt Fire/ATS spots are not blue") } if strings.Contains(feltArt, color.BgCode("xterm256", 124)) { t.Fatal("felt still uses a red bonus background") } if !strings.Contains(feltArt, color.BgCode("xterm256", 22)) { t.Fatal("felt not dark green") } g := &Game{} p := player.New("Alice") p.Options = map[string]any{"card_style": "light", "unicode": true} sess := &net.Session{Player: p} s.Dice = [2]int{2, 5} light := g.renderCasinoSnapshot(sess, s) p.Options["card_style"] = "dark" dark := g.renderCasinoSnapshot(sess, s) if light != dark { t.Fatal("craps dice are intentionally style-independent") } for a := 1; a <= 6; a++ { for b := 1; b <= 6; b++ { for _, unicode := range []bool{false, true} { lit := renderCrapsDice([2]int{a, b}, "xterm256", unicode) dim := renderCrapsDice([2]int{a, b}, "xterm256", unicode) if !strings.Contains(lit, color.BgCode("xterm256", 15)) || !strings.Contains(dim, color.BgCode("xterm256", 15)) { t.Fatal("craps dice should always use a white background") } pip := "o" if unicode { pip = "●" } if strings.Count(strings.ReplaceAll(plainRoulette(lit), "Total:", ""), pip) != a+b { t.Fatal("wrong pip count", a, b) } if plainRoulette(lit) != plainRoulette(dim) { t.Fatal("style changed dice outcome") } } } } } func TestCrapsRemindersFollowArt(t *testing.T) { g := &Game{Casino: casino.NewManager(1), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}} g.Casino.Join(1, casino.TableConfig{ID: "c", Game: casino.GameCraps}, "alice") conn := &casinoTestConn{} p := player.New("alice") sess := &net.Session{Player: p, Conn: conn} g.loggedInChars = map[string]*net.Session{"alice": sess} for _, display := range []string{"full", "compact"} { p.Options = map[string]any{"casino_display": display} conn.data = nil g.emitCasinoEvents(g.Casino.Table(1, "c").Status("alice")) text := plainRoulette(string(conn.data)) felt, commands := strings.Index(text, "PASS LINE"), strings.Index(text, "bet on ") if felt < 0 || commands < felt { t.Fatal("reminder above graphic", text) } for _, old := range []string{"Next in order:", "Fire points made:", "ATS numbers:", "Phase:", "Timer: 0"} { if strings.Contains(text, old) { t.Fatal("raw status leaked", old) } } } }