aboutsummaryrefslogtreecommitdiff
path: root/internal/game/plinko_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-03 19:46:54 -0400
committerhistoria <[not public]>2026-10-03 19:46:54 -0400
commit3a39a020e4449bc4d6f728ef837cffb9f7bd0c56 (patch)
tree9ba1cd5f2aef6ef7c54fdf5b02a780604b5af80b /internal/game/plinko_test.go
parentc6d4702c0be4d2b37e1d82ff96265c52847c3f9a (diff)
downloadthehouseoficarus-3a39a020e4449bc4d6f728ef837cffb9f7bd0c56.tar.gz
feat: new casino game: plinko
Diffstat (limited to 'internal/game/plinko_test.go')
-rw-r--r--internal/game/plinko_test.go261
1 files changed, 261 insertions, 0 deletions
diff --git a/internal/game/plinko_test.go b/internal/game/plinko_test.go
new file mode 100644
index 0000000..711525f
--- /dev/null
+++ b/internal/game/plinko_test.go
@@ -0,0 +1,261 @@
+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")
+ }
+}