aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino_ux.go2
-rw-r--r--internal/game/render_roulette.go59
-rw-r--r--internal/game/render_roulette_test.go66
-rw-r--r--internal/game/roulette_test.go44
4 files changed, 143 insertions, 28 deletions
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index e218653..de62797 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -112,7 +112,7 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
}
g.emitCasinoEvents(t.Status(sess.Player.Name))
if t.Config.Game == casino.GameRoulette {
- sess.WriteLine(g.rouletteReminder(sess))
+ sess.WriteLine(g.rouletteReminder(sess, t.Config))
}
if len(args) > 0 {
sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", []casino.MenuCommand{
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
index 47ba5c6..159fadc 100644
--- a/internal/game/render_roulette.go
+++ b/internal/game/render_roulette.go
@@ -21,7 +21,7 @@ func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode boo
case "result":
return renderRouletteResult(s.Pockets, mode, unicode, size)
default:
- return renderRouletteLayout(s.DoubleZero, mode, unicode)
+ return renderRouletteLayout(s.DoubleZero, s.Racetrack, mode, unicode)
}
}
@@ -76,18 +76,49 @@ 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.
+func renderRouletteRacetrack(mode string, unicode bool) string {
+ b := rouletteBorders(unicode)
+ tl, tr, bl, br := b.tl, b.tr, b.bl, b.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)
+ }
+ lines = append(lines, rouletteInk(mode, b.v+row+b.v))
+ }
+ lines = append(lines, rouletteInk(mode, bl+strings.Repeat(b.h, width)+br))
+ return strings.Join(lines, "\n")
+}
+
// renderRouletteLayout draws the betting board. Green is reserved for the
// 0/00 lane: every other cell is red, black, or plain. The odds reminder is
// not part of the board (it is shown by the "table" command), so the board
// stays compact enough to sit in a betting menu.
-func renderRouletteLayout(double bool, mode string, unicode bool) string {
+func renderRouletteLayout(double, racetrack bool, mode string, unicode bool) string {
+ // The call bets are a single-zero layout; never draw the track on an
+ // American board even if a malformed snapshot asks for it.
+ racetrack = racetrack && !double
b := rouletteBorders(unicode)
var lines []string
- title := "ROULETTE / SINGLE ZERO"
- if double {
- title = "ROULETTE / DOUBLE ZERO"
+ if racetrack {
+ lines = append(lines, renderRouletteRacetrack(mode, unicode))
}
- lines = append(lines, rouletteInk(mode, title))
// Five columns for the zero lane, twelve four-column pockets, and a
// six-column 2:1 lane on the right holding the row bets. Four adjacent
// pockets line up exactly over each 19-column dozen.
@@ -434,11 +465,15 @@ func renderRouletteResult(pockets [3]string, mode string, unicode bool, size int
}
// rouletteReminder is the odds/cheat-sheet block, shown only by "table" (and
-// "table rules"), never with the ambient betting board.
-func (g *Game) rouletteReminder(sess *net.Session) string {
+// "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" +
- "Low: 1-18 | High: 19-36 | Zero loses all outside bets\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)
}
@@ -446,8 +481,8 @@ func (g *Game) rouletteReminder(sess *net.Session) string {
func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string {
rules := g.colorize(sess, "casino_action", cfg.RulesText())
if cfg.Game == casino.GameRoulette {
- snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)}
- rules += "\n" + g.renderCasinoSnapshot(sess, snap) + "\n" + g.rouletteReminder(sess)
+ snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Racetrack: cfg.Roulette.Racetrack, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)}
+ rules += "\n" + g.renderCasinoSnapshot(sess, snap) + "\n" + g.rouletteReminder(sess, cfg)
}
return rules
}
diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go
index 1cb75ce..d992abf 100644
--- a/internal/game/render_roulette_test.go
+++ b/internal/game/render_roulette_test.go
@@ -64,14 +64,14 @@ func TestRouletteArtModes(t *testing.T) {
}
}
- layoutArt := renderRouletteLayout(double, mode, unicode)
+ layoutArt := renderRouletteLayout(double, false, mode, unicode)
layout := plainRoulette(layoutArt)
rows := strings.Split(layout, "\n")
if double {
- if !strings.Contains(string([]rune(rows[2])[:7]), "0") || !strings.Contains(string([]rune(rows[6])[:7]), "00") {
+ if !strings.Contains(string([]rune(rows[1])[:7]), "0") || !strings.Contains(string([]rune(rows[5])[:7]), "00") {
t.Fatal("double zero not left of grid", layout)
}
- } else if !strings.Contains(string([]rune(rows[4])[:7]), "0") {
+ } else if !strings.Contains(string([]rune(rows[3])[:7]), "0") {
t.Fatal("zero not left of grid", layout)
}
for _, target := range []string{"1to12", "13to24", "25to36", "row1", "row2", "row3", "red", "black", "odd", "even", "low", "high"} {
@@ -157,12 +157,12 @@ func TestRouletteWheelGreenPockets(t *testing.T) {
// each outside-bet divider.
func TestRouletteLayoutConnections(t *testing.T) {
for _, double := range []bool{false, true} {
- art := plainRoulette(renderRouletteLayout(double, "xterm256", true))
+ art := plainRoulette(renderRouletteLayout(double, false, "xterm256", true))
rows := strings.Split(art, "\n")
- // rows[7] is the numbered grid's bottom edge, directly above the dozen
+ // rows[6] is the numbered grid's bottom edge, directly above the dozen
// labels. The dozen boundaries and the grid/row-lane joint cross; the
// row-bet lane closes with a bottom-right corner.
- gridBottom := []rune(rows[7])
+ gridBottom := []rune(rows[6])
for _, col := range []int{6, 26, 46, 66} {
if gridBottom[col] != '┼' {
t.Fatalf("double=%v: grid bottom col %d = %q, want ┼:\n%s", double, col, gridBottom[col], art)
@@ -173,15 +173,15 @@ func TestRouletteLayoutConnections(t *testing.T) {
}
// The row-bet lane is boxed: its separators cross at the grid edge and
// close on the right.
- for _, idx := range []int{3, 5} {
+ for _, idx := range []int{2, 4} {
sep := []rune(rows[idx])
if sep[66] != '┼' || sep[73] != '┤' {
t.Fatalf("double=%v: row lane separator %q lacks box joints:\n%s", double, rows[idx], art)
}
}
- // rows[9] is the dozen boxes' bottom edge: tees point down where the
+ // rows[8] is the dozen boxes' bottom edge: tees point down where the
// even-money dividers sit and crosses pass the dozen dividers through.
- dozenBottom := []rune(rows[9])
+ dozenBottom := []rune(rows[8])
wantDozen := map[int]rune{6: '├', 16: '┬', 26: '┼', 36: '┬', 46: '┼', 56: '┬', 66: '┤'}
for col, want := range wantDozen {
if dozenBottom[col] != want {
@@ -204,20 +204,20 @@ func TestRouletteLayoutConnections(t *testing.T) {
// Unicode mode while ASCII mode stays square.
func TestRouletteZeroPanel(t *testing.T) {
for _, double := range []bool{false, true} {
- colored := renderRouletteLayout(double, "xterm256", true)
+ colored := renderRouletteLayout(double, false, "xterm256", true)
rows := strings.Split(colored, "\n")
- // Interior rows 2-6: top numbered row, separator, middle, separator,
+ // Interior rows 1-5: top numbered row, separator, middle, separator,
// bottom numbered row. Each must contain the green background.
- for row := 2; row <= 6; row++ {
+ for row := 1; row <= 5; row++ {
if !strings.Contains(rows[row], color.BgCode("xterm256", 2)) {
t.Fatalf("double=%v: zero lane row %d is not green:\n%s", double, row, plainRoulette(colored))
}
}
plainRows := strings.Split(plainRoulette(colored), "\n")
- if !strings.HasPrefix(plainRows[1], "╭") || !strings.HasPrefix(plainRows[7], "╰") {
+ if !strings.HasPrefix(plainRows[0], "╭") || !strings.HasPrefix(plainRows[6], "╰") {
t.Fatalf("double=%v: zero panel corners are not rounded:\n%s", double, plainRoulette(colored))
}
- ascii := plainRoulette(renderRouletteLayout(double, "none", false))
+ ascii := plainRoulette(renderRouletteLayout(double, false, "none", false))
for _, r := range ascii {
if r > 127 {
t.Fatalf("double=%v: non-ASCII rune %q in ASCII mode", double, r)
@@ -226,6 +226,44 @@ 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
+// 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)"} {
+ if !strings.Contains(plain, want) {
+ t.Fatalf("mode=%s unicode=%v: missing %q:\n%s", mode, unicode, want, plain)
+ }
+ }
+ for _, line := range strings.Split(art, "\n") {
+ if color.VisibleLen(line) > 78 {
+ t.Fatalf("mode=%s unicode=%v: line exceeds terminal width: %q", mode, unicode, plainRoulette(line))
+ }
+ if !unicode {
+ for _, r := range plainRoulette(line) {
+ if r > 127 {
+ t.Fatalf("mode=%s: non-ASCII rune %q in ASCII mode", mode, r)
+ }
+ }
+ }
+ }
+ }
+ }
+ // 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") {
+ t.Fatal("racetrack snapshot did not draw the panel")
+ }
+ snap.Racetrack = false
+ if strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins du Zero") {
+ t.Fatal("plain snapshot drew the racetrack panel")
+ }
+}
+
func TestRouletteResultSizesAndNeighbors(t *testing.T) {
for _, unicode := range []bool{false, true} {
for _, size := range []int{1, 5, 7, 9} {
diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go
index 7a22d9b..333e6a9 100644
--- a/internal/game/roulette_test.go
+++ b/internal/game/roulette_test.go
@@ -95,6 +95,45 @@ func TestRouletteCombinationTargetParsing(t *testing.T) {
}
}
+func TestRouletteRacetrackTargetParsing(t *testing.T) {
+ // A racetrack single-zero table accepts the call and neighbours syntaxes.
+ g := &Game{Casino: casino.NewManager(1)}
+ cfg := casino.TableConfig{ID: "roulette", Game: casino.GameRoulette, Roulette: casino.RouletteConfig{Racetrack: true}}
+ g.Casino.Join(1, cfg, "alice")
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: player.New("alice"), Conn: conn}
+ g.loggedInChars = map[string]*net.Session{"alice": sess}
+ valid := [][]string{
+ {"10", "on", "voisins"},
+ {"10", "on", "voisins", "du", "zero"},
+ {"10", "on", "tiers"},
+ {"10", "on", "orphelins"},
+ {"10", "on", "zero", "spiel"},
+ {"10", "on", "neighbors", "17"},
+ {"10", "on", "neighbors", "of", "17"},
+ }
+ for _, args := range valid {
+ conn.data = nil
+ g.executeBet(sess, args, "bet "+strings.Join(args, " "))
+ if !strings.Contains(string(conn.data), "You don't have enough chips and credits") {
+ t.Fatalf("args=%v: expected wallet failure, got %q", args, conn.data)
+ }
+ }
+ // The same names are unknown on a single-zero table without the option.
+ plain := &Game{Casino: casino.NewManager(1)}
+ plain.Casino.Join(1, casino.TableConfig{ID: "roulette", Game: casino.GameRoulette}, "bob")
+ conn2 := &casinoTestConn{}
+ sess2 := &net.Session{Player: player.New("bob"), Conn: conn2}
+ plain.loggedInChars = map[string]*net.Session{"bob": sess2}
+ for _, args := range [][]string{{"10", "on", "voisins"}, {"10", "on", "neighbors", "17"}} {
+ conn2.data = nil
+ plain.executeBet(sess2, args, "bet "+strings.Join(args, " "))
+ if !strings.Contains(string(conn2.data), "Unknown roulette target") {
+ t.Fatalf("non-racetrack args=%v: expected rejection, got %q", args, conn2.data)
+ }
+ }
+}
+
func TestCasinoTestPortalAuthoredContract(t *testing.T) {
g, rooms, _ := loadVesta(t)
found := false
@@ -118,7 +157,7 @@ func TestCasinoTestPortalAuthoredContract(t *testing.T) {
if steps[1].Teleport != 6100 || rooms[6100] == nil {
t.Fatal("wrong casino destination")
}
- for _, id := range []int{6182, 6183} {
+ for _, id := range []int{6182, 6183, 6184} {
cfg := rooms[id].CasinoTables[0]
if err := cfg.Validate(); err != nil {
t.Fatal(err)
@@ -126,5 +165,8 @@ func TestCasinoTestPortalAuthoredContract(t *testing.T) {
if cfg.Game != casino.GameRoulette || cfg.Roulette.DoubleZero != (id == 6183) {
t.Fatal("wrong roulette rules", id)
}
+ if cfg.Roulette.Racetrack != (id == 6184) {
+ t.Fatal("wrong racetrack setting", id)
+ }
}
}