aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-29 19:48:32 -0400
committerhistoria <[not public]>2026-09-29 19:48:32 -0400
commitffc7b582e9c14fdc2e579b9239b5ff7ad9a0d90a (patch)
treee286210755094b41025f2947e412e05e59ec2ac9 /internal/game
parent624626b9a10ae47cf7d5f6e91765570ed759bd74 (diff)
downloadthehouseoficarus-ffc7b582e9c14fdc2e579b9239b5ff7ad9a0d90a.tar.gz
feat: roulette history tracker
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/render_roulette.go39
-rw-r--r--internal/game/render_roulette_test.go67
-rw-r--r--internal/game/render_roulette_tracker.go98
-rw-r--r--internal/game/render_roulette_tracker_test.go120
-rw-r--r--internal/game/roulette_test.go20
5 files changed, 321 insertions, 23 deletions
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
index f6c6bb7..ce541c1 100644
--- a/internal/game/render_roulette.go
+++ b/internal/game/render_roulette.go
@@ -17,11 +17,12 @@ import (
func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode bool, size int) string {
switch s.Phase {
case "playing":
- return renderRouletteWheel(s.Wheel, mode, unicode)
+ // A blank line on either side keeps the wheel from crowding the prose.
+ return "\n" + renderRouletteWheel(s.Wheel, mode, unicode) + "\n"
case "result":
- return renderRouletteResult(s.Pockets, mode, unicode, size)
+ return "\n" + renderRouletteResult(s.Pockets, mode, unicode, size) + "\n"
default:
- return renderRouletteLayout(s.DoubleZero, s.Racetrack, mode, unicode)
+ return renderRouletteTracker(s, mode, unicode) + "\n" + renderRouletteLayout(s.DoubleZero, s.Racetrack, mode, unicode)
}
}
@@ -35,6 +36,11 @@ func roulettePaint(mode, text string, bg int) string {
return color.Render(mode, color.ColorSpec{Fg: 15, Bg: bg, Bold: true}, text)
}
+// rouletteWheelBlack is the spinning wheel's black pocket color. Plain black
+// background disappears on dark terminals, so the wheel uses a very dark blue
+// purely for visibility; the betting board and result cards keep true black.
+const rouletteWheelBlack = 17
+
func roulettePocketColor(pocket string) (int, string) {
if pocket == "0" || pocket == "00" {
return 2, "GREEN"
@@ -418,7 +424,7 @@ func renderRouletteWheel(wheel []string, mode string, unicode bool) string {
p := pocketIndex[i]
bg := 1
if p%2 == 1 {
- bg = 0
+ bg = rouletteWheelBlack
}
if p == greenPocket || (double && p == opposite) {
bg = 2
@@ -486,12 +492,11 @@ func renderRouletteRow(row []rouletteCell, mode string) string {
return sb.String()
}
-// renderRouletteResult draws the winning pocket and its two physical
-// neighbours at playing-card size. The pocket number sits on the top row, a
-// rule separates it from the ball (one row above centre), and the color is
-// spelled out on the bottom row only when color is off. There is no
-// surrounding prose.
-func renderRouletteResult(pockets [3]string, mode string, unicode bool, size int) string {
+// renderRouletteResult draws the winning pocket and two physical neighbours on
+// each side at playing-card size. The pocket number sits on the top row, a rule
+// separates it from the ball (one row above centre), and the color is spelled
+// out on the bottom row only when color is off. There is no surrounding prose.
+func renderRouletteResult(pockets [5]string, mode string, unicode bool, size int) string {
size = cardRenderSize(unicode, size)
if size < 5 {
size = 5
@@ -527,7 +532,7 @@ func renderRouletteResult(pockets [3]string, mode string, unicode bool, size int
if row == numberRow {
content = pocket
}
- if i == 1 && row == ballRow {
+ if i == len(pockets)/2 && row == ballRow {
content = ball
}
if plain && row == colorRow {
@@ -546,6 +551,18 @@ func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) stri
rules := g.colorize(sess, "casino_action", cfg.RulesText())
if cfg.Game == casino.GameRoulette {
snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Racetrack: cfg.Roulette.Racetrack, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)}
+ // Rules views draw a betting board too; use the live table's history
+ // rather than presenting an empty tracker after completed spins.
+ if g.Casino != nil && sess.Player != nil {
+ if table := g.Casino.Table(sess.Player.RoomID, cfg.ID); table != nil {
+ for _, event := range table.Status(sess.Player.Name) {
+ if view, ok := event.Snapshot.(*casino.RouletteSnapshot); ok {
+ snap.History = view.History
+ break
+ }
+ }
+ }
+ }
rules += "\n" + g.renderCasinoSnapshot(sess, snap) + "\n" + g.rouletteReminder(sess, cfg)
}
return rules
diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go
index 8d9c141..3577ec9 100644
--- a/internal/game/render_roulette_test.go
+++ b/internal/game/render_roulette_test.go
@@ -27,7 +27,7 @@ func TestRouletteArtModes(t *testing.T) {
s := &casino.RouletteSnapshot{DoubleZero: double, Wheel: wheelOrder}
for _, phase := range []string{"betting", "playing", "result"} {
s.Phase = phase
- s.Pockets = [3]string{"26", "0", "32"}
+ s.Pockets = [5]string{"25", "26", "0", "32", "15"}
art := renderRouletteSnapshot(s, mode, unicode, 7)
plain := plainRoulette(art)
if mode == "xterm256" && unicode && double {
@@ -50,15 +50,19 @@ func TestRouletteArtModes(t *testing.T) {
}
}
if mode != "none" {
- for _, bg := range []int{0, 1, 2} {
+ pocketBgs := []int{0, 1, 2}
+ if phase == "playing" {
+ pocketBgs = []int{rouletteWheelBlack, 1, 2}
+ }
+ for _, bg := range pocketBgs {
if !strings.Contains(art, color.BgCode(mode, bg)) {
- t.Fatalf("missing pocket background %d", bg)
+ t.Fatalf("phase=%s missing pocket background %d", phase, 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) {
+ if !strings.HasSuffix(strings.TrimRight(art, "\n"), color.Reset) {
t.Fatal("art leaves terminal colored")
}
}
@@ -117,14 +121,14 @@ func TestRouletteArtModes(t *testing.T) {
}
}
} else {
- for _, bg := range []int{0, 1, 2} {
+ for _, bg := range []int{rouletteWheelBlack, 1, 2} {
if !strings.Contains(wheelArt, color.BgCode(mode, bg)) {
t.Fatalf("wheel missing pocket background %d", bg)
}
}
}
- result := plainRoulette(renderRouletteResult([3]string{"1", "00", "27"}, mode, unicode, 7))
+ result := plainRoulette(renderRouletteResult([5]string{"36", "1", "00", "27", "13"}, 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)
@@ -267,7 +271,7 @@ func TestRouletteRacetrackPanel(t *testing.T) {
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))
+ art := plainRoulette(renderRouletteResult([5]string{"36", "1", "00", "27", "13"}, "xterm256", unicode, size))
lines := strings.Split(art, "\n")
height := max(5, cardRenderSize(unicode, size))
width := height + 2
@@ -275,13 +279,18 @@ func TestRouletteResultSizesAndNeighbors(t *testing.T) {
t.Fatalf("size=%d, height=%d want %d", size, len(lines), height)
}
for _, line := range lines {
- if color.VisibleLen(line) != 3*width+2 {
+ if color.VisibleLen(line) != 5*width+4 {
t.Fatalf("unequal pocket widths: %q", line)
}
}
- center := []rune(lines[1])
- 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)
+ top := plainRoulette(strings.Split(art, "\n")[1])
+ last := -1
+ for _, pocket := range []string{"36", "1", "00", "27", "13"} {
+ at := strings.Index(top, pocket)
+ if at < 0 || at < last {
+ t.Fatalf("wrong pocket order for %s: %q", pocket, top)
+ }
+ last = at
}
ball := "o"
if unicode {
@@ -299,7 +308,7 @@ func TestRouletteResultSizesAndNeighbors(t *testing.T) {
// bottom row only when color is off. cardRenderSize caps non-Unicode cards at
// five rows, so the full nine-row shape is exercised through Unicode.
func TestRouletteResultLayout(t *testing.T) {
- pockets := [3]string{"1", "10", "27"} // RED, BLACK, RED
+ pockets := [5]string{"1", "10", "27", "2", "3"} // RED, BLACK, RED, BLACK, RED
for _, tc := range []struct {
unicode bool
size int
@@ -537,3 +546,37 @@ func TestRouletteRacetrackSections(t *testing.T) {
}
}
}
+
+// TestRouletteWheelBlackAndSpacing locks in the visual tweaks: the spinning
+// wheel uses a visible dark blue for its black pockets while the betting board
+// keeps true black, and the wheel and result graphics get blank line framing.
+func TestRouletteWheelBlackAndSpacing(t *testing.T) {
+ wheelArt := renderRouletteWheel(casino.WheelOrder(false), "xterm256", true)
+ if !strings.Contains(wheelArt, color.BgCode("xterm256", rouletteWheelBlack)) {
+ t.Fatal("spinning wheel lost its dark blue black pockets")
+ }
+ if strings.Contains(wheelArt, color.BgCode("xterm256", 0)) {
+ t.Fatal("spinning wheel should not use pure black")
+ }
+ board := renderRouletteLayout(false, false, "xterm256", true)
+ if !strings.Contains(board, color.BgCode("xterm256", 0)) {
+ t.Fatal("betting board should keep true black pockets")
+ }
+
+ for _, tc := range []struct {
+ phase string
+ want string
+ }{
+ {"playing", renderRouletteWheel(casino.WheelOrder(false), "none", true)},
+ {"result", renderRouletteResult([5]string{"25", "26", "0", "32", "15"}, "none", true, 7)},
+ } {
+ s := &casino.RouletteSnapshot{Wheel: casino.WheelOrder(false), Phase: tc.phase, Pockets: [5]string{"25", "26", "0", "32", "15"}}
+ art := renderRouletteSnapshot(s, "none", true, 7)
+ if !strings.HasPrefix(art, "\n") || !strings.HasSuffix(art, "\n") {
+ t.Fatalf("phase=%s: graphic is not blank-line framed: %q", tc.phase, art)
+ }
+ if !strings.Contains(art, tc.want) {
+ t.Fatalf("phase=%s: framed art lost the graphic", tc.phase)
+ }
+ }
+}
diff --git a/internal/game/render_roulette_tracker.go b/internal/game/render_roulette_tracker.go
new file mode 100644
index 0000000..c432924
--- /dev/null
+++ b/internal/game/render_roulette_tracker.go
@@ -0,0 +1,98 @@
+package game
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+// The tracker is descriptive, not predictive. All statistics share the same
+// bounded history window; zero pockets count in the percentage denominator.
+func renderRouletteTracker(s *casino.RouletteSnapshot, mode string, unicode bool) string {
+ const width = 72 // same 74-column footprint as the betting board
+ b := rouletteBorders(unicode)
+ ink := func(text string) string { return rouletteInk(mode, text) }
+ pocket := func(n string) string {
+ bg, name := roulettePocketColor(n)
+ if mode == "none" || mode == "" {
+ n += name[:1]
+ }
+ return roulettePaint(mode, rouletteCenter(n, 4), bg)
+ }
+ accent := func(text string, fg int) string {
+ return color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, text)
+ }
+ counts := map[string]int{}
+ colors := [3]int{} // black, red, green
+ for _, n := range s.History {
+ counts[n]++
+ bg, _ := roulettePocketColor(n)
+ colors[bg]++
+ }
+ var recent []string
+ for i := len(s.History) - 1; i >= 0 && len(recent) < 8; i-- {
+ recent = append(recent, pocket(s.History[i]))
+ }
+ recentText := "-- awaiting the first spin --"
+ if len(recent) > 0 {
+ recentText = strings.Join(recent, " ") + ink(" newest first")
+ }
+ // Numeric tie-breaking keeps the display stable. Cold includes pockets
+ // not yet hit, but we never label an unhit pocket as hot.
+ numbers := []string{"0"}
+ if s.DoubleZero {
+ numbers = append(numbers, "00")
+ }
+ for n := 1; n <= 36; n++ {
+ numbers = append(numbers, strconv.Itoa(n))
+ }
+ ranked := func(hot bool) string {
+ if len(s.History) == 0 {
+ return ink("--")
+ }
+ order := append([]string(nil), numbers...)
+ sort.SliceStable(order, func(i, j int) bool {
+ if hot {
+ return counts[order[i]] > counts[order[j]]
+ }
+ return counts[order[i]] < counts[order[j]]
+ })
+ var cells []string
+ for _, n := range order {
+ if len(cells) == 3 || hot && counts[n] == 0 {
+ break
+ }
+ cells = append(cells, pocket(n)+ink(fmt.Sprintf("(%d)", counts[n])))
+ }
+ return strings.Join(cells, " ")
+ }
+ percentage := func(label string, bg int) string {
+ value := "--"
+ if len(s.History) > 0 {
+ value = fmt.Sprintf("%.1f%%", 100*float64(colors[bg])/float64(len(s.History)))
+ }
+ return roulettePaint(mode, " "+label+" "+value+" ", bg)
+ }
+ hot := accent("Hot: ", 9) + ranked(true)
+ cold := accent("Cold: ", 14) + ranked(false)
+ ranking := hot + ink(strings.Repeat(" ", 34-color.VisibleLen(hot))+" | ") + cold
+ spinLabel := "spins"
+ if len(s.History) == 1 {
+ spinLabel = "spin"
+ }
+ title := fmt.Sprintf(" History: last %d %s ", len(s.History), spinLabel)
+ row := func(text string) string {
+ return ink(b.v+" ") + text + ink(strings.Repeat(" ", width-1-color.VisibleLen(text))+b.v)
+ }
+ return strings.Join([]string{
+ ink(b.tl+b.h) + accent(title, 14) + ink(strings.Repeat(b.h, width-1-len(title))+b.tr),
+ row(ink("Recent: ") + recentText),
+ row(ranking),
+ row(percentage("Red", 1) + ink(" | ") + percentage("Black", 0) + ink(" | ") + percentage("Zero", 2)),
+ ink(b.bl + strings.Repeat(b.h, width) + b.br),
+ }, "\n")
+}
diff --git a/internal/game/render_roulette_tracker_test.go b/internal/game/render_roulette_tracker_test.go
new file mode 100644
index 0000000..3bae930
--- /dev/null
+++ b/internal/game/render_roulette_tracker_test.go
@@ -0,0 +1,120 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func TestRouletteTrackerFormats(t *testing.T) {
+ for _, mode := range []string{"none", "ansi", "xterm256"} {
+ for _, unicode := range []bool{false, true} {
+ for _, history := range [][]string{nil, {"0"}, {"1", "2", "00", "1", "36", "00", "36", "36", "2", "0"}} {
+ s := &casino.RouletteSnapshot{DoubleZero: true, History: history}
+ art := renderRouletteTracker(s, mode, unicode)
+ rows := strings.Split(art, "\n")
+ if len(rows) != 5 {
+ t.Fatalf("tracker has %d rows", len(rows))
+ }
+ for _, row := range rows {
+ if color.VisibleLen(row) != 74 {
+ t.Errorf("tracker row width %d: %q", color.VisibleLen(row), plainRoulette(row))
+ }
+ if !unicode {
+ for _, r := range plainRoulette(row) {
+ if r > 127 {
+ t.Errorf("non-ASCII rune %q", r)
+ }
+ }
+ }
+ }
+ if mode == "none" && strings.Contains(art, "\x1b") {
+ t.Fatal("color disabled but ANSI present")
+ }
+ if mode != "none" {
+ for _, bg := range []int{0, 1, 2} {
+ if !strings.Contains(art, color.BgCode(mode, bg)) {
+ t.Errorf("missing color %d", bg)
+ }
+ }
+ }
+ plain := plainRoulette(art)
+ if strings.Contains(plain, "History, not odds") {
+ t.Fatal("tracker should not carry the removed caption")
+ }
+ if len(history) == 0 {
+ for _, want := range []string{"last 0 spins", "awaiting the first spin", "Hot: --", "Cold: --", "Red --", "Black --"} {
+ if !strings.Contains(plain, want) {
+ t.Errorf("missing %q", want)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestRouletteTrackerStatistics(t *testing.T) {
+ s := &casino.RouletteSnapshot{DoubleZero: true, History: []string{"1", "2", "00", "1", "36", "00", "36", "36", "2", "0"}}
+ plain := plainRoulette(renderRouletteTracker(s, "none", true))
+ for _, want := range []string{"last 10 spins", "Red 50.0%", "Black 20.0%", "Zero 30.0%", "Hot: 36R (3) 00G (2) 1R (2)", "Cold: 3R (0) 4B (0) 5R (0)"} {
+ if !strings.Contains(strings.Join(strings.Fields(plain), " "), want) {
+ t.Errorf("missing %q:\n%s", want, plain)
+ }
+ }
+ // Only the newest eight, newest on the left, and 00 stays distinct from 0.
+ recent := strings.Split(plain, "\n")[1]
+ hits := racetrackNumbers(recent)
+ if got := strings.Join(hits, ","); got != "0,2,36,36,00,36,1,00" {
+ t.Errorf("recent hits: %s", got)
+ }
+ // Unhit pockets aren't called hot. Cold tie order includes 00 only on
+ // American wheels; it is not silently merged with zero.
+ s.History = []string{"0"}
+ if line := strings.Split(renderRouletteTracker(s, "none", false), "\n")[2]; !strings.Contains(strings.Join(strings.Fields(line), " "), "Hot: 0G (1) | Cold: 00G (0)") {
+ t.Errorf("zero ranking: %s", line)
+ }
+ s.DoubleZero = false
+ if strings.Contains(renderRouletteTracker(s, "none", false), "00G") {
+ t.Fatal("European tracker contains 00")
+ }
+}
+
+func TestRouletteTrackerAboveBettingBoardsOnly(t *testing.T) {
+ for _, racetrack := range []bool{false, true} {
+ 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") {
+ t.Fatal("tracker not above board")
+ }
+ for _, phase := range []string{"playing", "result"} {
+ s.Phase = phase
+ s.Pockets = [5]string{"25", "17", "34", "6", "27"}
+ if strings.Contains(renderRouletteSnapshot(s, "none", true, 7), "History:") {
+ t.Fatalf("tracker appeared during %s", phase)
+ }
+ }
+ }
+}
+
+func TestRouletteRulesUseLiveHistory(t *testing.T) {
+ g := &Game{Casino: casino.NewManager(7)}
+ p := player.New("alice")
+ p.RoomID, p.Credits = 1, 100
+ p.Options = map[string]any{"color": "none", "unicode": true}
+ sess := &net.Session{Player: p}
+ 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, "red", 1, casinoWallet{g: g, p: p, saveCharacter: func(*player.Player) error { return nil }})
+ g.Casino.Tick()
+ g.Casino.Tick()
+ art := g.renderCasinoRules(sess, cfg)
+ if !strings.Contains(art, "History: last 1 spin") || strings.Contains(art, "awaiting the first spin") {
+ t.Fatalf("rules did not show live history:\n%s", art)
+ }
+}
diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go
index e581435..7b83807 100644
--- a/internal/game/roulette_test.go
+++ b/internal/game/roulette_test.go
@@ -173,3 +173,23 @@ func TestCasinoTestPortalAuthoredContract(t *testing.T) {
}
}
}
+
+// A wager must not re-send the betting board; the board arrives on join,
+// after settlement via the next-spin menu, or on an explicit "table".
+func TestRouletteBetDoesNotResendBoard(t *testing.T) {
+ g, sess, conn := uxCasinoGame(t)
+ cfg := casino.TableConfig{ID: "roulette", Game: casino.GameRoulette}
+ g.emitCasinoEvents(g.Casino.Join(1, cfg, sess.Player.Name))
+ if !strings.Contains(string(conn.data), "1to12") {
+ t.Fatalf("join did not draw the board: %q", conn.data)
+ }
+ conn.data = nil
+ g.executeBet(sess, []string{"10", "on", "red"}, "bet 10 on red")
+ text := string(conn.data)
+ if strings.Contains(text, "1to12") {
+ t.Fatalf("placing a wager redrew the board: %q", text)
+ }
+ if !strings.Contains(text, "You bet 10 on red") {
+ t.Fatalf("missing wager confirmation: %q", text)
+ }
+}