aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-30 03:55:37 -0400
committerhistoria <[not public]>2026-09-30 03:55:37 -0400
commit735428f0667f67c12fdf3caca872d7dc65e2ce26 (patch)
tree92c9931f223bfd50ced4f1954130d879640a435b /internal/game
parentffc7b582e9c14fdc2e579b9239b5ff7ad9a0d90a (diff)
downloadthehouseoficarus-735428f0667f67c12fdf3caca872d7dc65e2ce26.tar.gz
feat: restyle roulette presentation
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino.go84
-rw-r--r--internal/game/casino_test.go42
-rw-r--r--internal/game/casino_ux_test.go2
-rw-r--r--internal/game/render_roulette.go53
-rw-r--r--internal/game/render_roulette_test.go49
-rw-r--r--internal/game/render_roulette_tracker.go2
-rw-r--r--internal/game/render_roulette_tracker_test.go5
7 files changed, 223 insertions, 14 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 9282653..08695e7 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -563,7 +563,7 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
// 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))
+ message := g.casinoProse(sess, category, g.casinoTimeText(event.Message))
if details {
if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" {
message += "\n" + rendered
@@ -592,7 +592,10 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
}
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 != "" {
+ rendered := g.renderCasinoSnapshot(sess, event.Snapshot)
+ if header, reminder, ok := g.casinoBoardReminder(sess, category, event.Menu, event.Snapshot, g.casinoTimeText(text)); ok {
+ privateMessage = composeTableThenReminder(header, rendered, reminder)
+ } else if rendered != "" {
privateMessage += "\n" + rendered
}
if event.ShowBalance {
@@ -605,13 +608,66 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
}
}
+// casinoBoardReminder splits a roulette menu so its wager instructions can be
+// printed under the betting board, which is the table the reminder refers to.
+// Every other game, and compact displays, keep their menu in one block.
+func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *casino.MenuView, snapshot casino.Snapshot, message string) (header, reminder string, ok bool) {
+ if category != "casino_menu" || menu == nil || sess.Player == nil {
+ return "", "", false
+ }
+ s, isRoulette := snapshot.(*casino.RouletteSnapshot)
+ if !isRoulette || s.Phase == "playing" || s.Phase == "result" {
+ return "", "", false
+ }
+ if sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
+ // The compact reminder is already a short block; leave it intact.
+ return "", "", false
+ }
+ balance := ""
+ if menu.ShowBalance {
+ balance = g.casinoBalance(sess)
+ }
+ heading := g.casinoTimeText(menu.Heading)
+ if heading == "" {
+ heading = message
+ }
+ commands := append([]casino.MenuCommand(nil), menu.Commands...)
+ for i := range commands {
+ if commands[i].Cost > casinoAffordable(sess.Player) {
+ commands[i].Desc += " [unavailable: insufficient funds]"
+ }
+ }
+ return g.casinoMenuHeader(sess, balance, heading), g.casinoMenuCommands(sess, commands), true
+}
+
+// composeTableThenReminder joins a menu header, the board graphic, and the
+// wager reminder. The board snapshot already leads with a blank line, so only
+// the reminder needs an extra gap before it.
+func composeTableThenReminder(header, table, reminder string) string {
+ out := header
+ if table != "" {
+ out += "\n" + table
+ }
+ if reminder != "" {
+ out += "\n\n" + reminder
+ }
+ return out
+}
+
// renderCasinoMessage colorizes a casino message unless it is an
// already-composed structured menu.
func (g *Game) renderCasinoMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string {
if category == "casino_menu" && menu != nil {
return message
}
- return g.colorize(sess, category, message)
+ return g.casinoProse(sess, category, message)
+}
+
+// casinoProse colorizes casino event text, expanding inline color tags such as
+// {casino_result_win}WIN{/} against the session's palette. The event's color
+// category is the default for untagged text.
+func (g *Game) casinoProse(sess *net.Session, category, text string) string {
+ return g.expandTagsDefault(sess, g.resolveColor(sess, category), text)
}
func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string {
@@ -701,6 +757,22 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot)
// optional balance line, a heading, and an aligned command list with each
// command in the "command" color and its description in plain text.
func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string, commands []casino.MenuCommand) string {
+ head := g.casinoMenuHeader(sess, balance, heading)
+ body := g.casinoMenuCommands(sess, commands)
+ switch {
+ case head == "":
+ return body
+ case body == "":
+ return head
+ default:
+ return head + "\n\n" + body
+ }
+}
+
+// casinoMenuHeader formats the balance and heading that introduce a casino
+// menu. It is split from the command list so the roulette board can be drawn
+// between them.
+func (g *Game) casinoMenuHeader(sess *net.Session, balance, heading string) string {
var sb strings.Builder
if balance != "" {
sb.WriteString(balance)
@@ -710,12 +782,18 @@ func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string,
sb.WriteString(g.colorize(sess, "casino_action", heading))
sb.WriteString("\n\n")
}
+ return strings.TrimRight(sb.String(), "\n")
+}
+
+// casinoMenuCommands formats an aligned, colored command list.
+func (g *Game) casinoMenuCommands(sess *net.Session, commands []casino.MenuCommand) string {
width := 0
for _, c := range commands {
if n := utf8.RuneCountInString(c.Command); n > width {
width = n
}
}
+ var sb strings.Builder
for _, c := range commands {
padded := c.Command + strings.Repeat(" ", width-utf8.RuneCountInString(c.Command))
category := "command"
diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go
index 3a1fc41..2f72d07 100644
--- a/internal/game/casino_test.go
+++ b/internal/game/casino_test.go
@@ -246,3 +246,45 @@ func TestRenderBlackjackSnapshotUsesCompactASCIIArt(t *testing.T) {
t.Fatalf("light card edges flipped: top=%q bottom=%q", plainLines[1], plainLines[7])
}
}
+
+// Casino settlements carry inline color tags for the outcome and each bet.
+// The presentation layer must expand them per recipient (white prose, green
+// win, red loss) rather than leaking the raw tags.
+func TestCasinoProseExpandsSettlementTags(t *testing.T) {
+ g := &Game{}
+ p := player.New("alice")
+ p.Options = map[string]any{"color": "xterm256"}
+ sess := &net.Session{Player: p}
+ got := g.casinoProse(sess, "casino_result_loss",
+ "{casino_result_loss}LOSS{/} {casino_payout}-1 credit{/}\n {casino_action}red:{/} {casino_result_loss}-1 credit{/}")
+ if strings.Contains(got, "{") {
+ t.Fatalf("raw tag leaked into rendered text: %q", got)
+ }
+ for _, want := range []string{"LOSS", "-1 credit", "red:"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("missing %q in %q", want, got)
+ }
+ }
+ // White label, red loss, and the payout color on the headline total.
+ if !strings.Contains(got, color.FgCode("xterm256", 255)) {
+ t.Fatalf("bet label is not white: %q", got)
+ }
+ if !strings.Contains(got, color.FgCode("xterm256", 196)) {
+ t.Fatalf("loss is not bold red: %q", got)
+ }
+ if !strings.Contains(got, color.StyleCode("bold")) {
+ t.Fatalf("loss/amount is not bold: %q", got)
+ }
+}
+
+// The landing pocket is the only bolded part of the spin announcement.
+func TestCasinoProseBoldsLandingPocket(t *testing.T) {
+ g := &Game{}
+ p := player.New("alice")
+ p.Options = map[string]any{"color": "xterm256"}
+ sess := &net.Session{Player: p}
+ got := g.casinoProse(sess, "casino_action", "The ball lands in {casino_emphasis}20{/}!")
+ if !strings.Contains(got, color.StyleCode("bold")+color.FgCode("xterm256", 255)+"20") {
+ t.Fatalf("landing pocket is not bold white: %q", got)
+ }
+}
diff --git a/internal/game/casino_ux_test.go b/internal/game/casino_ux_test.go
index 046d7d3..0d5ad63 100644
--- a/internal/game/casino_ux_test.go
+++ b/internal/game/casino_ux_test.go
@@ -55,7 +55,7 @@ func TestPlayAliasesJoinGames(t *testing.T) {
if !ok || table == nil || string(table.Config.Game) != game {
t.Fatalf("play %s: participation=%v game=%q", input, ok, game)
}
- if !strings.Contains(stripANSI(string(conn.data)), "Next hand") {
+ if !strings.Contains(stripANSI(string(conn.data)), "Limits between") {
t.Fatalf("play %s: missing betting menu, got %q", input, stripANSI(string(conn.data)))
}
}
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
index ce541c1..d69b5bf 100644
--- a/internal/game/render_roulette.go
+++ b/internal/game/render_roulette.go
@@ -22,7 +22,9 @@ func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode boo
case "result":
return "\n" + renderRouletteResult(s.Pockets, mode, unicode, size) + "\n"
default:
- return renderRouletteTracker(s, mode, unicode) + "\n" + renderRouletteLayout(s.DoubleZero, s.Racetrack, mode, unicode)
+ // A blank line above the tracker separates it from the prose or menu
+ // that precedes the board.
+ return "\n" + renderRouletteTracker(s, mode, unicode) + "\n" + renderRouletteLayout(s.DoubleZero, s.Racetrack, mode, unicode)
}
}
@@ -174,6 +176,18 @@ func renderRouletteRacetrack(mode string, unicode bool) string {
ink(58, 5, b.v)
ink(58, 6, bl+tr)
ink(59, 7, bl+tr)
+ // Labels are centered on the middle row; a label too wide to leave a blank
+ // cell against its dividers is wrapped onto the rows above and below it.
+ // Only the glyphs are written: the centering padding must not be painted
+ // over the step and curve dividers that share these rows.
+ labelX := func(left, right int, text string) int {
+ width := right - left - 1
+ pad := max(0, width-color.VisibleLen(text))
+ return left + 1 + pad/2
+ }
+ inkCenter := func(y, left, right int, text string) {
+ ink(labelX(left, right, text), y, text)
+ }
for _, label := range []struct {
left, right int
text string
@@ -181,7 +195,17 @@ func renderRouletteRacetrack(mode string, unicode bool) string {
{4, 22, "Tiers du Cylindre"}, {22, 40, "Orphelins"},
{40, 58, "Voisins du Zero"}, {58, 68, "Jeu Zero"},
} {
- ink(label.left+1, 5, rouletteCenter(label.text, label.right-label.left-1))
+ lines := rouletteWrapLabel(label.text, label.right-label.left-3)
+ if len(lines) == 1 {
+ inkCenter(5, label.left, label.right, lines[0])
+ continue
+ }
+ // Wrapped lines share the first line's left edge so the two words read
+ // as one label instead of drifting apart under independent centering.
+ x := labelX(label.left, label.right, lines[0])
+ for i, line := range lines {
+ ink(x, 4+2*i, line)
+ }
}
lines := make([]string, height)
for y, row := range grid {
@@ -190,6 +214,31 @@ func renderRouletteRacetrack(mode string, unicode bool) string {
return strings.Join(lines, "\n")
}
+// rouletteWrapLabel breaks a call-bet label on spaces so each line leaves a
+// blank cell against the track dividers. The caller knows the labels are short
+// enough to wrap onto at most two lines; a single over-long word is left whole
+// rather than hyphenated.
+func rouletteWrapLabel(text string, limit int) []string {
+ words := strings.Fields(text)
+ var lines []string
+ cur := ""
+ for _, word := range words {
+ switch {
+ case cur == "":
+ cur = word
+ case len(cur)+1+len(word) <= limit:
+ cur += " " + word
+ default:
+ lines = append(lines, cur)
+ cur = word
+ }
+ }
+ if cur != "" {
+ lines = append(lines, cur)
+ }
+ return lines
+}
+
// 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
diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go
index 3577ec9..fcfd9c7 100644
--- a/internal/game/render_roulette_test.go
+++ b/internal/game/render_roulette_test.go
@@ -238,7 +238,7 @@ func TestRouletteRacetrackPanel(t *testing.T) {
for _, unicode := range []bool{false, true} {
art := renderRouletteLayout(false, true, mode, unicode)
plain := plainRoulette(art)
- for _, want := range []string{"Tiers du Cylindre", "Orphelins", "Voisins du Zero", "Zero"} {
+ for _, want := range []string{"Tiers du", "Cylindre", "Orphelins", "Voisins du Zero", "Jeu"} {
if !strings.Contains(plain, want) {
t.Fatalf("mode=%s unicode=%v: missing %q:\n%s", mode, unicode, want, plain)
}
@@ -494,14 +494,22 @@ func TestRouletteRacetrackSections(t *testing.T) {
}
}
}
+ // Labels wrap onto the rows around the middle so no glyph touches a
+ // divider; every field keeps a blank cell on both sides of its text.
for _, label := range []struct {
- left, right int
- text string
+ row, left, right int
+ text string
}{
- {4, 22, "Tiers du Cylindre"}, {22, 40, "Orphelins"}, {40, 58, "Voisins du Zero"}, {58, 68, "Jeu Zero"},
+ {4, 5, 20, "Tiers du"}, {6, 5, 22, "Cylindre"},
+ {5, 23, 40, "Orphelins"}, {5, 41, 58, "Voisins du Zero"},
+ {4, 60, 68, "Jeu"}, {6, 60, 68, "Zero"},
} {
- if strings.TrimSpace(segment(5, label.left+1, label.right)) != label.text {
- t.Errorf("misplaced %s label", label.text)
+ field := segment(label.row, label.left, label.right)
+ if strings.TrimSpace(field) != label.text {
+ t.Errorf("misplaced %q label on row %d: %q", label.text, label.row, field)
+ }
+ if !strings.HasPrefix(field, " ") || !strings.HasSuffix(field, " ") {
+ t.Errorf("%q label touches a divider on row %d: %q", label.text, label.row, field)
}
}
// Every step joins orthogonally; no font-dependent diagonal gaps.
@@ -580,3 +588,32 @@ func TestRouletteWheelBlackAndSpacing(t *testing.T) {
}
}
}
+
+// The roulette menu draws the board before the wager reminder so the
+// instructions follow the table they describe, and the tracker gets a blank
+// line above it instead of butting into the heading.
+func TestRouletteMenuPutsReminderAfterBoard(t *testing.T) {
+ g := &Game{Casino: casino.NewManager(1), Hub: net.NewHub(), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}}
+ p := player.New("alice")
+ p.RoomID, p.Credits = 1, 1000
+ p.Options = map[string]any{"color": "none", "unicode": true}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: p, Conn: conn}
+ g.loggedInChars = map[string]*net.Session{"alice": sess}
+ g.Hub.EnterRoom(sess, 1)
+ cfg := casino.TableConfig{ID: "r", Game: casino.GameRoulette, Roulette: casino.RouletteConfig{Racetrack: true}}
+ g.emitCasinoEvents(g.Casino.Join(1, cfg, p.Name))
+ out := strings.ReplaceAll(string(conn.data), "\r\n", "\n")
+ tracker := strings.Index(out, "History: last 0 spins")
+ board := strings.Index(out, "R = red, B = black, G = green")
+ reminder := strings.Index(out, "bet <amount> on <target>")
+ if tracker < 0 || board < 0 || reminder < 0 {
+ t.Fatalf("missing tracker, board, or reminder:\n%s", out)
+ }
+ if !(tracker < board && board < reminder) {
+ t.Fatalf("expected tracker before board before reminder, got %d/%d/%d:\n%s", tracker, board, reminder, out)
+ }
+ if !strings.Contains(out, "\n\nā”Œā”€ History: last 0 spins") {
+ t.Fatalf("tracker is not preceded by a blank line:\n%s", out)
+ }
+}
diff --git a/internal/game/render_roulette_tracker.go b/internal/game/render_roulette_tracker.go
index c432924..3602224 100644
--- a/internal/game/render_roulette_tracker.go
+++ b/internal/game/render_roulette_tracker.go
@@ -39,7 +39,7 @@ func renderRouletteTracker(s *casino.RouletteSnapshot, mode string, unicode bool
}
recentText := "-- awaiting the first spin --"
if len(recent) > 0 {
- recentText = strings.Join(recent, " ") + ink(" newest first")
+ recentText = strings.Join(recent, " ")
}
// Numeric tie-breaking keeps the display stable. Cold includes pockets
// not yet hit, but we never label an unhit pocket as hot.
diff --git a/internal/game/render_roulette_tracker_test.go b/internal/game/render_roulette_tracker_test.go
index 3bae930..a629b57 100644
--- a/internal/game/render_roulette_tracker_test.go
+++ b/internal/game/render_roulette_tracker_test.go
@@ -46,6 +46,9 @@ func TestRouletteTrackerFormats(t *testing.T) {
if strings.Contains(plain, "History, not odds") {
t.Fatal("tracker should not carry the removed caption")
}
+ if strings.Contains(plain, "newest first") {
+ t.Fatal("tracker should not label the recent order")
+ }
if len(history) == 0 {
for _, want := range []string{"last 0 spins", "awaiting the first spin", "Hot: --", "Cold: --", "Red --", "Black --"} {
if !strings.Contains(plain, want) {
@@ -89,7 +92,7 @@ func TestRouletteTrackerAboveBettingBoardsOnly(t *testing.T) {
s := &casino.RouletteSnapshot{Racetrack: racetrack, Phase: "betting", History: []string{"17"}, Wheel: casino.WheelOrder(false)}
art := renderRouletteSnapshot(s, "none", true, 7)
tracker := renderRouletteTracker(s, "none", true)
- if !strings.HasPrefix(art, tracker+"\n") {
+ if !strings.HasPrefix(art, "\n"+tracker+"\n") {
t.Fatal("tracker not above board")
}
for _, phase := range []string{"playing", "result"} {