aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-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
7 files changed, 478 insertions, 51 deletions
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"},
}