diff options
| author | historia <[not public]> | 2026-09-29 19:48:32 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-29 19:48:32 -0400 |
| commit | ffc7b582e9c14fdc2e579b9239b5ff7ad9a0d90a (patch) | |
| tree | e286210755094b41025f2947e412e05e59ec2ac9 /internal/game | |
| parent | 624626b9a10ae47cf7d5f6e91765570ed759bd74 (diff) | |
| download | thehouseoficarus-ffc7b582e9c14fdc2e579b9239b5ff7ad9a0d90a.tar.gz | |
feat: roulette history tracker
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/render_roulette.go | 39 | ||||
| -rw-r--r-- | internal/game/render_roulette_test.go | 67 | ||||
| -rw-r--r-- | internal/game/render_roulette_tracker.go | 98 | ||||
| -rw-r--r-- | internal/game/render_roulette_tracker_test.go | 120 | ||||
| -rw-r--r-- | internal/game/roulette_test.go | 20 |
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) + } +} |
