package game import ( "fmt" "strings" "testing" "unicode/utf8" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" ) func TestPlinkoCommands(t *testing.T) { g, s, conn := casinoCrapsGame(t, "name: Plinko\ncasino_tables:\n - id: plinko\n game: plinko\n") g.executePlay(s, []string{"plinko"}, "play plinko") g.executeCommand(s, "difficulty", []string{"expert"}, "difficulty expert") g.executeCommand(s, "balls", []string{"10"}, "balls 10") table, ok := g.Casino.Participation(s.Player.Name) if !ok || table == nil { t.Fatal("not seated") } snap := table.Status(s.Player.Name)[0].Snapshot.(*casino.PlinkoSnapshot) if snap.Total != 10 || snap.Risk != "expert" || snap.Pockets[0] != 10000 { t.Fatalf("commands failed: %+v", snap) } g.executeBet(s, []string{"1"}, "bet 1") if !table.Busy(s.Player.Name) || len(s.Player.PendingWagers) != 1 { t.Fatal("drop did not start") } conn.data = nil for i := 0; i < 32 && table.Busy(s.Player.Name); i++ { g.emitCasinoEvents(g.Casino.Tick()) } if table.Busy(s.Player.Name) || len(s.Player.PendingWagers) != 0 { t.Fatal("drop did not settle") } text := stripANSI(string(conn.data)) if !strings.Contains(text, "Ball 10 ->") || strings.Count(text, "Returned:") != 1 || strings.Contains(text, "credits returned") || strings.Contains(text, "net +") { t.Fatal(text) } conn.data = nil g.doLookTarget(s, "plinko") if !strings.Contains(stripANSI(string(conn.data)), "P L I N K O") { t.Fatal("look missing paytable") } } func TestPlinkoBallShades(t *testing.T) { if plinkoBallColor(1, 1) != color.NearestXterm256(255, 255, 255) { t.Fatal("single must be white") } for total := 2; total <= 10; total++ { last := 256 for count := 1; count <= total; count++ { r, g, b := color.Xterm256ToRGB(plinkoBallColor(count, total)) if r != 255 || g != b || g > last { t.Fatal("invalid white-red gradient") } last = g } if last != 0 { t.Fatal("all balls must be pure red") } } } func TestPlinkoRenderRows(t *testing.T) { for _, mode := range []string{"none", "ansi", "xterm256"} { for _, unicode := range []bool{false, true} { for row := 0; row <= 16; row++ { s := &casino.PlinkoSnapshot{Row: row, Total: 10} s.Counts[0] = 10 out := renderPlinko(s, mode, unicode) for _, line := range strings.Split(stripANSI(out), "\n") { if utf8.RuneCountInString(line) != plinkoWidth { t.Fatal("board must keep its full width, including exterior spaces") } } wantLines := 1 if row == plinkoRows/2 || row == plinkoRows { wantLines = 2 // midpoint hint ruler, base rail } if got := len(strings.Split(out, "\n")); got != wantLines { t.Fatalf("row %d printed %d lines, want %d", row, got, wantLines) } if strings.Contains(out, "\x1b[48;") { t.Fatal("the board must not paint a background") } if mode == "none" && (!strings.Contains(out, "*") || strings.Contains(out, "\x1b")) { t.Fatal("plain overlap fallback missing") } } } } } func TestPlinkoUsesSharedHints(t *testing.T) { for _, mode := range []string{"on", "once", "off"} { t.Run(mode, func(t *testing.T) { g, s, conn := casinoCrapsGame(t, "name: Plinko\ncasino_tables:\n - id: plinko\n game: plinko\n") s.Player.Options = map[string]any{"color": "none", "unicode": true, "casino_hints": mode} g.executePlay(s, []string{"plinko"}, "play plinko") text := string(conn.data) if strings.Contains(text, "difficulty easy|medium|hard|expert") != (mode != "off") { t.Fatalf("join hints (%s): %s", mode, text) } if mode != "off" && strings.Index(text, "P L I N K O") > strings.Index(text, "bet N") { t.Fatal("hints must follow the paytable") } conn.data = nil g.executeTableAction(s, "balls 2") if strings.Contains(string(conn.data), "bet N") != (mode == "on") { t.Fatalf("settings hints (%s): %s", mode, conn.data) } g.executeBet(s, []string{"1"}, "bet 1") table, _ := g.Casino.Participation(s.Player.Name) for i := 0; i < 32 && table.Busy(s.Player.Name); i++ { conn.data = nil g.emitCasinoEvents(g.Casino.Tick()) } if strings.Contains(string(conn.data), "bet N") != (mode == "on") { t.Fatalf("settlement hints (%s): %s", mode, conn.data) } conn.data = nil g.executeVerbs(s, nil, "what") if !strings.Contains(string(conn.data), "balls N") { t.Fatal("explicit hints missing") } }) } } func plinkoTestBoardRow(s *casino.PlinkoSnapshot) []rune { lines := strings.Split(renderPlinko(s, "none", true), "\n") return []rune(lines[0]) } func TestPlinkoPegsNeverMove(t *testing.T) { for row := 0; row <= 16; row++ { s := &casino.PlinkoSnapshot{Row: row, Total: 1} empty := plinkoTestBoardRow(s) left, right := 32-2*row, 36+2*row pegs := map[int]bool{} for k := 1; k <= row; k++ { pegs[left+4*k] = true if empty[left+4*k] != '·' { t.Fatalf("row %d: peg missing at its true position %d", row, left+4*k) } } if right < plinkoWidth && empty[right] != '\\' || empty[left] != '/' { t.Fatalf("row %d: border moved", row) } for lane := 0; lane <= row; lane++ { s.Counts = [17]int{} s.Counts[lane] = 1 filled := plinkoTestBoardRow(s) x := 34 - 2*row + 4*lane if filled[x] != '●' { t.Fatalf("ball not in its own lane: row %d lane %d", row, lane) } for px := range pegs { if filled[px] != '·' { t.Fatalf("row %d lane %d overwrote peg at %d", row, lane, px) } } } } } func TestPlinkoHintsPrintOnceAtMidpoint(t *testing.T) { pockets := [17]float64{16, 7.8, 5.9, 4.4, 3.1, 2, 1.1, 0.5, 0.2, 0.5, 1.1, 2, 3.1, 4.4, 5.9, 7.8, 16} labels := casino.PlinkoPocketLabels(pockets) isRuler := func(line string) bool { r := []rune(line) if len(r) != plinkoWidth || r[1] != ' ' { return false } for k, label := range labels { if r[2+4*k] != label { return false } } return true } for row := 0; row <= 16; row++ { s := &casino.PlinkoSnapshot{Row: row, Total: 1, Pockets: pockets} hasRuler := false for _, line := range strings.Split(stripANSI(renderPlinko(s, "none", true)), "\n") { hasRuler = hasRuler || isRuler(line) } if hasRuler != (row == plinkoRows/2) { t.Fatalf("row %d: hint ruler present=%v", row, hasRuler) } // The pyramid row itself never carries pocket letters any more. board := plinkoTestBoardRow(s) for k, label := range labels { if board[2+4*k] == label { t.Fatalf("row %d: pocket hint leaked into the board", row) } } } } func TestPlinkoLegendDownTheLeft(t *testing.T) { s := &casino.PlinkoSnapshot{Pockets: [17]float64{16, 7.8, 5.9, 4.4, 3.1, 2, 1.1, 0.5, 0.2, 0.5, 1.1, 2, 3.1, 4.4, 5.9, 7.8, 16}, Total: 1} labels := casino.PlinkoPocketLabels(s.Pockets) if string(labels[:]) != "ABCDEFGHIHGFEDCBA" { t.Fatalf("wrong labels: %s", string(labels[:])) } entries := plinkoLegendEntries(s) if len(entries) != 9 { t.Fatalf("want 9 distinct legend entries, got %d", len(entries)) } for row := 0; row < len(entries); row++ { s.Row = row line := string(plinkoTestBoardRow(s)) want := fmt.Sprintf("%c %s", entries[row].label, entries[row].text) if !strings.HasPrefix(line[1:], want) { t.Fatalf("row %d: legend entry not on the left: %q", row, line) } if entries[row].label == 'A' && !strings.Contains(line, "16x") { t.Fatal("legend missing the 16x edge payout") } } // The legend stays clear of the pyramid as it widens. s.Row = len(entries) - 1 if left := 32 - 2*s.Row; left <= 10 { t.Fatalf("legend would collide with the pyramid: left border %d", left) } // The paytable prints the same entries, one per line, not a wide block. s.Row = 0 paytable := stripANSI(renderPlinko(&casino.PlinkoSnapshot{Pockets: s.Pockets, Risk: "easy", Paytable: true}, "none", true)) for _, e := range entries { if strings.Count(paytable, fmt.Sprintf("%c %s", e.label, e.text)) != 1 { t.Fatalf("paytable legend missing or duplicated %c", e.label) } } // Rounded ties, not just mirrored positions, share one legend entry. s.Row = 16 s.Counts = [17]int{} rail := strings.Split(renderPlinko(s, "none", true), "\n")[1] if !strings.Contains(rail, "[A] [B] [C] [D] [E] [F] [G] [H] [I] [H] [G] [F] [E] [D] [C] [B] [A]") { t.Fatal(rail) } s.Pockets[7], s.Pockets[8], s.Pockets[9] = 0.1, 0.1, 0.1 if got := plinkoLegendEntries(s); len(got) != 8 { t.Fatalf("rounded ties duplicated: %d entries", len(got)) } } func TestPlinkoHintsAreDimWhite(t *testing.T) { r, g, b := color.Xterm256ToRGB(plinkoHintColor()) if r != g || g != b { t.Fatalf("hint is not white: %d,%d,%d", r, g, b) } if r > 95 { t.Fatalf("hint is too bright: %d", r) } br, bg, bb := color.Xterm256ToRGB(plinkoBallColor(1, 1)) if r >= br || g >= bg || b >= bb { t.Fatal("hint must be dimmer than a ball") } }