diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/casino_ux.go | 2 | ||||
| -rw-r--r-- | internal/game/render_roulette.go | 59 | ||||
| -rw-r--r-- | internal/game/render_roulette_test.go | 66 | ||||
| -rw-r--r-- | internal/game/roulette_test.go | 44 |
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) + } } } |
