aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-29 14:15:06 -0400
committerhistoria <[not public]>2026-09-29 14:15:06 -0400
commit3db3071c444abb2133e1fcaf7e685095415c1adf (patch)
treea1d3c276aa7db2e89ea92a5751a81c197a1e1103
parent9803facd4368420a5dce3af59c835e13cb015cf5 (diff)
downloadthehouseoficarus-3db3071c444abb2133e1fcaf7e685095415c1adf.tar.gz
feat: better roulette wheel design
-rw-r--r--building_guide/roulette.md23
-rw-r--r--internal/casino/roulette.go61
-rw-r--r--internal/casino/roulette_test.go19
-rw-r--r--internal/casino/table_ux.go22
-rw-r--r--internal/game/blackjack_rules.go2
-rw-r--r--internal/game/casino.go12
-rw-r--r--internal/game/casino_ux.go6
-rw-r--r--internal/game/cmd_look.go37
-rw-r--r--internal/game/look_alignment_test.go34
-rw-r--r--internal/game/look_entities.go2
-rw-r--r--internal/game/render_roulette.go333
-rw-r--r--internal/game/render_roulette_test.go217
-rw-r--r--internal/game/roulette_test.go2
13 files changed, 682 insertions, 88 deletions
diff --git a/building_guide/roulette.md b/building_guide/roulette.md
index 205e044..ac977a6 100644
--- a/building_guide/roulette.md
+++ b/building_guide/roulette.md
@@ -23,7 +23,28 @@ casino_tables:
European 37-pocket wheel is used. Spin ticks default to 3; betting ticks use
the shared default. Ticks follow the server's configured tick length.
The wheel order is authentic and the outcome is selected uniformly only when
-spinning finishes. The spinning illustration is intentionally schematic.
+spinning finishes.
+
+## Presentation
+
+Art is rendered per recipient in `internal/game/render_roulette.go`, so color
+mode and Unicode preference stay per player (the engine snapshot never embeds
+ANSI). Three views share one renderer:
+
+- **Betting board**: numbered grid with zeros in the left lane, red/black
+ pockets, and outside bets. Green background is reserved for the 0/00 cells.
+- **Spinning**: a large circular wheel with every pocket placed and colored;
+ the pocket count carries the real zero count (one green for European, two
+ for American), and a hub marks the ball.
+- **Result**: the winning pocket plus its two physical wheel neighbours at
+ playing-card size, with the ball inside the winning pocket and no
+ surrounding prose.
+
+The odds reminder (`Numbers 35:1 ...`) is not part of the ambient board. It is
+shown only by the `table` command (and `table rules`). Each table's one-line
+overview is kept short (`Label | Limits 1-10000 | n/m seated | state`) so it
+fits beside a look minimap; `writeLookSideBySide` also wraps any content line
+that would otherwise run into the map column.
Initial scope is **one wager per player per spin**, covering straight-up and
outside bets (colors, parity, high/low, dozens, columns). Multiple simultaneous
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
index be8966c..ed649e9 100644
--- a/internal/casino/roulette.go
+++ b/internal/casino/roulette.go
@@ -22,6 +22,10 @@ func rouletteWheel(double bool) []string {
}
return europeanWheel
}
+
+// WheelOrder returns a copy of the pocket order (clockwise from the top) for
+// presentation layers that draw the wheel themselves.
+func WheelOrder(double bool) []string { return append([]string(nil), rouletteWheel(double)...) }
func rouletteRed(n int) bool {
switch n {
case 1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36:
@@ -101,7 +105,7 @@ func newRouletteTable(room int, cfg TableConfig, rng *rand.Rand) *rouletteTable
return &rouletteTable{tableCore: newTableCore[*roulettePlayer](&tableBase{RoomID: room, Config: cfg}), rng: rng}
}
func (t *rouletteTable) view() *RouletteSnapshot {
- s := &RouletteSnapshot{DoubleZero: t.Config.Roulette.DoubleZero, Phase: string(t.phase)}
+ s := &RouletteSnapshot{DoubleZero: t.Config.Roulette.DoubleZero, Phase: string(t.phase), Wheel: rouletteWheel(t.Config.Roulette.DoubleZero)}
for _, p := range t.players.ordered() {
s.Players = append(s.Players, p.Name)
}
@@ -109,7 +113,7 @@ func (t *rouletteTable) view() *RouletteSnapshot {
}
func (t *rouletteTable) menuEvent(name string) Event {
e := t.betMenuEvent(name, []MenuCommand{
- {Command: "bet <amount> on <target>", Desc: "One wager per spin; numbers pay 35:1, outside bets 1:1, dozens/columns 2:1"},
+ {Command: "bet <amount> on <target>", Desc: "Place one wager per spin (help roulette)"},
{Command: "bet / rebet", Desc: "Repeat your previous wager"},
{Command: "table / sitout / stop", Desc: "View layout / sit out / leave after this spin"},
})
@@ -256,7 +260,9 @@ func (t *rouletteTable) settle() []Event {
public = p.Name + "'s settlement is pending recovery."
}
event := t.playerAction(p.Name, public, text)
- event.ShowBalance = !failed
+ // Returning players get their balance in the next-spin menu. Only
+ // departures/sit-outs need a balance attached to the settlement.
+ event.ShowBalance = !failed && (p.LeaveAfter || p.SitOutAfter || !p.Connected)
if result == "win" && !failed {
event.Color = "casino_big_win"
}
@@ -296,7 +302,11 @@ func (t *rouletteTable) rebet(name string) (string, int) {
func (t *rouletteTable) status(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
- e := t.private(name, t.overviewLocked()+"\n"+t.seatStatusLocked(name))
+ message := t.overviewLocked()
+ if text := t.seatStatusLocked(name); text != "" {
+ message += "\n" + text
+ }
+ e := t.private(name, message)
e.Snapshot = t.view()
events := []Event{e}
if t.phase == phaseWaiting && t.lastResult != nil {
@@ -335,48 +345,17 @@ func (t *rouletteTable) refundAll() []Event {
return events
}
-// RouletteSnapshot keeps ASCII views available to spectators and players
-// without coupling the engine to terminal color or card-art settings.
+// RouletteSnapshot describes the view without coupling the engine to terminal
+// colors, Unicode preferences, or card-art settings.
type RouletteSnapshot struct {
DoubleZero bool
Phase string
Pockets [3]string // physical wheel neighbors, not numerical neighbors
Players []string
+ // Wheel is the pocket order clockwise from the top; the renderer colors
+ // and places each pocket. It carries the zero count (one or two) so the
+ // drawn wheel always matches the configured table.
+ Wheel []string
}
func (*RouletteSnapshot) snapshot() {}
-func (s *RouletteSnapshot) ASCII() string {
- if s.Phase == "result" {
- return fmt.Sprintf("+-----+-----+-----+\n| %-3s | %-3s | %-3s |\n| | o | |\n+-----+-----+-----+\n WINNER", s.Pockets[0], s.Pockets[1], s.Pockets[2])
- }
- if s.Phase == string(phasePlaying) {
- bottom := " * "
- if s.DoubleZero {
- bottom = "00 "
- }
- return " .--[ 0 ]--.\n / * | * \\\n | * --+-- * |\n \\ * | * /\n '--[" + bottom + "]--'\n Spinning"
- }
- var b strings.Builder
- b.WriteString("ROULETTE | R=red B=black G=green\n[0 G]")
- if s.DoubleZero {
- b.WriteString(" [00 G]")
- }
- b.WriteString("\n")
- for row := 3; row >= 1; row-- {
- b.WriteString("|")
- for n := row; n <= 36; n += 3 {
- color := "B"
- if rouletteRed(n) {
- color = "R"
- }
- fmt.Fprintf(&b, "%2d%s|", n, color)
- }
- fmt.Fprintf(&b, " column%d\n", row)
- }
- b.WriteString("| dozen1 | dozen2 | dozen3 |\n| low (1-18) | even | red | black | odd | high (19-36) |\nTargets: 0-36")
- if s.DoubleZero {
- b.WriteString(", 00")
- }
- b.WriteString(", red, black, odd, even, low, high,\n dozen1/2/3, column1/2/3\nExample: bet 10 on 17 (one wager per spin)")
- return b.String()
-}
diff --git a/internal/casino/roulette_test.go b/internal/casino/roulette_test.go
index 8defe1e..3ef3814 100644
--- a/internal/casino/roulette_test.go
+++ b/internal/casino/roulette_test.go
@@ -137,24 +137,7 @@ func TestRouletteRefundAndPersistenceFailure(t *testing.T) {
t.Fatal("failed settlement not refunded once")
}
}
-func TestRouletteViewsAndConfig(t *testing.T) {
- for _, double := range []bool{false, true} {
- s := &RouletteSnapshot{DoubleZero: double, Phase: "playing"}
- wheel := s.ASCII()
- if strings.Contains(wheel, "00") != double || strings.Count(wheel, "[ 0 ]") != 1 {
- t.Fatal("wrong wheel zero pockets", wheel)
- }
- s.Phase = "betting"
- layout := s.ASCII()
- if !strings.Contains(layout, "column3") || !strings.Contains(layout, "36R") || strings.Contains(layout, "[00 G]") != double {
- t.Fatal("bad layout", layout)
- }
- s.Phase = "result"
- s.Pockets = [3]string{"26", "0", "32"}
- if !strings.Contains(s.ASCII(), "| 26 | 0 | 32 |") {
- t.Fatal(s.ASCII())
- }
- }
+func TestRouletteConfig(t *testing.T) {
cfg := TableConfig{ID: "r", Game: GameRoulette}
if err := cfg.Validate(); err != nil {
t.Fatal(err)
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index 48ea46e..cec766d 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -13,8 +13,7 @@ func (c TableConfig) RulesText() string {
case GameBaccarat:
return c.BaccaratRulesText()
case GameRoulette:
- s := &RouletteSnapshot{DoubleZero: c.Roulette.DoubleZero, Phase: "betting"}
- return c.Label() + "\n" + s.ASCII() + "\nStraight numbers pay 35:1; dozens/columns 2:1; other outside bets 1:1.\nZero pockets lose all outside bets. One wager per spin. Use help roulette."
+ return c.Label() + "\nStraight numbers pay 35:1; dozens/columns 2:1; other outside bets 1:1.\nZero pockets lose all outside bets. One wager per spin. Use help roulette."
default:
return c.Label()
}
@@ -47,16 +46,25 @@ func (c *tableCore[P]) overview() string {
}
func (c *tableCore[P]) overviewLocked() string {
+ // Concise enough to sit beside a look minimap without wrapping into it.
+ // The betting countdown lives in the menu heading and warning lines.
state := "Betting open"
switch c.phase {
- case phaseBetting:
- state = fmt.Sprintf("Betting closes in %d ticks", c.window.timer.remaining())
case phasePlaying:
- state = "Round in progress"
+ state = "In progress"
case phaseInsurance:
- state = "Insurance decisions"
+ state = "Insurance"
}
- return fmt.Sprintf("%s | Limits %s | %d/%d seated | %s", c.Config.Label(), betRangeText(c.Config.MinBet, c.Config.MaxBet), c.players.count(), c.Config.MaxPlayers, state)
+ return fmt.Sprintf("%s | Limits %s | %d/%d seated | %s", c.Config.Label(), compactBetRange(c.Config.MinBet, c.Config.MaxBet), c.players.count(), c.Config.MaxPlayers, state)
+}
+
+// compactBetRange renders the wager range in the short form used by the
+// one-line table overview (the verbose form stays in the betting menu).
+func compactBetRange(minBet, maxBet int) string {
+ if maxBet == maxBetUnlimited {
+ return fmt.Sprintf("%d+", minBet)
+ }
+ return fmt.Sprintf("%d-%d", minBet, maxBet)
}
func (c *tableCore[P]) warning(name, text string) Event {
diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go
index b56d3ee..bab996c 100644
--- a/internal/game/blackjack_rules.go
+++ b/internal/game/blackjack_rules.go
@@ -39,7 +39,7 @@ func (g *Game) lookCardTable(sess *net.Session, target string, game casino.GameN
if !ok {
return false
}
- sess.WriteLine(table.Config.RulesText())
+ sess.WriteLine(g.renderCasinoRules(sess, table.Config))
return true
}
return false
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 857a6a8..230108f 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -551,14 +551,17 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
if event.Detail && !details {
continue
}
- message := g.casinoTimeText(event.Message)
+ // Style prose before composing it with independently styled art.
+ // Wrapping the finished message leaks casino_action into borders
+ // and plain spaces between a snapshot's own color resets.
+ message := g.colorize(sess, category, g.casinoTimeText(event.Message))
if details {
if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" {
message += "\n" + rendered
}
}
if message != "" {
- sess.WriteLine(g.colorize(sess, category, message))
+ sess.WriteLine(message)
}
}
}
@@ -579,13 +582,14 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
continue
}
privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, g.casinoTimeText(text))
+ privateMessage = g.renderCasinoMessage(sess, category, event.Menu, privateMessage)
if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" {
privateMessage += "\n" + rendered
}
if event.ShowBalance {
privateMessage += "\n" + g.casinoBalance(sess)
}
- sess.WriteLine(g.renderCasinoMessage(sess, category, event.Menu, privateMessage))
+ sess.WriteLine(privateMessage)
if category == "casino_menu" {
g.writePrompt(sess)
}
@@ -674,7 +678,7 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot)
}
switch s := snapshot.(type) {
case *casino.RouletteSnapshot:
- return s.ASCII()
+ return renderRouletteSnapshot(s, mode, unicode, size)
case *casino.SlotSnapshot:
return renderSlotSnapshot(s, mode, unicode)
case *casino.BlackjackSnapshot:
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index d5ed67e..d885665 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -107,11 +107,13 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
return
}
if len(args) > 0 && args[0] == "rules" {
- text := t.Config.RulesText()
- sess.WriteLine(g.colorize(sess, "casino_action", text))
+ 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))
+ }
if len(args) > 0 {
sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", []casino.MenuCommand{
{Command: "bet / rebet", Desc: "Repeat the displayed wager"},
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index f01b83c..1de58f8 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -133,33 +133,46 @@ func wrapText(text string, width int) []string {
}
func (g *Game) writeLookSideBySide(sess *net.Session, p *player.Player, contentLines []string, mapLines []string, descWidth int) {
- total := len(contentLines)
- if len(mapLines) > total {
- total = len(mapLines)
- }
- leftMap := p.OptionString("tiny_map") == "left"
mapWidth := descWidth
if mapWidth <= 0 {
mapWidth = 70
}
+ // Wrap any content line that would overrun the map column. Without this a
+ // long table status writes through the minimap; with it the status wraps
+ // and the map keeps its own column.
+ content := make([]string, 0, len(contentLines))
+ for _, line := range contentLines {
+ if visibleLen(line) <= mapWidth {
+ content = append(content, line)
+ continue
+ }
+ for _, part := range strings.Split(color.WrapANSI(line, mapWidth), "\r\n") {
+ content = append(content, part)
+ }
+ }
+ total := len(content)
+ if len(mapLines) > total {
+ total = len(mapLines)
+ }
+ leftMap := p.OptionString("tiny_map") == "left"
for i := 0; i < total; i++ {
- content := ""
- if i < len(contentLines) {
- content = contentLines[i]
+ piece := ""
+ if i < len(content) {
+ piece = content[i]
}
if i < len(mapLines) {
mapLine := mapLines[i]
if leftMap {
- sess.WriteLine(fmt.Sprintf("%s %s", mapLine, content))
+ sess.WriteLine(fmt.Sprintf("%s %s", mapLine, piece))
} else {
- pad := mapWidth - visibleLen(content)
+ pad := mapWidth - visibleLen(piece)
if pad < 0 {
pad = 0
}
- sess.WriteLine(content + strings.Repeat(" ", pad) + " " + mapLine)
+ sess.WriteLine(piece + strings.Repeat(" ", pad) + " " + mapLine)
}
} else {
- sess.WriteLine(content)
+ sess.WriteLine(piece)
}
}
}
diff --git a/internal/game/look_alignment_test.go b/internal/game/look_alignment_test.go
index 42af09a..3b45da1 100644
--- a/internal/game/look_alignment_test.go
+++ b/internal/game/look_alignment_test.go
@@ -9,6 +9,40 @@ import (
"thehouseoficarus/internal/player"
)
+func TestLookMiniMapLongContentWrapsWithoutCollision(t *testing.T) {
+ // A long status line (for example a room fixture's live table overview)
+ // must wrap before the map column instead of writing through it.
+ for _, side := range []string{"right", "left"} {
+ t.Run(side, func(t *testing.T) {
+ p := player.New("alice")
+ p.Options = map[string]any{"tiny_map": side, "wrap_width": 80}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: p, Conn: conn}
+ long := "Roulette / Single zero | Limits 1-10000 | 1/8 seated | Betting open and the wheel is spinning right now"
+ content := []string{long}
+ mapLines := []string{"╔═════╗", "║□-□-□║", "║ ↑ ║", "║ @ ║", "║ │ ║", "║ ■ ║", "╚═════╝"}
+ (&Game{}).writeLookSideBySide(sess, p, content, mapLines, 71)
+ lines := strings.Split(strings.TrimSuffix(string(conn.data), "\r\n"), "\r\n")
+ if len(lines) < len(mapLines) {
+ t.Fatalf("got %d lines, want at least %d", len(lines), len(mapLines))
+ }
+ for i, line := range lines[:len(mapLines)] {
+ index := strings.Index(line, mapLines[i])
+ if index < 0 {
+ t.Fatalf("line %d missing map row: %q", i, line)
+ }
+ want := 73
+ if side == "left" {
+ want = 0
+ }
+ if got := visibleLen(line[:index]); got != want {
+ t.Errorf("line %d: map column = %d, want %d (content collided)", i, got, want)
+ }
+ }
+ })
+ }
+}
+
func TestLookMiniMapAlignment(t *testing.T) {
for _, side := range []string{"right", "left"} {
for _, colored := range []bool{false, true} {
diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go
index 8bf762d..2507073 100644
--- a/internal/game/look_entities.go
+++ b/internal/game/look_entities.go
@@ -231,7 +231,7 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string {
var lines []string
for _, table := range room.CasinoTables {
cfg := table.Normalize()
- label := fmt.Sprintf("%s | Limits %d–%d | 0/%d seated | Betting open", cfg.Label(), cfg.MinBet, cfg.MaxBet, cfg.MaxPlayers)
+ label := fmt.Sprintf("%s | Limits %d-%d | 0/%d seated | Betting open", cfg.Label(), cfg.MinBet, cfg.MaxBet, cfg.MaxPlayers)
if g.Casino != nil {
if live := g.Casino.Table(room.ID, table.ID); live != nil {
label = g.casinoTimeText(live.Overview())
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
new file mode 100644
index 0000000..95e91ff
--- /dev/null
+++ b/internal/game/render_roulette.go
@@ -0,0 +1,333 @@
+package game
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+)
+
+// Roulette art is rendered per recipient, just like cards. Never embed ANSI
+// in engine snapshots: spectators may have different color/Unicode settings.
+func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode bool, size int) string {
+ switch s.Phase {
+ case "playing":
+ return renderRouletteWheel(s.Wheel, mode, unicode)
+ case "result":
+ return renderRouletteResult(s.Pockets, mode, unicode, size)
+ default:
+ return renderRouletteLayout(s.DoubleZero, mode, unicode)
+ }
+}
+
+func rouletteInk(mode, text string) string {
+ return color.Render(mode, color.ColorSpec{Fg: 15, Bg: -1}, text)
+}
+
+// Use the basic palette so both ANSI-16 and xterm256 give white-on-red,
+// white-on-green and white-on-black, rather than approximating a gradient.
+func roulettePaint(mode, text string, bg int) string {
+ return color.Render(mode, color.ColorSpec{Fg: 15, Bg: bg, Bold: true}, text)
+}
+
+func roulettePocketColor(pocket string) (int, string) {
+ if pocket == "0" || pocket == "00" {
+ return 2, "GREEN"
+ }
+ n, _ := strconv.Atoi(pocket)
+ switch n {
+ case 1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36:
+ return 1, "RED"
+ default:
+ return 0, "BLACK"
+ }
+}
+
+func rouletteCenter(text string, width int) string {
+ pad := max(0, width-color.VisibleLen(text))
+ return strings.Repeat(" ", pad/2) + text + strings.Repeat(" ", pad-pad/2)
+}
+
+type rouletteLines struct{ h, v, tl, tr, bl, br, teeTop, teeBottom, teeLeft, teeRight, cross string }
+
+func rouletteBorders(unicode bool) rouletteLines {
+ if unicode {
+ return rouletteLines{"─", "│", "┌", "┐", "└", "┘", "┬", "┴", "├", "┤", "┼"}
+ }
+ return rouletteLines{"-", "|", "+", "+", "+", "+", "+", "+", "+", "+", "+"}
+}
+
+func repeatRoulette(s string, n int) []string {
+ parts := make([]string, n)
+ for i := range parts {
+ parts[i] = s
+ }
+ return parts
+}
+
+// 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 {
+ b := rouletteBorders(unicode)
+ var lines []string
+ title := "ROULETTE / SINGLE ZERO"
+ if double {
+ title = "ROULETTE / DOUBLE ZERO"
+ }
+ lines = append(lines, rouletteInk(mode, title))
+ // Five columns for the zero lane, twelve four-column pockets. Four
+ // adjacent pockets line up exactly over each 19-column dozen.
+ gridRule := func(left, joint, right string) string {
+ return left + strings.Repeat(b.h, 5) + joint + strings.Join(repeatRoulette(strings.Repeat(b.h, 4), 12), joint) + right
+ }
+ lines = append(lines, rouletteInk(mode, gridRule(b.tl, b.teeTop, b.tr)))
+ for row := 3; row >= 1; row-- {
+ zero, zeroBg := "", -1
+ switch {
+ case double && row == 3:
+ zero, zeroBg = "0", 2
+ case double && row == 1:
+ zero, zeroBg = "00", 2
+ case !double && row == 2:
+ zero, zeroBg = "0", 2
+ }
+ if zeroBg == 2 && (mode == "none" || mode == "") {
+ zero += "G"
+ }
+ line := rouletteInk(mode, b.v) + roulettePaint(mode, rouletteCenter(zero, 5), zeroBg) + rouletteInk(mode, b.v)
+ for n := row; n <= 36; n += 3 {
+ pocket := strconv.Itoa(n)
+ bg, name := roulettePocketColor(pocket)
+ if mode == "none" || mode == "" {
+ pocket += name[:1]
+ }
+ line += roulettePaint(mode, rouletteCenter(pocket, 4), bg) + rouletteInk(mode, b.v)
+ }
+ line += rouletteInk(mode, fmt.Sprintf(" column%d", row))
+ lines = append(lines, line)
+ if row > 1 {
+ // The zero lane spans the grid height; only its labeled cell is green.
+ line = rouletteInk(mode, b.v+" "+b.teeLeft+strings.Join(repeatRoulette(strings.Repeat(b.h, 4), 12), b.cross)+b.teeRight)
+ lines = append(lines, line)
+ }
+ }
+ lines = append(lines, rouletteInk(mode, gridRule(b.bl, b.teeBottom, b.br)))
+ // Outside bets share the exact 60-column numbered-grid footprint.
+ indent := " "
+ dozen := indent + rouletteInk(mode, b.v)
+ for i := 1; i <= 3; i++ {
+ dozen += rouletteInk(mode, rouletteCenter(fmt.Sprintf("dozen%d", i), 19)) + rouletteInk(mode, b.v)
+ }
+ lines = append(lines, dozen)
+ line := indent + rouletteInk(mode, b.v)
+ for _, bet := range []string{"low", "even", "red", "black", "odd", "high"} {
+ bg := -1
+ if bet == "red" {
+ bg = 1
+ }
+ if bet == "black" {
+ bg = 0
+ }
+ line += roulettePaint(mode, rouletteCenter(bet, 9), bg) + rouletteInk(mode, b.v)
+ }
+ lines = append(lines, line, rouletteInk(mode, indent+b.bl+strings.Repeat(b.h, 59)+b.br))
+ if mode == "none" || mode == "" {
+ lines = append(lines, "R = red, B = black, G = green")
+ }
+ return strings.Join(lines, "\n")
+}
+
+// rouletteWheelSlots are the pocket anchor points (clockwise from the top) for
+// the single-zero ring. The double-zero ring inserts one extra slot at the
+// bottom centre. This hand-tuned geometry is what keeps a full 37/38-pocket
+// circle readable in a monospace terminal.
+var rouletteWheelSlots = [][2]int{
+ {25, 0}, {33, 0}, {41, 0}, {46, 1}, {50, 2}, {53, 3}, {57, 4}, {59, 6}, {59, 7},
+ {60, 9}, {61, 11}, {60, 13}, {58, 15}, {58, 16}, {56, 18}, {54, 19}, {50, 20},
+ {46, 21}, {41, 22}, {33, 22}, {25, 22}, {20, 21}, {17, 20}, {13, 19}, {9, 18},
+ {7, 17}, {5, 16}, {4, 14}, {3, 12}, {3, 10}, {4, 8}, {7, 6}, {9, 5}, {12, 4},
+ {14, 3}, {17, 2}, {21, 1},
+}
+
+func rouletteWheelSlotsFor(double bool) [][2]int {
+ slots := append([][2]int(nil), rouletteWheelSlots...)
+ if !double {
+ return slots
+ }
+ insert := 20 // between {33,22} and {25,22}
+ slots = append(slots, [2]int{})
+ copy(slots[insert+1:], slots[insert:])
+ slots[insert] = [2]int{29, 22}
+ return slots
+}
+
+// roulettePocketToken renders one three-column pocket. With color it is just
+// the number; without color a trailing letter keeps red/black/green distinct.
+func roulettePocketToken(value, label, mode string) string {
+ if mode == "none" || mode == "" {
+ switch len(value) {
+ case 1:
+ return value + label[:1] + " "
+ case 2:
+ return value + label[:1]
+ default:
+ return value
+ }
+ }
+ return rouletteCenter(value, 3)
+}
+
+type rouletteCell struct {
+ r rune
+ bg int
+}
+
+// renderRouletteWheel draws a large circular wheel. The pocket count carries
+// the real zero count (one green for single zero, two for American), and the
+// hub shows the ball during the spin.
+func renderRouletteWheel(wheel []string, mode string, unicode bool) string {
+ double := false
+ for _, p := range wheel {
+ if p == "00" {
+ double = true
+ }
+ }
+ slots := rouletteWheelSlotsFor(double)
+ if len(wheel) != len(slots) {
+ slots = rouletteWheelSlots
+ if len(wheel) > len(slots) {
+ wheel = wheel[:len(slots)]
+ }
+ }
+ const width, height = 68, 23
+ grid := make([][]rouletteCell, height)
+ for y := range grid {
+ grid[y] = make([]rouletteCell, width)
+ for x := range grid[y] {
+ grid[y][x] = rouletteCell{' ', -1}
+ }
+ }
+ place := func(x, y int, s string, bg int) {
+ for k, r := range []rune(s) {
+ px := x + k
+ if px >= 0 && px < width && y >= 0 && y < height {
+ grid[y][px] = rouletteCell{r, bg}
+ }
+ }
+ }
+ for i, value := range wheel {
+ if i >= len(slots) {
+ break
+ }
+ bg, label := roulettePocketColor(value)
+ place(slots[i][0]-1, slots[i][1], roulettePocketToken(value, label, mode), bg)
+ }
+ hub := []string{"\\ | /", " \\ | / ", "--( o )--", " / | \\ ", "/ | \\"}
+ if unicode {
+ hub = []string{"╲ │ ╱", " ╲ │ ╱ ", "──( ● )──", " ╱ │ ╲ ", "╱ │ ╲"}
+ }
+ for k, text := range hub {
+ place(29, 9+k, text, -1)
+ }
+ lines := make([]string, 0, height)
+ for _, row := range grid {
+ lines = append(lines, renderRouletteRow(row, mode))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func renderRouletteRow(row []rouletteCell, mode string) string {
+ last := len(row)
+ for last > 0 && row[last-1].r == ' ' {
+ last--
+ }
+ if last == 0 {
+ return ""
+ }
+ var sb strings.Builder
+ for i := 0; i < last; {
+ bg := row[i].bg
+ j := i
+ var seg []rune
+ for j < last && row[j].bg == bg {
+ seg = append(seg, row[j].r)
+ j++
+ }
+ text := string(seg)
+ if bg >= 0 && mode != "none" && mode != "" {
+ sb.WriteString(roulettePaint(mode, text, bg))
+ } else {
+ sb.WriteString(rouletteInk(mode, text))
+ }
+ i = j
+ }
+ return sb.String()
+}
+
+// renderRouletteResult draws the winning pocket and its two physical
+// neighbours at playing-card size. The ball marks the middle pocket; there is
+// no surrounding prose.
+func renderRouletteResult(pockets [3]string, mode string, unicode bool, size int) string {
+ size = cardRenderSize(unicode, size)
+ if size < 5 {
+ size = 5
+ }
+ width := size + 2
+ b := rouletteBorders(unicode)
+ ball := "o"
+ if unicode {
+ ball = "●"
+ }
+ var lines []string
+ for row := 0; row < size; row++ {
+ var cells []string
+ for i, pocket := range pockets {
+ bg, name := roulettePocketColor(pocket)
+ var text string
+ switch row {
+ case 0:
+ text = b.tl + strings.Repeat(b.h, width-2) + b.tr
+ case size - 1:
+ text = b.bl + strings.Repeat(b.h, width-2) + b.br
+ default:
+ content := ""
+ if row == 1 {
+ content = name
+ }
+ if row == size/2 {
+ content = pocket
+ }
+ if i == 1 && row == size/2+1 {
+ content = ball
+ }
+ text = b.v + rouletteCenter(content, width-2) + b.v
+ }
+ cells = append(cells, roulettePaint(mode, text, bg))
+ }
+ lines = append(lines, strings.Join(cells, " "))
+ }
+ 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.
+func (g *Game) rouletteReminder(sess *net.Session) string {
+ text := "Numbers 35:1 | Dozens / columns 2:1 | Other outside bets 1:1\n" +
+ "Low: 1-18 | High: 19-36 | Zero loses all outside bets\n" +
+ "bet 10 on 17 / bet 10 on red / bet 10 on dozen2"
+ 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 {
+ 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)
+ }
+ return rules
+}
diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go
new file mode 100644
index 0000000..1dde5ad
--- /dev/null
+++ b/internal/game/render_roulette_test.go
@@ -0,0 +1,217 @@
+package game
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+var rouletteSGR = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
+func plainRoulette(s string) string { return rouletteSGR.ReplaceAllString(s, "") }
+
+func TestRouletteArtModes(t *testing.T) {
+ for _, mode := range []string{"none", "ansi", "xterm256"} {
+ for _, unicode := range []bool{false, true} {
+ for _, double := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/unicode=%v/double=%v", mode, unicode, double), func(t *testing.T) {
+ wheelOrder := casino.WheelOrder(double)
+ s := &casino.RouletteSnapshot{DoubleZero: double, Wheel: wheelOrder}
+ for _, phase := range []string{"betting", "playing", "result"} {
+ s.Phase = phase
+ s.Pockets = [3]string{"26", "0", "32"}
+ art := renderRouletteSnapshot(s, mode, unicode, 7)
+ plain := plainRoulette(art)
+ if mode == "xterm256" && unicode && double {
+ t.Logf("%s preview (colors stripped):\n%s", phase, plain)
+ }
+ if strings.Contains(plain, "\x1b") {
+ t.Fatal("malformed ANSI")
+ }
+ if mode == "none" && art != plain {
+ t.Fatal("color disabled but ANSI present")
+ }
+ for _, r := range plain {
+ if !unicode && r > 127 {
+ t.Fatalf("non-ASCII rune %q in ASCII mode", r)
+ }
+ }
+ for _, line := range strings.Split(art, "\n") {
+ if color.VisibleLen(line) > 78 {
+ t.Fatalf("line exceeds terminal width: %q", plainRoulette(line))
+ }
+ }
+ if mode != "none" {
+ for _, bg := range []int{0, 1, 2} {
+ if !strings.Contains(art, color.BgCode(mode, bg)) {
+ t.Fatalf("missing pocket background %d", bg)
+ }
+ }
+ if !strings.Contains(art, color.FgCode(mode, 15)) || !strings.Contains(art, color.StyleCode("bold")) {
+ t.Fatal("missing bold white text")
+ }
+ if !strings.HasSuffix(art, color.Reset) {
+ t.Fatal("art leaves terminal colored")
+ }
+ }
+ }
+
+ layoutArt := renderRouletteLayout(double, 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") {
+ t.Fatal("double zero not left of grid", layout)
+ }
+ } else if !strings.Contains(string([]rune(rows[4])[:7]), "0") {
+ t.Fatal("zero not left of grid", layout)
+ }
+ for _, target := range []string{"column1", "column2", "column3", "dozen1", "dozen2", "dozen3", "red", "black", "odd", "even", "low", "high"} {
+ if !strings.Contains(layout, target) {
+ t.Fatal("missing bet", target)
+ }
+ }
+ // The odds reminder belongs to "table", never the board.
+ for _, reminder := range []string{"Numbers 35:1", "Low: 1-18", "bet 10 on"} {
+ if strings.Contains(layout, reminder) {
+ t.Fatal("reminder text leaked onto the betting board")
+ }
+ }
+ if mode != "none" {
+ wantGreen := 1
+ if double {
+ wantGreen = 2
+ }
+ if got := strings.Count(layoutArt, color.BgCode(mode, 2)); got != wantGreen {
+ t.Fatalf("green cells on board = %d, want %d", got, wantGreen)
+ }
+ }
+
+ wheelArt := renderRouletteWheel(wheelOrder, mode, unicode)
+ plainWheel := plainRoulette(wheelArt)
+ lines := strings.Split(plainWheel, "\n")
+ if len(lines) != 23 {
+ t.Fatalf("wheel height = %d, want 23", len(lines))
+ }
+ wantDoubleZeroLabel := 0
+ if double {
+ wantDoubleZeroLabel = 1
+ }
+ if got := strings.Count(plainWheel, "00"); got != wantDoubleZeroLabel {
+ t.Fatalf("double-zero labels = %d, want %d", got, wantDoubleZeroLabel)
+ }
+ if mode != "none" {
+ wantGreen := 1
+ if double {
+ wantGreen = 2
+ }
+ greens := strings.Count(wheelArt, color.BgCode(mode, 2))
+ if greens != wantGreen {
+ t.Fatalf("green pockets = %d, want %d", greens, wantGreen)
+ }
+ total := greens + strings.Count(wheelArt, color.BgCode(mode, 0)) + strings.Count(wheelArt, color.BgCode(mode, 1))
+ if total != len(wheelOrder) {
+ t.Fatalf("drawn pockets = %d, want %d", total, len(wheelOrder))
+ }
+ }
+
+ result := plainRoulette(renderRouletteResult([3]string{"1", "00", "27"}, mode, unicode, 7))
+ for _, banned := range []string{"PREV", "BALL", "NEXT", "WINNER"} {
+ if strings.Contains(result, banned) {
+ t.Fatalf("result prose %q should be removed:\n%s", banned, result)
+ }
+ }
+ })
+ }
+ }
+ }
+}
+
+func TestRouletteResultSizesAndNeighbors(t *testing.T) {
+ for _, unicode := range []bool{false, true} {
+ for _, size := range []int{1, 5, 7, 9} {
+ art := plainRoulette(renderRouletteResult([3]string{"1", "00", "27"}, "xterm256", unicode, size))
+ lines := strings.Split(art, "\n")
+ height := max(5, cardRenderSize(unicode, size))
+ width := height + 2
+ if len(lines) != height {
+ t.Fatalf("size=%d, height=%d want %d", size, len(lines), height)
+ }
+ for _, line := range lines {
+ if color.VisibleLen(line) != 3*width+2 {
+ t.Fatalf("unequal pocket widths: %q", line)
+ }
+ }
+ center := []rune(lines[height/2])
+ if !strings.Contains(string(center[:width]), "1") || !strings.Contains(string(center[width+1:2*width+1]), "00") || !strings.Contains(string(center[2*width+2:]), "27") {
+ t.Fatal("wrong pocket order", art)
+ }
+ ball := "o"
+ if unicode {
+ ball = "●"
+ }
+ if strings.Count(art, ball) != 1 {
+ t.Fatal("missing or duplicate ball", art)
+ }
+ }
+ }
+}
+
+func TestRouletteEventColorsDoNotWrapArt(t *testing.T) {
+ for _, public := range []bool{false, true} {
+ for _, mode := range []string{"none", "ansi", "xterm256"} {
+ g := &Game{Casino: casino.NewManager(1), Hub: net.NewHub()}
+ p := player.New("alice")
+ p.RoomID = 1
+ p.Options = map[string]any{"color": mode, "unicode": true}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: p, Conn: conn}
+ g.loggedInChars = map[string]*net.Session{"alice": sess}
+ g.Hub.EnterRoom(sess, 1)
+ // Use an emphatic blue background to detect whole-message color wrapping.
+ sess.Account = &player.Account{Colors: map[string]string{"casino_action": "0F bg:04 bold"}}
+ g.Casino.Join(1, casino.TableConfig{ID: "r", Game: casino.GameRoulette}, p.Name)
+ e := casino.Event{RoomID: 1, TableID: "r", Message: "Spinning now", Color: "casino_action", Public: public, Snapshot: &casino.RouletteSnapshot{Phase: "playing", Players: []string{p.Name}}}
+ if !public {
+ e.PrivateTo = p.Name
+ }
+ g.emitCasinoEvents([]casino.Event{e})
+ output := strings.ReplaceAll(string(conn.data), "\r\n", "\n")
+ expected := g.colorize(sess, "casino_action", e.Message) + "\n" + g.renderCasinoSnapshot(sess, e.Snapshot) + "\n"
+ if output != expected {
+ t.Fatalf("public=%v mode=%s: prose color leaked into art or snapshot missing\n%q\nwant\n%q", public, mode, output, expected)
+ }
+ }
+ }
+}
+
+func TestRouletteSettlementShowsBalanceOnce(t *testing.T) {
+ for _, action := range []string{"", "sitout", "leave_after"} {
+ g := &Game{Casino: casino.NewManager(7), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}}
+ p := player.New("alice")
+ p.Credits = 1000
+ p.Options = map[string]any{"color": "none"}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: p, Conn: conn}
+ g.loggedInChars = map[string]*net.Session{"alice": sess}
+ cfg := casino.TableConfig{ID: "r", Game: casino.GameRoulette, BettingTicks: 1, Roulette: casino.RouletteConfig{SpinTicks: 1}}
+ g.Casino.Join(1, cfg, p.Name)
+ g.Casino.Bet(1, "r", p.Name, "dozen2", 10, casinoWallet{g: g, p: p, saveCharacter: func(*player.Player) error { return nil }})
+ if action != "" {
+ g.Casino.Action(1, "r", p.Name, action)
+ }
+ g.Casino.Tick()
+ g.emitCasinoEvents(g.Casino.Tick())
+ if got := strings.Count(string(conn.data), "Available "); got != 1 {
+ t.Fatalf("action=%q: balance printed %d times\n%s", action, got, conn.data)
+ }
+ }
+}
diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go
index 0487b0b..9537710 100644
--- a/internal/game/roulette_test.go
+++ b/internal/game/roulette_test.go
@@ -34,7 +34,7 @@ func TestRouletteNumericTargetsAndRendering(t *testing.T) {
t.Fatal("wrong rules", string(conn.data))
}
snapshot := &casino.RouletteSnapshot{DoubleZero: double, Phase: "playing", Players: []string{"alice"}}
- if got := g.renderCasinoSnapshot(sess, snapshot); got != snapshot.ASCII() {
+ if got := g.renderCasinoSnapshot(sess, snapshot); got != renderRouletteSnapshot(snapshot, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size")) {
t.Fatal("snapshot not rendered", got)
}
if !g.seesCasinoDetails(sess, casino.Event{RoomID: 1, TableID: "roulette", Snapshot: snapshot}) {