diff options
| author | historia <[not public]> | 2026-09-29 14:15:06 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-29 14:15:06 -0400 |
| commit | 3db3071c444abb2133e1fcaf7e685095415c1adf (patch) | |
| tree | a1d3c276aa7db2e89ea92a5751a81c197a1e1103 | |
| parent | 9803facd4368420a5dce3af59c835e13cb015cf5 (diff) | |
| download | thehouseoficarus-3db3071c444abb2133e1fcaf7e685095415c1adf.tar.gz | |
feat: better roulette wheel design
| -rw-r--r-- | building_guide/roulette.md | 23 | ||||
| -rw-r--r-- | internal/casino/roulette.go | 61 | ||||
| -rw-r--r-- | internal/casino/roulette_test.go | 19 | ||||
| -rw-r--r-- | internal/casino/table_ux.go | 22 | ||||
| -rw-r--r-- | internal/game/blackjack_rules.go | 2 | ||||
| -rw-r--r-- | internal/game/casino.go | 12 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 6 | ||||
| -rw-r--r-- | internal/game/cmd_look.go | 37 | ||||
| -rw-r--r-- | internal/game/look_alignment_test.go | 34 | ||||
| -rw-r--r-- | internal/game/look_entities.go | 2 | ||||
| -rw-r--r-- | internal/game/render_roulette.go | 333 | ||||
| -rw-r--r-- | internal/game/render_roulette_test.go | 217 | ||||
| -rw-r--r-- | internal/game/roulette_test.go | 2 |
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}) { |
