diff options
Diffstat (limited to 'internal/game/pachinko_test.go')
| -rw-r--r-- | internal/game/pachinko_test.go | 309 |
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) + } + } +} |
