aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
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/casino
parent624626b9a10ae47cf7d5f6e91765570ed759bd74 (diff)
downloadthehouseoficarus-ffc7b582e9c14fdc2e579b9239b5ff7ad9a0d90a.tar.gz
feat: roulette history tracker
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/roulette.go30
-rw-r--r--internal/casino/roulette_history_test.go79
-rw-r--r--internal/casino/roulette_test.go25
3 files changed, 128 insertions, 6 deletions
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
index 109306a..44c5f30 100644
--- a/internal/casino/roulette.go
+++ b/internal/casino/roulette.go
@@ -467,11 +467,15 @@ type roulettePlayer struct {
func (p *roulettePlayer) participantPtr() *Participant { return &p.Participant }
+// Keep a small, table-local window of completed spins (oldest first).
+const rouletteHistoryLimit = 50
+
type rouletteTable struct {
tableCore[*roulettePlayer]
rng *rand.Rand
spinRemaining int
lastResult *RouletteSnapshot
+ history []string
}
func newRouletteTable(room int, cfg TableConfig, rng *rand.Rand) *rouletteTable {
@@ -479,7 +483,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, Racetrack: t.Config.Roulette.Racetrack, Phase: string(t.phase), Wheel: rouletteWheel(t.Config.Roulette.DoubleZero)}
+ s := &RouletteSnapshot{DoubleZero: t.Config.Roulette.DoubleZero, Racetrack: t.Config.Roulette.Racetrack, Phase: string(t.phase), Wheel: rouletteWheel(t.Config.Roulette.DoubleZero), History: append([]string(nil), t.history...)}
for _, p := range t.players.ordered() {
s.Players = append(s.Players, p.Name)
}
@@ -555,12 +559,14 @@ func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event {
}
p.HasBet, p.Wager, p.Wallet, p.lastSpot, p.lastBet, p.SittingOut = true, wager, w, spot, amount, false
events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet %d on %s.", amount, spot))}
- // Always offer a real betting phase, even with only one player.
+ // Always offer a real betting phase, even with only one player. The
+ // board is not attached here: it is sent once per round by the menu event
+ // (join, settlement, or an explicit "table"), so placing a wager never
+ // redraws it.
if t.phase == phaseWaiting {
t.phase = phaseBetting
t.window.open(t.Config.BettingTicks)
e := t.event(fmt.Sprintf("Roulette betting is open for %d ticks.", t.Config.BettingTicks), true)
- e.Snapshot = t.view()
events = append(events, e)
}
return events
@@ -601,9 +607,20 @@ func (t *rouletteTable) settle() []Event {
wheel := rouletteWheel(t.Config.Roulette.DoubleZero)
index := t.rng.Intn(len(wheel))
pocket := wheel[index]
+ t.history = append(t.history, pocket)
+ if len(t.history) > rouletteHistoryLimit {
+ copy(t.history, t.history[len(t.history)-rouletteHistoryLimit:])
+ t.history = t.history[:rouletteHistoryLimit]
+ }
s := t.view()
s.Phase = "result"
- s.Pockets = [3]string{wheel[(index+len(wheel)-1)%len(wheel)], pocket, wheel[(index+1)%len(wheel)]}
+ // Show the winning pocket with two physical neighbours on each side so
+ // the result reads like a slice of the real wheel.
+ n := len(wheel)
+ s.Pockets = [5]string{
+ wheel[(index-2+n)%n], wheel[(index-1+n)%n], pocket,
+ wheel[(index+1)%n], wheel[(index+2)%n],
+ }
t.lastResult = s
e := t.event("The ball lands in "+pocket+"!", true)
e.Snapshot = s
@@ -728,12 +745,15 @@ type RouletteSnapshot struct {
// neighbours bets, so the renderer draws the racetrack panel.
Racetrack bool
Phase string
- Pockets [3]string // physical wheel neighbors, not numerical neighbors
+ Pockets [5]string // winning pocket and two physical wheel neighbours each side
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
+ // History is a detached copy of the last 50 completed spins, oldest first.
+ // It is table-local, survives round resets, and is not persisted on restart.
+ History []string
}
func (*RouletteSnapshot) snapshot() {}
diff --git a/internal/casino/roulette_history_test.go b/internal/casino/roulette_history_test.go
new file mode 100644
index 0000000..fbd2f65
--- /dev/null
+++ b/internal/casino/roulette_history_test.go
@@ -0,0 +1,79 @@
+package casino
+
+import (
+ "math/rand"
+ "reflect"
+ "testing"
+)
+
+func TestRouletteHistoryTracksCompletedSpinsOnly(t *testing.T) {
+ for _, double := range []bool{false, true} {
+ table := rouletteFixture()
+ table.Config.Roulette.DoubleZero = double
+ table.join("a")
+ wallet := &testWallet{chips: 1000}
+ rng := rand.New(rand.NewSource(7))
+ wheel := rouletteWheel(double)
+ var expected []string
+ var first *RouletteSnapshot
+ for i := 0; i < rouletteHistoryLimit+5; i++ {
+ table.bet("a", "red", 1, wallet)
+ before := table.view()
+ if !reflect.DeepEqual(before.History, expected) {
+ t.Fatalf("history changed on bet: %v", before.History)
+ }
+ // Two betting ticks, then two spinning ticks. No sample until the last.
+ for j := 0; j < 3; j++ {
+ table.tick()
+ if !reflect.DeepEqual(table.view().History, expected) {
+ t.Fatal("history revealed before settlement")
+ }
+ }
+ events := table.tick()
+ result := events[0].Snapshot.(*RouletteSnapshot)
+ expected = append(expected, wheel[rng.Intn(len(wheel))])
+ if len(expected) > rouletteHistoryLimit {
+ expected = expected[1:]
+ }
+ if !reflect.DeepEqual(result.History, expected) || result.Pockets[2] != expected[len(expected)-1] {
+ t.Fatalf("wrong result history: %v, want %v", result.History, expected)
+ }
+ if i == 0 {
+ first = result
+ }
+ if !reflect.DeepEqual(table.view().History, expected) {
+ t.Fatal("round reset lost history")
+ }
+ }
+ if len(first.History) != 1 || first.History[0] != first.Pockets[2] {
+ t.Fatal("later spins mutated an earlier snapshot")
+ }
+ snapshot := table.view()
+ snapshot.History[0] = "bad"
+ if !reflect.DeepEqual(table.view().History, expected) {
+ t.Fatal("snapshot aliases engine history")
+ }
+ table.refundAll()
+ table.tick()
+ if !reflect.DeepEqual(table.view().History, expected) {
+ t.Fatal("refund or idle tick changed history")
+ }
+ if len(rouletteFixture().view().History) != 0 {
+ t.Fatal("history leaked to another table")
+ }
+ }
+}
+
+func TestRouletteCancelledSpinDoesNotEnterHistory(t *testing.T) {
+ table := rouletteFixture()
+ table.join("a")
+ table.bet("a", "red", 1, &testWallet{chips: 100})
+ table.tick()
+ table.tick()
+ table.markDisconnected("a")
+ table.tick()
+ table.tick()
+ if len(table.view().History) != 0 {
+ t.Fatal("cancelled spin recorded a result")
+ }
+}
diff --git a/internal/casino/roulette_test.go b/internal/casino/roulette_test.go
index 0a43677..9a62236 100644
--- a/internal/casino/roulette_test.go
+++ b/internal/casino/roulette_test.go
@@ -307,7 +307,12 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) {
t.Fatalf("bad settlement: %+v", w)
}
s := events[0].Snapshot.(*RouletteSnapshot)
- if s.Pockets != [3]string{wheel[(index+len(wheel)-1)%len(wheel)], wheel[index], wheel[(index+1)%len(wheel)]} {
+ n := len(wheel)
+ wantPockets := [5]string{
+ wheel[(index-2+n)%n], wheel[(index-1+n)%n], wheel[index],
+ wheel[(index+1)%n], wheel[(index+2)%n],
+ }
+ if s.Pockets != wantPockets {
t.Fatal("incorrect wheel neighbors")
}
table.tick()
@@ -317,6 +322,24 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) {
}
}
}
+func TestRouletteBetDoesNotRedrawBoard(t *testing.T) {
+ table := rouletteFixture()
+ table.join("a")
+ events := table.bet("a", "red", 10, &testWallet{chips: 100})
+ for _, e := range events {
+ if e.Snapshot != nil {
+ t.Fatalf("placing a wager redrew the board: %+v", e)
+ }
+ }
+ // The board is still available on demand and after settlement.
+ for _, e := range table.status("a") {
+ if _, ok := e.Snapshot.(*RouletteSnapshot); ok {
+ return
+ }
+ }
+ t.Fatal("table status did not offer the board")
+}
+
func TestRouletteRefundAndPersistenceFailure(t *testing.T) {
for _, spinning := range []bool{false, true} {
table := rouletteFixture()