aboutsummaryrefslogtreecommitdiff
path: root/internal/game/pachinko_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/pachinko_test.go')
-rw-r--r--internal/game/pachinko_test.go309
1 files changed, 309 insertions, 0 deletions
diff --git a/internal/game/pachinko_test.go b/internal/game/pachinko_test.go
new file mode 100644
index 0000000..fbf3b35
--- /dev/null
+++ b/internal/game/pachinko_test.go
@@ -0,0 +1,309 @@
+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)
+ }
+ }
+}