diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/roulette.go | 30 | ||||
| -rw-r--r-- | internal/casino/roulette_history_test.go | 79 | ||||
| -rw-r--r-- | internal/casino/roulette_test.go | 25 |
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() |
