package game import ( "strings" "testing" "unicode/utf8" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" ) func pachinkoTestSnapshot(phase string) *casino.PachinkoSnapshot { s := &casino.PachinkoSnapshot{ Phase: phase, RTP: 100.00, Uniform: 83.67, Row: pachinkoRows, Lane: 4, Landing: -1, Doubling: 0, } values := []casino.PachinkoPocket{ {Value: 0}, {Value: 2}, {Value: 10}, {Value: 2}, {Value: 100}, {Double: true}, {Value: 2}, {Value: 10}, } copy(s.Pockets[:], values) if phase == "result" { s.Landing = 4 } return s } func TestPachinkoRenderBoard(t *testing.T) { for _, mode := range []string{"none", "ansi", "xterm256"} { for _, unicode := range []bool{false, true} { s := pachinkoTestSnapshot("choosing") out := renderPachinko(s, mode, unicode, false, 0) lines := strings.Split(out, "\n") if len(lines) != pachinkoRows+3 { t.Fatalf("%d lines, want %d", len(lines), pachinkoRows+3) } for _, line := range lines { if got := utf8.RuneCountInString(stripANSI(line)); got != pachinkoWidth { t.Fatalf("line width %d, want %d (%q)", got, pachinkoWidth, stripANSI(line)) } } if mode == "none" { if strings.Contains(out, "\x1b") { t.Fatal("plain board has ANSI escapes") } if !strings.Contains(stripANSI(out), "DBL") || !strings.Contains(stripANSI(out), "100x") { t.Fatalf("plain board missing prizes: %q", stripANSI(out)) } } else { for bg := 0; bg < 256; bg++ { if strings.Contains(out, color.BgCode(mode, bg)) { t.Fatalf("%s board must not paint a background", mode) } } } } } } func TestPachinkoHeaderSitsBetweenPegs(t *testing.T) { s := pachinkoTestSnapshot("choosing") lines := strings.Split(stripANSI(renderPachinko(s, "none", false, false, 0)), "\n") header, firstPegs := []rune(lines[0]), []rune(lines[1]) for lane := 0; lane < pachinkoLanes; lane++ { x := pachinkoLaneX(lane) if header[x] != rune('1'+lane) { t.Fatalf("header %d not at x=%d: %q", lane+1, x, string(header)) } // The number must sit in a gap of the first peg row, not on a pin or // the wall. if firstPegs[x] != ' ' { t.Fatalf("slot %d header overlaps a pin or wall: %q", lane+1, string(firstPegs)) } near := false for _, dx := range []int{-pachinkoPitch / 2, pachinkoPitch / 2} { if at := x + dx; at >= 0 && at < len(firstPegs) && firstPegs[at] == '.' { near = true } } if !near { t.Fatalf("slot %d header not between pins: %q", lane+1, string(firstPegs)) } } } func TestPachinkoRenderDroppingIsOneLine(t *testing.T) { s := pachinkoTestSnapshot("dropping") for row := 0; row <= pachinkoRows; row++ { for lane := 0; lane < pachinkoLanes; lane++ { s.Row, s.Lane = row, lane out := renderPachinko(s, "none", true, false, 0) if strings.Contains(out, "\n") { t.Fatalf("row %d lane %d printed more than one line", row, lane) } if got := utf8.RuneCountInString(stripANSI(out)); got != pachinkoWidth { t.Fatalf("row %d lane %d width %d, want %d", row, lane, got, pachinkoWidth) } // The ball sits exactly on its staggered lane column. plain := []rune(stripANSI(out)) if plain[pachinkoDropX(row, lane)] != '●' { t.Fatalf("row %d lane %d ball misplaced: %q", row, lane, string(plain)) } } } } func TestPachinkoRenderResultMarksLanding(t *testing.T) { s := pachinkoTestSnapshot("result") s.Landing = 4 out := stripANSI(renderPachinko(s, "none", true, false, 0)) lines := strings.Split(out, "\n") // The settled view is only the prize row: the drop already drew the ball // path, so the result must not reprint the board or its number header. if len(lines) != 1 { t.Fatalf("result should be one prize row, got %d lines: %q", len(lines), out) } if got := utf8.RuneCountInString(lines[0]); got != pachinkoWidth { t.Fatalf("result width %d, want %d", got, pachinkoWidth) } if !strings.Contains(lines[len(lines)-1], "[100x]") { t.Fatalf("landed jackpot not bracketed: %q", lines[len(lines)-1]) } if strings.Count(lines[len(lines)-1], "[") != 1 { t.Fatalf("only the landed pocket may be bracketed: %q", lines[len(lines)-1]) } } func TestPachinkoRenderDoubledLabels(t *testing.T) { s := pachinkoTestSnapshot("choosing") s.Doubling = 2 out := stripANSI(renderPachinko(s, "none", true, false, 0)) // 0, 2, 10, 2, 100, DBL, 2, 10, 0 doubled twice: 0, 8, 40, 8, 400, DBL, 8, 40, 0. for _, want := range []string{"8x", "40x", "400x", "DBL"} { if !strings.Contains(out, want) { t.Fatalf("doubled board missing %q: %q", want, out) } } } func TestPachinkoIdleShowsIntroOnly(t *testing.T) { s := &casino.PachinkoSnapshot{Phase: "idle", RTP: 100.00, Uniform: 83.67, Odds: casino.PachinkoOdds()} out := stripANSI(renderPachinko(s, "none", true, false, 0)) if !strings.Contains(out, "You stand atop a giant peg board") { t.Fatalf("idle view missing the intro: %q", out) } // Sitting down must not dump the blank board or pay table. if strings.Contains(out, "Pachinko pay table") || strings.Contains(out, "?") || strings.Contains(out, "Prize") { t.Fatalf("idle view leaked the reference table: %q", out) } } func TestPachinkoPaytableRender(t *testing.T) { s := &casino.PachinkoSnapshot{Phase: "paytable", RTP: 100.00, Uniform: 83.67} s.Odds = casino.PachinkoOdds() out := stripANSI(renderPachinko(s, "none", true, true, 2)) for _, want := range []string{"100.00%", "83.67%", "0.1-1.5x", "1.7x", "2x", "5x", "10x", "100x", "DOUBLE"} { if !strings.Contains(out, want) { t.Fatalf("paytable missing %q: %q", want, out) } } if !strings.Contains(out, "Prize") || !strings.Contains(out, "Chance") || !strings.Contains(out, "Odds") { t.Fatalf("paytable missing its columns: %q", out) } } func TestPachinkoPaytableHidesOdds(t *testing.T) { s := &casino.PachinkoSnapshot{Phase: "paytable", RTP: 100.00, Uniform: 83.67, Odds: casino.PachinkoOdds()} out := stripANSI(renderPachinko(s, "none", true, false, 2)) if !strings.Contains(out, "Pachinko pay table") || !strings.Contains(out, "DOUBLE") { t.Fatalf("reference view missing the table: %q", out) } // Without a House Edge instrument the exact percentages stay hidden. for _, leak := range []string{"100.00%", "83.67%", "28.18%", "5.00%"} { if strings.Contains(out, leak) { t.Fatalf("paytable leaked odds %q without an instrument: %q", leak, out) } } } func TestPachinkoPaytablePrecision(t *testing.T) { s := &casino.PachinkoSnapshot{Phase: "paytable", RTP: 100.00, Uniform: 80.09, Odds: casino.PachinkoOdds()} out := stripANSI(renderPachinko(s, "none", true, true, 0)) if !strings.Contains(out, "4%") || strings.Contains(out, "3.73%") { t.Fatalf("whole-percent instrument should round: %q", out) } out = stripANSI(renderPachinko(s, "none", true, true, 2)) if !strings.Contains(out, "3.73%") { t.Fatalf("hundredth-percent instrument should not round: %q", out) } } func TestPachinkoCommandsAndReceipt(t *testing.T) { g, s, conn := casinoCrapsGame(t, "name: Pins\ncasino_tables:\n - id: pachinko\n game: pachinko\n") s.Player.Options = map[string]any{"color": "none", "casino_hints": "off"} g.executePlay(s, []string{"pachinko"}, "play pachinko") table, ok := g.Casino.Participation(s.Player.Name) if !ok || table == nil || table.Config.Game != casino.GamePachinko { t.Fatal("pachinko was not seated") } conn.data = nil g.executeBet(s, []string{"1"}, "bet 1") if text := stripANSI(string(conn.data)); !strings.Contains(text, "Choose a top slot") || !strings.Contains(text, "0.1x") { t.Fatalf("bet did not reveal a board: %s", text) } if table.Busy(s.Player.Name) { t.Fatal("choosing a slot must not suppress the input prompt") } // A bare slot number must reach the game rather than being rejected. conn.data = nil g.handleGameCommand(s, "4") if strings.Contains(string(conn.data), "Unknown command") { t.Fatalf("bare slot rejected: %s", conn.data) } if !table.Busy(s.Player.Name) { t.Fatal("drop did not start") } // Settle, re-dropping through any chained DOUBLEs. for i := 0; i < 2000; i++ { switch table.State() { case "waiting": i = 5000 case "choosing a slot": g.executeTableAction(s, "4") case "dropping": g.emitCasinoEvents(g.Casino.Tick()) } } if table.State() != "waiting" || len(s.Player.PendingWagers) != 0 { t.Fatalf("round did not settle: state %q pending %d", table.State(), len(s.Player.PendingWagers)) } if text := stripANSI(string(conn.data)); !strings.Contains(text, "Returned:") || !strings.Contains(text, "Staked") { t.Fatalf("settlement receipt missing: %s", text) } conn.data = nil g.doLookTarget(s, "pachinko") if text := stripANSI(string(conn.data)); !strings.Contains(text, "giant peg board") { t.Fatalf("look missing the reference board: %s", text) } conn.data = nil g.executeRules(s, nil, "rules") if text := stripANSI(string(conn.data)); !strings.Contains(text, "PACHINKO") { t.Fatalf("rules did not open the pachinko help page: %s", text) } } func TestPachinkoRejectsPickingSlotBeforeBoard(t *testing.T) { g, s, conn := casinoCrapsGame(t, "name: Pins\ncasino_tables:\n - id: pachinko\n game: pachinko\n") s.Player.Options = map[string]any{"color": "none", "casino_hints": "off"} g.executePlay(s, []string{"pachinko"}, "play pachinko") conn.data = nil g.executeBet(s, []string{"1", "on", "5"}, "bet 1 on 5") table, _ := g.Casino.Participation(s.Player.Name) if table.State() != "waiting" { t.Fatalf("bet on SLOT should be rejected before the board exists: %q", table.State()) } if len(s.Player.PendingWagers) != 0 { t.Fatal("rejected bet must not commit a stake") } if !strings.Contains(stripANSI(string(conn.data)), "board is revealed before you choose") { t.Fatalf("missing rejection: %s", conn.data) } } func TestPachinkoDropSuppressesPrompt(t *testing.T) { g, s, conn := casinoCrapsGame(t, "name: Pins\ncasino_tables:\n - id: pachinko\n game: pachinko\n") s.Player.Options = map[string]any{"color": "none", "casino_hints": "off"} g.executePlay(s, []string{"pachinko"}, "play pachinko") g.executeBet(s, []string{"1"}, "bet 1") conn.data = nil g.handleGameCommand(s, "4") if !g.casinoAnimationBusy(s) { t.Fatal("drop did not mark the table busy") } before := len(conn.data) g.writePrompt(s) // the command queue's post-command prompt if len(conn.data) != before { t.Fatalf("prompt interrupted the drop: %q", conn.data) } } func TestPachinkoDropVerbKeepsItemDrop(t *testing.T) { // "drop sword" must stay a world command, not a casino slot. g, s, conn := casinoCrapsGame(t, "name: Pins\ncasino_tables:\n - id: pachinko\n game: pachinko\n") s.Player.Options = map[string]any{"color": "none", "casino_hints": "off"} g.executePlay(s, []string{"pachinko"}, "play pachinko") g.executeBet(s, []string{"1"}, "bet 1") conn.data = nil g.executeCommand(s, "drop", []string{"sword"}, "drop sword") if strings.Contains(string(conn.data), "Choose a top slot") { t.Fatalf("drop with a non-slot argument leaked into the casino: %s", conn.data) } } func TestPachinkoHelpAndAliases(t *testing.T) { for _, name := range []string{"pachinko", "pachinko machine"} { g, s, _ := casinoCrapsGame(t, "name: Pins\ncasino_tables:\n - id: pachinko\n game: pachinko\n") args := strings.Fields(name) g.executePlay(s, args, "play "+name) table, ok := g.Casino.Participation(s.Player.Name) if !ok || table == nil || table.Config.Game != casino.GamePachinko { t.Fatalf("alias %q did not seat the player", name) } } }