diff options
| author | historia <[not public]> | 2026-09-30 03:55:37 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-30 03:55:37 -0400 |
| commit | 735428f0667f67c12fdf3caca872d7dc65e2ce26 (patch) | |
| tree | 92c9931f223bfd50ced4f1954130d879640a435b /internal/game | |
| parent | ffc7b582e9c14fdc2e579b9239b5ff7ad9a0d90a (diff) | |
| download | thehouseoficarus-735428f0667f67c12fdf3caca872d7dc65e2ce26.tar.gz | |
feat: restyle roulette presentation
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/casino.go | 84 | ||||
| -rw-r--r-- | internal/game/casino_test.go | 42 | ||||
| -rw-r--r-- | internal/game/casino_ux_test.go | 2 | ||||
| -rw-r--r-- | internal/game/render_roulette.go | 53 | ||||
| -rw-r--r-- | internal/game/render_roulette_test.go | 49 | ||||
| -rw-r--r-- | internal/game/render_roulette_tracker.go | 2 | ||||
| -rw-r--r-- | internal/game/render_roulette_tracker_test.go | 5 |
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"} { |
