aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-29 18:58:28 -0400
committerhistoria <[not public]>2026-09-29 18:58:28 -0400
commit624626b9a10ae47cf7d5f6e91765570ed759bd74 (patch)
tree9812f5d254b7d9cfe1d2fcb6da53362e974541b0 /internal
parentff9002b96e35ad0808945e7c35791460c9ca8776 (diff)
downloadthehouseoficarus-624626b9a10ae47cf7d5f6e91765570ed759bd74.tar.gz
feat: better roulette racetrack graphics and payout table
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/roulette.go71
-rw-r--r--internal/casino/roulette_geometry_test.go109
-rw-r--r--internal/casino/roulette_racetrack.go18
-rw-r--r--internal/casino/roulette_racetrack_test.go34
-rw-r--r--internal/casino/table_ux.go4
-rw-r--r--internal/game/casino_ux.go37
-rw-r--r--internal/game/render_casino_help.go85
-rw-r--r--internal/game/render_casino_help_test.go122
-rw-r--r--internal/game/render_help.go1
-rw-r--r--internal/game/render_roulette.go136
-rw-r--r--internal/game/render_roulette_test.go143
-rw-r--r--internal/game/roulette_test.go5
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"},
}