diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/casino/roulette.go | 71 | ||||
| -rw-r--r-- | internal/casino/roulette_geometry_test.go | 109 | ||||
| -rw-r--r-- | internal/casino/roulette_racetrack.go | 18 | ||||
| -rw-r--r-- | internal/casino/roulette_racetrack_test.go | 34 | ||||
| -rw-r--r-- | internal/casino/table_ux.go | 4 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 37 | ||||
| -rw-r--r-- | internal/game/render_casino_help.go | 85 | ||||
| -rw-r--r-- | internal/game/render_casino_help_test.go | 122 | ||||
| -rw-r--r-- | internal/game/render_help.go | 1 | ||||
| -rw-r--r-- | internal/game/render_roulette.go | 136 | ||||
| -rw-r--r-- | internal/game/render_roulette_test.go | 143 | ||||
| -rw-r--r-- | internal/game/roulette_test.go | 5 |
12 files changed, 647 insertions, 118 deletions
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index 252ac4c..109306a 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -306,19 +306,17 @@ func rouletteAdjacent(a, b string, double bool) bool { return (dc == 1 && dr == 0) || (dc == 0 && dr == 1) } -// The zero lane spans all three numbered rows, so 0 splits with 1, 2 and 3. -// On an American felt the two zeros are adjacent to each other as well. +// European zero touches 1, 2 and 3. On the American layout, 0 +// touches 1 and 2; 00 touches 2 and 3. The zeros also touch each other. func rouletteZeroAdjacent(a, b string, double bool) bool { - neighbors := func(other string) bool { - if other == "0" || other == "00" { - return double - } - return other == "1" || other == "2" || other == "3" + if b == "0" || b == "00" { + a, b = b, a } - if a == "0" || a == "00" { - return neighbors(b) + if !double { + return a == "0" && (b == "1" || b == "2" || b == "3") } - return neighbors(a) + return (a == "0" && (b == "00" || b == "1" || b == "2")) || + (a == "00" && (b == "0" || b == "2" || b == "3")) } func rouletteIsStreet(nums []string) bool { @@ -352,6 +350,7 @@ func rouletteIsCorner(nums []string, double bool) bool { if !double && rouletteIsFirstFour(nums) { return true } + minCol, maxCol, minRow, maxRow := 12, -1, 3, -1 cells := map[[2]int]bool{} for _, s := range nums { c, r, ok := rouletteNumberCell(s) @@ -359,26 +358,10 @@ func rouletteIsCorner(nums []string, double bool) bool { return false } cells[[2]int{c, r}] = true + minCol, maxCol = min(minCol, c), max(maxCol, c) + minRow, maxRow = min(minRow, r), max(maxRow, r) } - if len(cells) != 4 { - return false - } - cols, rows := map[int]bool{}, map[int]bool{} - for cell := range cells { - cols[cell[0]] = true - rows[cell[1]] = true - } - if len(cols) != 2 || len(rows) != 2 { - return false - } - for c := range cols { - for r := range rows { - if !cells[[2]int{c, r}] { - return false - } - } - } - return true + return len(cells) == 4 && maxCol-minCol == 1 && maxRow-minRow == 1 } func rouletteIsFirstFour(nums []string) bool { @@ -427,30 +410,22 @@ func rouletteIsLine(nums []string) bool { return c2-c1 == 1 } -// A trio is any three distinct pockets drawn from 0, 00 and the first street -// (1,2,3) that includes at least one zero. It pays the same 11:1 as a street. +// Trios cover touching zero-edge intersections, not arbitrary selections +// from the zeros and the first street. func rouletteIsTrio(nums []string, double bool) bool { if len(nums) != 3 { return false } - hasZero := false - for _, s := range nums { - switch s { - case "0": - hasZero = true - case "00": - if !double { - return false - } - hasZero = true - default: - v, err := strconv.Atoi(s) - if err != nil || v < 1 || v > 3 { - return false - } - } + set := map[string]bool{} + for _, n := range nums { + set[n] = true + } + if double { + return (set["0"] && set["1"] && set["2"]) || + (set["0"] && set["00"] && set["2"]) || + (set["00"] && set["2"] && set["3"]) } - return hasZero + return set["0"] && set["2"] && (set["1"] || set["3"]) } // The basket is the American five-number 0/00/1/2/3 bet, which pays a short diff --git a/internal/casino/roulette_geometry_test.go b/internal/casino/roulette_geometry_test.go new file mode 100644 index 0000000..37fec74 --- /dev/null +++ b/internal/casino/roulette_geometry_test.go @@ -0,0 +1,109 @@ +package casino + +import ( + "fmt" + "testing" +) + +func TestRouletteRejectsNonTouchingCombinations(t *testing.T) { + for _, tc := range []struct { + target string + double bool + }{ + {"corner 1 3 4 6", false}, {"corner 1 2 7 8", false}, + {"corner 1 3 34 36", true}, {"trio 0 1 3", false}, + {"trio 0 1 3", true}, {"trio 0 2 3", true}, + {"trio 00 1 2", true}, {"trio 0 00 1", true}, + {"trio 0 00 3", true}, {"split 0 3", true}, + {"split 00 1", true}, + } { + if _, mult := rouletteBet(tc.target, tc.double); mult != 0 { + t.Errorf("accepted %q (double=%v)", tc.target, tc.double) + } + } + for _, double := range []bool{false, true} { + for c := 0; c < 11; c++ { + for r := 1; r <= 2; r++ { + n := 3*c + r + target := fmt.Sprintf("corner %d %d %d %d", n, n+1, n+3, n+4) + if _, mult := rouletteBet(target, double); mult != 9 { + t.Errorf("rejected %s", target) + } + } + } + for _, target := range []string{"split 0 1", "split 0 2", "trio 0 1 2"} { + if _, mult := rouletteBet(target, double); mult == 0 { + t.Errorf("rejected %s", target) + } + } + } + for _, target := range []string{"split 00 2", "split 00 3", "trio 00 2 3", "trio 0 00 2"} { + if _, mult := rouletteBet(target, true); mult == 0 { + t.Errorf("rejected %s", target) + } + } +} + +// Sum the return over every equally likely outcome. Ordinary bets return +// 36 stakes per wheel; the American five-number basket returns only 35. +func TestRouletteWheelAndExpectedReturns(t *testing.T) { + for _, double := range []bool{false, true} { + wheel := WheelOrder(double) + want := 37 + if double { + want = 38 + } + seen := map[string]bool{} + for _, p := range wheel { + if _, valid := roulettePocketToken(p, double); !valid || seen[p] { + t.Fatalf("invalid wheel pocket %s", p) + } + seen[p] = true + } + if len(seen) != want { + t.Fatalf("wheel has %d pockets, want %d", len(seen), want) + } + targets := append(WheelOrder(double), "red", "black", "odd", "even", "low", "high", "1to12", "13to24", "25to36", "row1", "row2", "row3", "split 1 2", "street 1 2 3", "corner 1 2 4 5", "line 1 2 3 4 5 6", "trio 0 1 2", "basket") + for _, target := range targets { + wager, ok := rouletteWagerFor(target, double, false) + if !ok { + t.Fatalf("invalid target %s", target) + } + sum := 0 + for _, p := range wheel { + share, _ := wager.covers(p) + payout, err := share.payout(1) + if err != nil { + t.Fatal(err) + } + sum += payout + } + wantReturn := 36 + if double && target == "basket" { + wantReturn = 35 + } + if sum != wantReturn { + t.Errorf("%s: total return %d, want %d", target, sum, wantReturn) + } + } + } +} + +func TestRouletteSharePayoutCap(t *testing.T) { + shares := []rouletteShare{{36, 1}, {7, 1}, {36, 9}} + for _, bet := range rouletteCallBets { + for _, share := range bet.wager().shares { + shares = append(shares, share) + } + } + for _, share := range shares { + limit := ((MaxCredits+1)*share.den - 1) / share.num + got, err := share.payout(limit) + if err != nil || got > MaxCredits { + t.Fatalf("%+v at limit: %d, %v", share, got, err) + } + if _, err := share.payout(limit + 1); err == nil { + t.Fatalf("%+v accepted overflow", share) + } + } +} diff --git a/internal/casino/roulette_racetrack.go b/internal/casino/roulette_racetrack.go index 624940b..9784d9a 100644 --- a/internal/casino/roulette_racetrack.go +++ b/internal/casino/roulette_racetrack.go @@ -23,7 +23,7 @@ func (s rouletteShare) payout(stake int) (int, error) { if stake <= 0 || s.num <= 0 || s.den <= 0 { return 0, nil } - if limit := MaxCredits * s.den / s.num; stake > limit { + if limit := ((MaxCredits+1)*s.den - 1) / s.num; stake > limit { return MaxCredits, fmt.Errorf("payout overflow: %d * %d/%d exceeds %d", stake, s.num, s.den, MaxCredits) } return stake * s.num / s.den, nil @@ -96,8 +96,8 @@ func RacetrackBets() []RacetrackBetView { // The four standard single-zero call bets. Chip placements and payouts follow // the traditional layout: Voisins (9 chips), Tiers (6), Orphelins (5) and -// Zero Spiel (4). Every inside component returns 36/covered, so the combined -// wager keeps the wheel's normal house edge. +// Jeu Zero (4). Every inside component returns 36/covered, so the combined +// wager keeps the wheel's normal house edge before whole-credit rounding. var rouletteCallBets = []rouletteCallBet{ { canonical: "voisins", @@ -142,8 +142,8 @@ var rouletteCallBets = []rouletteCallBet{ }, }, { - canonical: "zerospiel", - name: "Zero Spiel", + canonical: "jeuzero", + name: "Jeu Zero", chips: 4, series: []string{"12", "35", "3", "26", "0", "32", "15"}, parts: []rouletteComponent{ @@ -171,11 +171,9 @@ var rouletteCallAliases = map[string]string{ "orphelins": "orphelins", "orphans": "orphelins", "les orphelins": "orphelins", - "zero spiel": "zerospiel", - "zerospiel": "zerospiel", - "spiel": "zerospiel", - "jeu zero": "zerospiel", - "zero game": "zerospiel", + "zero": "jeuzero", + "jeu zero": "jeuzero", + "jeuzero": "jeuzero", } // neighborsRadius is how many pockets on each side of the chosen number the diff --git a/internal/casino/roulette_racetrack_test.go b/internal/casino/roulette_racetrack_test.go index 7c465b8..29e2809 100644 --- a/internal/casino/roulette_racetrack_test.go +++ b/internal/casino/roulette_racetrack_test.go @@ -33,6 +33,9 @@ func TestRouletteCallBetMath(t *testing.T) { chips := 0 for _, part := range bet.parts { chips += part.chips + if _, mult := rouletteBet(strings.Join(part.pockets, " "), false); mult != part.mult { + t.Fatalf("%s: invalid component %v or multiplier %d (want %d)", bet.name, part.pockets, part.mult, mult) + } } if chips != bet.chips { t.Fatalf("%s: parts total %d chips, want %d", bet.name, chips, bet.chips) @@ -68,7 +71,7 @@ func TestRouletteCallBetAliases(t *testing.T) { {[]string{"voisins", "voisins du zero", "Voisins du Zéro", "vdz", "grands voisins du zero"}, "voisins"}, {[]string{"tiers", "tiers du cylindre", "le tiers du cylindre", "tdc"}, "tiers"}, {[]string{"orphelins", "orphans", "les orphelins"}, "orphelins"}, - {[]string{"zero spiel", "zerospiel", "spiel", "jeu zero", "zero game"}, "zerospiel"}, + {[]string{"zero", "Zero", "jeu zero", "Jeu Zero", "jeuzero", "JeuZero", "jeu zéro"}, "jeuzero"}, } { var want rouletteWager for _, bet := range rouletteCallBets { @@ -152,13 +155,13 @@ func TestRouletteCallBetPayouts(t *testing.T) { if got := payout(orphelins, "6", 5); got != 18 { t.Fatalf("orphelins 5 on 6 = %d, want 18", got) } - // Zero Spiel: 26 is the straight-up. - spiel := wager("zero spiel") - if got := payout(spiel, "26", 4); got != 36 { - t.Fatalf("zero spiel 4 on 26 = %d, want 36", got) + // Jeu Zero: 26 is the straight-up. + jeuZero := wager("jeu zero") + if got := payout(jeuZero, "26", 4); got != 36 { + t.Fatalf("jeu zero 4 on 26 = %d, want 36", got) } - if got := payout(spiel, "0", 4); got != 18 { - t.Fatalf("zero spiel 4 on 0 = %d, want 18", got) + if got := payout(jeuZero, "0", 4); got != 18 { + t.Fatalf("jeu zero 4 on 0 = %d, want 18", got) } // Tiers is a plain six-way split. tiers := wager("tiers") @@ -263,3 +266,20 @@ func TestRouletteRacetrackConfig(t *testing.T) { t.Fatalf("double-zero racetrack accepted: %v", err) } } + +func TestRouletteZeroNameIsNotPocketZero(t *testing.T) { + call, ok := rouletteWagerFor("Zero", false, true) + if !ok || len(call.shares) != 7 { + t.Fatalf("Zero did not resolve to Jeu Zero: %+v", call) + } + straight, ok := rouletteWagerFor("0", false, true) + if !ok || len(straight.shares) != 1 { + t.Fatalf("0 did not resolve to a straight bet: %+v", straight) + } + if got, err := straight.shares["0"].payout(10); err != nil || got != 360 { + t.Fatalf("straight zero return %d, %v", got, err) + } + if got, err := call.shares["0"].payout(10); err != nil || got != 45 { + t.Fatalf("Jeu Zero return %d, %v", got, err) + } +} diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index 187bb6d..002d9f0 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -16,9 +16,9 @@ func (c TableConfig) RulesText() string { rules := c.Label() + "\nStraight numbers pay 35:1; splits 17:1; streets and trios 11:1; corners 8:1; lines 5:1.\n" + "1to12/13to24/25to36 and rows 2:1; other outside bets 1:1; the American basket pays 6:1.\n" + "Combos: bet 10 on split 1 2 | bet 10 on street 1 2 3 | bet 10 on corner 1 2 4 5 |\n" + - "bet 10 on line 1 2 3 4 5 6 | bet 10 on trio 0 00 2 | bet 10 on basket.\n" + "bet 10 on line 1 2 3 4 5 6 | bet 10 on trio 0 1 2 | bet 10 on basket.\n" if c.Roulette.Racetrack { - rules += "Racetrack (single zero): bet 10 on voisins | tiers | orphelins | zero spiel | neighbors 17.\n" + rules += "Racetrack (single zero): bet 10 on voisins | tiers | orphelins | jeu zero | neighbors 17.\n" } return rules + "Zero pockets lose all outside bets. One wager per spin. Use help roulette." default: diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index de62797..6e146d0 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -103,28 +103,47 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) { } t, playing := g.Casino.Participation(sess.Player.Name) if !playing || t == nil { - g.showCasinoChoices(sess, g.casinoChoices(sess.Player.RoomID)) + if len(args) > 0 && args[0] == "help" { + topic := "casino" + if playing { + topic = "slots" + } + g.doHelp(sess, topic) + } else { + g.showCasinoChoices(sess, g.casinoChoices(sess.Player.RoomID)) + } return } if len(args) > 0 && args[0] == "rules" { sess.WriteLine(g.renderCasinoRules(sess, t.Config)) return } - g.emitCasinoEvents(t.Status(sess.Player.Name)) - if t.Config.Game == casino.GameRoulette { - sess.WriteLine(g.rouletteReminder(sess, t.Config)) + if len(args) > 0 && args[0] == "help" { + g.doHelp(sess, string(t.Config.Game)) + } else { + g.emitCasinoEvents(t.Status(sess.Player.Name)) + if t.Config.Game == casino.GameRoulette { + sess.WriteLine(g.rouletteReminder(sess, t.Config)) + } else { + sess.WriteLine(g.colorize(sess, "command", "Learn to play: table help / help casino. Exact payouts: table rules.")) + } } if len(args) > 0 { - sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", []casino.MenuCommand{ - {Command: "bet / rebet", Desc: "Repeat the displayed wager"}, + commands := []casino.MenuCommand{ + {Command: "bet / rebet", Desc: "Repeat your previous wager"}, {Command: "bet min / bet max", Desc: "Use the table minimum / affordable maximum"}, - {Command: "h / st / dd / sp", Desc: "Blackjack: hit / stand / double / split (s and d still move)"}, - {Command: "insurance / decline", Desc: "Answer the insurance offer explicitly"}, {Command: "sitout", Desc: "Skip future rounds; bet to rejoin"}, {Command: "stop", Desc: "Leave now, or after your committed round"}, {Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"}, {Command: "table rules", Desc: "Review this table’s rules"}, - })) + {Command: "help casino", Desc: "Quick guides to all casino games"}, + } + if t.Config.Game == casino.GameBlackjack { + commands = append(commands, + casino.MenuCommand{Command: "h / st / dd / sp", Desc: "Hit / stand / double / split (s and d still move)"}, + casino.MenuCommand{Command: "insurance / decline", Desc: "Answer the insurance offer explicitly"}) + } + sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", commands)) } } diff --git a/internal/game/render_casino_help.go b/internal/game/render_casino_help.go new file mode 100644 index 0000000..5a76ef0 --- /dev/null +++ b/internal/game/render_casino_help.go @@ -0,0 +1,85 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/ui" +) + +// casinoBetTable uses the shared ANSI-aware table renderer, rather than +// letting the session wrap a long, pipe-separated payout paragraph. +func (g *Game) casinoBetTable(sess *net.Session, title string, columns []string, rows [][]string) string { + t := ui.Table{Title: g.colorize(sess, "casino_action", title)} + for _, col := range columns { + t.Columns = append(t.Columns, g.colorize(sess, "casino_action", col)) + } + for _, row := range rows { + cells := append([]string(nil), row...) + for i, cell := range cells { + category := "casino_action" + if i == 0 { + category = "command" + } + if i == len(cells)-1 { + category = "casino_payout" + } + cells[i] = g.colorize(sess, category, cell) + } + t.Rows = append(t.Rows, cells) + } + unicode, width := true, 78 + if p := sess.Player; p != nil { + unicode = p.OptionBool("unicode") + if wrap := p.OptionInt("wrap_width"); wrap > 0 { + width = min(width, wrap) + } + } + return strings.Join(t.Render(unicode, width), "\n") +} + +// rouletteReminder is shown by table/table rules, not on ambient boards. +// Hit chances count equally likely pockets; all ratios are NET profit odds. +func (g *Game) rouletteReminder(sess *net.Session, cfg casino.TableConfig) string { + pockets, basketCount, basketOdds := 37, 4, "8:1" + if cfg.Roulette.DoubleZero { + pockets, basketCount, basketOdds = 38, 5, "6:1" + } + chance := func(n int) string { return fmt.Sprintf("%d/%d", n, pockets) } + rows := [][]string{ + {"Number", "17 (or 0)", chance(1), "35:1"}, + {"Split", "split 1 2", chance(2), "17:1"}, + {"Street", "street 1 2 3", chance(3), "11:1"}, + {"Trio", "trio 0 1 2", chance(3), "11:1"}, + {"Corner", "corner 1 2 4 5", chance(4), "8:1"}, + {"Six line", "line 1 2 3 4 5 6", chance(6), "5:1"}, + {"Dozen", "1to12/13to24/25to36", chance(12), "2:1"}, + {"Row", "row1/row2/row3", chance(12), "2:1"}, + {"Color", "red / black", chance(18), "1:1"}, + {"Parity", "odd / even", chance(18), "1:1"}, + {"Low / high", "low / high", chance(18), "1:1"}, + {"Basket", "basket", chance(basketCount), basketOdds}, + } + if cfg.Roulette.DoubleZero { + rows[0][1] = "17 (or 0, 00)" + } + blocks := []string{g.casinoBetTable(sess, "Roulette payouts", []string{"Bet", "Target / example", "Chance", "Profit"}, rows)} + if cfg.Roulette.Racetrack && !cfg.Roulette.DoubleZero { + blocks = append(blocks, g.casinoBetTable(sess, "Racetrack payouts", []string{"Target", "Chance", "Profit on total stake"}, [][]string{ + {"voisins", chance(17), "1:1; 0/2/3 pay 5:3"}, + {"tiers", chance(12), "2:1"}, + {"orphelins", chance(8), "13:5; 1/17 pay 31:5"}, + {"zero / jeu zero", chance(7), "7:2; 26 pays 8:1"}, + {"neighbors <n>", chance(9), "3:1"}, + })) + blocks = append(blocks, "Call bets split your total stake; fractional returns round down.\nUse help racetrack for the chip placements.") + } + blocks = append(blocks, + "Profit excludes your returned stake: 1:1 on 10 returns 20.\n"+ + "Low: 1-18; high: 19-36. Zero pockets lose outside bets.\n"+ + "Use bet <amount> on <target>, e.g. bet 10 on split 1 2.\n"+ + "Learn more: help roulette / help casino; table rules for this table.") + return strings.Join(blocks, "\n") +} diff --git a/internal/game/render_casino_help_test.go b/internal/game/render_casino_help_test.go new file mode 100644 index 0000000..246880f --- /dev/null +++ b/internal/game/render_casino_help_test.go @@ -0,0 +1,122 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func TestRoulettePayoutTables(t *testing.T) { + for _, mode := range []string{"none", "ansi", "xterm256"} { + for _, unicode := range []bool{false, true} { + for _, double := range []bool{false, true} { + for _, width := range []int{60, 78} { + g := &Game{} + p := player.New("alice") + p.Options = map[string]any{"color": mode, "unicode": unicode, "wrap_width": width} + sess := &net.Session{Player: p} + cfg := casino.TableConfig{Game: casino.GameRoulette, Roulette: casino.RouletteConfig{DoubleZero: double, Racetrack: !double}} + art := g.rouletteReminder(sess, cfg) + plain := stripANSI(art) + for _, want := range []string{"Roulette payouts", "Target / example", "Chance", "Profit", "35:1", "17:1", "11:1", "8:1", "5:1", "2:1", "1:1", "help roulette"} { + if !strings.Contains(plain, want) { + t.Fatalf("missing %q:\n%s", want, plain) + } + } + if double { + for _, want := range []string{"1/38", "5/38", "6:1", "0, 00"} { + if !strings.Contains(plain, want) { + t.Fatalf("missing %q", want) + } + } + if strings.Contains(plain, "Racetrack payouts") { + t.Fatal("American wheel has call bets") + } + } else { + for _, want := range []string{"1/37", "4/37", "17/37", "12/37", "8/37", "7/37", "9/37", "Racetrack payouts", "5:3", "13:5", "31:5", "7:2", "zero / jeu zero"} { + if !strings.Contains(plain, want) { + t.Fatalf("missing %q:\n%s", want, plain) + } + } + } + if (mode == "none") == strings.Contains(art, "\x1b[") { + t.Fatalf("wrong color mode %s", mode) + } + tableWidth := 0 + for _, line := range strings.Split(art, "\n") { + plainLine := stripANSI(line) + if plainLine != "" && strings.ContainsRune("╔╟╚+", []rune(plainLine)[0]) { + tableWidth = color.VisibleLen(line) + } + if plainLine != "" && strings.ContainsRune("╔║╟╚+|", []rune(plainLine)[0]) { + if got := color.VisibleLen(line); got != tableWidth || got > width { + t.Errorf("uneven table width %d (want %d, max %d): %q", got, tableWidth, width, plainLine) + } + } + if !unicode { + for _, r := range plainLine { + if r > 127 { + t.Errorf("non-ASCII %q", r) + } + } + } + } + } + } + } + } +} + +func TestCasinoHelpTopics(t *testing.T) { + g := &Game{Deps: Deps{DataDir: "../../data"}} + conn := &casinoTestConn{} + sess := &net.Session{Player: player.New("alice"), Conn: conn} + for topic, want := range map[string]string{ + "casino": "Profit odds", "play": "Casino quick guide", + "blackjack": "3:2", "baccarat": "0.95:1", "slots": "243", + "roulette": "35:1", "racetrack": "17/37", "zero": "Jeu Zero", + } { + conn.data = nil + g.doHelp(sess, topic) + if !strings.Contains(string(conn.data), want) { + t.Errorf("help %s: %s", topic, conn.data) + } + } +} + +func TestTableHelpTeachesCurrentGame(t *testing.T) { + for _, game := range []casino.GameName{casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette} { + g, sess, conn := uxCasinoGame(t) + g.DataDir = "../../data" + g.Casino.Join(1, casino.TableConfig{ID: "test", Game: game}, "alice") + conn.data = nil + g.executeTableStatus(sess, []string{"help"}, "table help") + text := stripANSI(string(conn.data)) + if strings.Contains(text, "No help") || !strings.Contains(text, "Table controls") || !strings.Contains(text, "pays") && !strings.Contains(text, "35:1") { + t.Fatalf("help %s: %s", game, text) + } + if game != casino.GameBlackjack && strings.Contains(text, "insurance / decline") { + t.Fatalf("blackjack-only controls shown for %s", game) + } + } + g, sess, conn := uxCasinoGame(t) + g.DataDir = "../../data" + g.executeTableStatus(sess, []string{"help"}, "table help") + if !strings.Contains(string(conn.data), "Casino quick guide") { + t.Fatal("unseated players cannot learn the games") + } +} + +func TestTableHelpAtSlotMachine(t *testing.T) { + g, sess, conn := uxCasinoGame(t) + g.DataDir = "../../data" + g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots}, "alice") + g.executeTableStatus(sess, []string{"help"}, "table help") + if !strings.Contains(string(conn.data), "Slots quick guide") { + t.Fatal("machine player did not get the slots guide") + } +} diff --git a/internal/game/render_help.go b/internal/game/render_help.go index b41af99..6066e9a 100644 --- a/internal/game/render_help.go +++ b/internal/game/render_help.go @@ -182,6 +182,7 @@ func (g *Game) doHelp(sess *net.Session, topic string) { sess.WriteLine("Active (replaces current action, queued per tick).") sess.WriteLine("") sess.WriteLine("Use 'help <command>' for detailed usage of a specific command.") + sess.WriteLine("Use 'help casino' for quick guides to casino bets, odds and payouts.") sess.WriteLine("Use 'option' to view and change settings, 'color' to customize colors.") return } diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go index 159fadc..f6c6bb7 100644 --- a/internal/game/render_roulette.go +++ b/internal/game/render_roulette.go @@ -76,33 +76,111 @@ func repeatRoulette(s string, n int) []string { return parts } -// renderRouletteRacetrack draws the French call-bet track above the board. The -// numbers are shown in wheel order for reference; the named bets are what the -// player places. The Neighbors line explains the one bet that takes a number. +// renderRouletteRacetrack draws the European wheel clockwise with 26 at +// the right-hand tip. Interior boundaries meet the pockets covered by each +// call bet; the bowed Zero boundary denotes its overlap with Voisins. func renderRouletteRacetrack(mode string, unicode bool) string { b := rouletteBorders(unicode) - tl, tr, bl, br := b.tl, b.tr, b.bl, b.br + tl, tr, bl, br := "/", "\\", "\\", "/" if unicode { tl, tr, bl, br = "╭", "╮", "╰", "╯" } - // The panel matches the betting board's 74-column footprint. - const width = 72 - bets := casino.RacetrackBets() - rows := make([]string, 0, len(bets)+1) - for _, bet := range bets { - label := fmt.Sprintf("%s (%d)", bet.Name, bet.Chips) - rows = append(rows, fmt.Sprintf("%-22s %s", label, strings.Join(bet.Series, " "))) - } - rows = append(rows, fmt.Sprintf("%-22s %s", "Neighbors (9)", "neighbors <n>: n plus four each side")) - lines := []string{rouletteInk(mode, tl+strings.Repeat(b.h, width)+tr)} - for _, row := range rows { - row = " " + row - if pad := width - color.VisibleLen(row); pad > 0 { - row += strings.Repeat(" ", pad) + const width, height = 73, 11 + grid := make([][]rouletteCell, height) + for y := range grid { + grid[y] = make([]rouletteCell, width) + for x := range grid[y] { + grid[y][x] = rouletteCell{' ', -1} + } + } + put := func(x, y int, text string, bg int) { + for _, r := range text { + grid[y][x] = rouletteCell{r, bg} + x++ + } + } + ink := func(x, y int, text string) { put(x, y, text, -1) } + wheel := casino.WheelOrder(false) + pocket := func(x, y, index, width int) { + n := wheel[index%len(wheel)] + bg, name := roulettePocketColor(n) + if mode == "none" || mode == "" { + n += name[:1] + } + put(x, y, rouletteCenter(n, width), bg) + } + // Equal-width bands, but slightly wider Orphelins cells below: five + // pockets (1,20,14,31,9) above face three (6,34,17) below. + band := func(y, start, step int, widths []int) { + x := 4 + ink(x, y, b.v) + for i, w := range widths { + ink(x, y-1, b.teeTop+strings.Repeat(b.h, w-1)) + ink(x, y+1, b.teeBottom+strings.Repeat(b.h, w-1)) + pocket(x+1, y, start+i*step, w-1) + x += w + ink(x, y, b.v) } - lines = append(lines, rouletteInk(mode, b.v+row+b.v)) } - lines = append(lines, rouletteInk(mode, bl+strings.Repeat(b.h, width)+br)) + band(1, 19, 1, []int{4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4}) + band(9, 15, -1, []int{4, 4, 4, 4, 4, 5, 5, 6, 4, 4, 4, 4, 4, 4, 4}) + ink(4, 0, tl) + ink(68, 0, tr) + ink(4, 10, bl) + ink(68, 10, br) + // Connected, stepped curves join the long bands to three end pockets. + ink(1, 1, tl+strings.Repeat(b.h, 2)+b.teeRight) + ink(68, 1, b.teeLeft+strings.Repeat(b.h, 2)+tr) + ink(0, 2, tl+b.teeBottom+strings.Repeat(b.h, 2)+b.cross) + ink(68, 2, b.cross+strings.Repeat(b.h, 2)+b.teeBottom+tr) + ink(0, 8, bl+b.teeTop+strings.Repeat(b.h, 2)+b.cross) + ink(68, 8, b.cross+strings.Repeat(b.h, 2)+b.teeTop+br) + ink(1, 9, bl+strings.Repeat(b.h, 2)+b.teeRight) + ink(68, 9, b.teeLeft+strings.Repeat(b.h, 2)+br) + for i := 0; i < 3; i++ { + y := 3 + 2*i + for _, x := range []int{0, 4, 68, 72} { + ink(x, y, b.v) + } + pocket(1, y, 18-i, 3) // 10,23,8 + pocket(69, y, 35+i, 3) // 3,26,0 + if i < 2 { + ink(0, y+1, b.teeLeft+strings.Repeat(b.h, 3)+b.teeRight) + ink(68, y+1, b.teeLeft+strings.Repeat(b.h, 3)+b.teeRight) + } + } + // Connected steps meet 33/1 above and 27/6 below. Diagonal glyphs + // only touch at cell corners and leave visible gaps in many fonts. + ink(20, 3, b.v) + ink(20, 4, b.bl+b.h+b.tr) + ink(22, 5, b.v) + ink(22, 6, b.bl+b.h+b.tr) + ink(24, 7, b.v) + for y := 3; y <= 7; y++ { + ink(40, y, b.v) + } + for _, p := range [][2]int{{20, 2}, {24, 8}, {40, 2}, {40, 8}, {60, 2}, {60, 8}} { + ink(p[0], p[1], b.cross) + } + // Zero includes 12,35,3,26,0,32,15, entirely within Voisins. + ink(59, 3, tl+br) + ink(58, 4, tl+br) + ink(58, 5, b.v) + ink(58, 6, bl+tr) + ink(59, 7, bl+tr) + for _, label := range []struct { + left, right int + text string + }{ + {4, 22, "Tiers du Cylindre"}, {22, 40, "Orphelins"}, + {40, 58, "Voisins du Zero"}, {58, 68, "Jeu Zero"}, + } { + ink(label.left+1, 5, rouletteCenter(label.text, label.right-label.left-1)) + } + lines := make([]string, height) + for y, row := range grid { + lines[y] = renderRouletteRow(row, mode) + } return strings.Join(lines, "\n") } @@ -157,9 +235,9 @@ func renderRouletteLayout(double, racetrack bool, mode string, unicode bool) str zero := "" switch { case double && row == 3: - zero = "0" - case double && row == 1: zero = "00" + case double && row == 1: + zero = "0" case !double && row == 2: zero = "0" } @@ -464,20 +542,6 @@ func renderRouletteResult(pockets [3]string, mode string, unicode bool, size int return strings.Join(lines, "\n") } -// rouletteReminder is the odds/cheat-sheet block, shown only by "table" (and -// "table rules"), never with the ambient betting board. Racetrack tables also -// list their call-bet syntax. -func (g *Game) rouletteReminder(sess *net.Session, cfg casino.TableConfig) string { - text := "Numbers 35:1 | Split 17:1 | Street/trio 11:1 | Corner 8:1 | Line 5:1 | Dozens & rows 2:1 | Outside 1:1\n" + - "Combos: split 1 2 | street 1 2 3 | corner 1 2 4 5 | line 1 2 3 4 5 6 | trio 0 1 2 | basket (double zero)\n" - if cfg.Roulette.Racetrack { - text += "Racetrack: bet 10 on voisins | tiers | orphelins | zero spiel | neighbors 17\n" - } - text += "Low: 1-18 | High: 19-36 | Zero loses all outside bets\n" + - "bet 10 on 17 / bet 10 on red / bet 10 on split 1 2" - return g.colorize(sess, "casino_action", text) -} - func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string { rules := g.colorize(sess, "casino_action", cfg.RulesText()) if cfg.Game == casino.GameRoulette { diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go index d992abf..8d9c141 100644 --- a/internal/game/render_roulette_test.go +++ b/internal/game/render_roulette_test.go @@ -68,7 +68,7 @@ func TestRouletteArtModes(t *testing.T) { layout := plainRoulette(layoutArt) rows := strings.Split(layout, "\n") if double { - if !strings.Contains(string([]rune(rows[1])[:7]), "0") || !strings.Contains(string([]rune(rows[5])[:7]), "00") { + if !strings.Contains(string([]rune(rows[1])[:7]), "00") || !strings.Contains(string([]rune(rows[5])[:7]), "0") { t.Fatal("double zero not left of grid", layout) } } else if !strings.Contains(string([]rune(rows[3])[:7]), "0") { @@ -227,14 +227,14 @@ func TestRouletteZeroPanel(t *testing.T) { } // TestRouletteRacetrackPanel checks that an enabled racetrack adds the call-bet -// panel above the board, names all four call bets and the neighbours bet, and +// panel above the board, names the four call-bet areas (not Neighbors), and // keeps the same width and ASCII/Unicode guarantees as the board. func TestRouletteRacetrackPanel(t *testing.T) { for _, mode := range []string{"none", "ansi", "xterm256"} { for _, unicode := range []bool{false, true} { art := renderRouletteLayout(false, true, mode, unicode) plain := plainRoulette(art) - for _, want := range []string{"Voisins du Zero", "Tiers du Cylindre", "Orphelins", "Zero Spiel", "Neighbors (9)"} { + for _, want := range []string{"Tiers du Cylindre", "Orphelins", "Voisins du Zero", "Zero"} { if !strings.Contains(plain, want) { t.Fatalf("mode=%s unicode=%v: missing %q:\n%s", mode, unicode, want, plain) } @@ -255,11 +255,11 @@ func TestRouletteRacetrackPanel(t *testing.T) { } // The panel comes from the snapshot flag, and a plain board omits it. snap := &casino.RouletteSnapshot{Phase: "betting", Wheel: casino.WheelOrder(false), Racetrack: true} - if !strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins du Zero") { + if !strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins") { t.Fatal("racetrack snapshot did not draw the panel") } snap.Racetrack = false - if strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins du Zero") { + if strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins") { t.Fatal("plain snapshot drew the racetrack panel") } } @@ -404,3 +404,136 @@ func TestRouletteSettlementShowsBalanceOnce(t *testing.T) { } } } + +// Read the perimeter clockwise, not in terminal row order. Every displayed +// pocket must match the actual European wheel, including the wraparound. +func TestRouletteRacetrackPhysicalOrder(t *testing.T) { + for _, unicode := range []bool{false, true} { + art := renderRouletteRacetrack("none", unicode) + rows := strings.Split(art, "\n") + top, bottom := racetrackNumbers(rows[1]), racetrackNumbers(rows[9]) + if len(top) != 16 || len(bottom) != 15 { + t.Fatalf("missing track pockets:\n%s", art) + } + order := append([]string(nil), top...) + for _, i := range []int{3, 5, 7} { + side := racetrackNumbers(rows[i]) + if len(side) != 2 { + t.Fatalf("missing curved-end pockets:\n%s", art) + } + order = append(order, side[1]) + } + for i := len(bottom) - 1; i >= 0; i-- { + order = append(order, bottom[i]) + } + for _, i := range []int{7, 5, 3} { + order = append(order, racetrackNumbers(rows[i])[0]) + } + wheel := casino.WheelOrder(false) + if len(order) != len(wheel) { + t.Fatalf("got %d pockets", len(order)) + } + for i, p := range order { + if p != wheel[(19+i)%len(wheel)] { + t.Errorf("pocket %d: %s", i, p) + } + } + if got := racetrackNumbers(rows[5]); got[1] != "26" { + t.Fatalf("right tip is %s, want 26", got[1]) + } + } +} + +func racetrackNumbers(row string) []string { + var out []string + for _, match := range regexp.MustCompile(`([0-9]+)[RBG]`).FindAllStringSubmatch(row, -1) { + out = append(out, match[1]) + } + return out +} + +// The sections must touch their actual bet coverage, not merely contain +// labels. Zero is a nested part of Voisins, not a fourth disjoint sector. +func TestRouletteRacetrackSections(t *testing.T) { + for _, unicode := range []bool{false, true} { + rows := strings.Split(renderRouletteRacetrack("none", unicode), "\n") + segment := func(y, left, right int) string { return string([]rune(rows[y])[left:right]) } + nums := func(y, left, right int) []string { return racetrackNumbers(segment(y, left, right)) } + sections := map[string][]string{ + "tiers": append(nums(1, 4, 20), nums(9, 4, 24)...), + "orphelins": append(nums(1, 20, 40), nums(9, 24, 40)...), + "voisins": append(nums(1, 40, 60), nums(9, 40, 60)...), + "jeuzero": append(nums(1, 60, 68), nums(9, 60, 68)...), + } + for _, y := range []int{3, 5, 7} { + sections["tiers"] = append(sections["tiers"], nums(y, 0, 4)...) + sections["jeuzero"] = append(sections["jeuzero"], nums(y, 68, 73)...) + } + sections["voisins"] = append(sections["voisins"], sections["jeuzero"]...) + for _, bet := range casino.RacetrackBets() { + got := sections[bet.Syntax] + if len(got) != len(bet.Series) { + t.Fatalf("%s: coverage %v, want %v", bet.Syntax, got, bet.Series) + } + seen := map[string]bool{} + for _, n := range got { + seen[n] = true + } + for _, n := range bet.Series { + if !seen[n] { + t.Errorf("%s does not touch %s", bet.Syntax, n) + } + } + } + for _, label := range []struct { + left, right int + text string + }{ + {4, 22, "Tiers du Cylindre"}, {22, 40, "Orphelins"}, {40, 58, "Voisins du Zero"}, {58, 68, "Jeu Zero"}, + } { + if strings.TrimSpace(segment(5, label.left+1, label.right)) != label.text { + t.Errorf("misplaced %s label", label.text) + } + } + // Every step joins orthogonally; no font-dependent diagonal gaps. + b := rouletteBorders(unicode) + for _, step := range []struct { + x, y int + text string + }{ + {20, 3, b.v}, {20, 4, b.bl + b.h + b.tr}, {22, 5, b.v}, + {22, 6, b.bl + b.h + b.tr}, {24, 7, b.v}, + } { + if got := segment(step.y, step.x, step.x+len([]rune(step.text))); got != step.text { + t.Errorf("disconnected step at %d,%d: %q", step.x, step.y, got) + } + } + // The Zero boundary bows into Voisins then returns to the same column. + for _, p := range [][2]int{{60, 2}, {59, 3}, {58, 4}, {58, 5}, {58, 6}, {59, 7}, {60, 8}} { + if []rune(rows[p[1]])[p[0]] == ' ' { + t.Errorf("gap in Zero curve at %v", p) + } + } + } + for _, mode := range []string{"none", "ansi", "xterm256"} { + for _, unicode := range []bool{false, true} { + art := renderRouletteRacetrack(mode, unicode) + if strings.Contains(strings.ToLower(art), "neighbor") { + t.Fatal("Neighbors belongs in help, not on the racetrack") + } + rows := strings.Split(art, "\n") + for y, row := range rows { + wantWidth := 73 + if y == 0 || y == 10 { + wantWidth = 69 + } + if y == 1 || y == 9 { + wantWidth = 72 + } + if color.VisibleLen(row) != wantWidth { + t.Errorf("mode=%s unicode=%v: row %d width=%d, want %d", mode, unicode, y, color.VisibleLen(row), wantWidth) + } + } + } + } +} diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go index 333e6a9..e581435 100644 --- a/internal/game/roulette_test.go +++ b/internal/game/roulette_test.go @@ -108,7 +108,10 @@ func TestRouletteRacetrackTargetParsing(t *testing.T) { {"10", "on", "voisins", "du", "zero"}, {"10", "on", "tiers"}, {"10", "on", "orphelins"}, - {"10", "on", "zero", "spiel"}, + {"10", "on", "zero"}, + {"10", "on", "jeu", "zero"}, + {"10", "on", "jeuzero"}, + {"10", "on", "0"}, {"10", "on", "neighbors", "17"}, {"10", "on", "neighbors", "of", "17"}, } |
