aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/blackjack.go9
-rw-r--r--internal/casino/blackjack_timer_test.go50
-rw-r--r--internal/casino/machine.go16
-rw-r--r--internal/casino/machine_test.go31
4 files changed, 102 insertions, 4 deletions
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index 442d4a2..4901c31 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -214,6 +214,9 @@ func (t *blackjackTable) tick() []Event {
}
active.hands[t.activeHand].stood = true
events := []Event{t.private(name, "Your action timed out; standing.")}
+ // Restart the timer so the next active hand gets a full action window
+ // instead of timing out on the following tick.
+ t.timer.start(t.Rules.ActionTicks)
events = append(events, t.finishHand()...)
return events
default:
@@ -232,7 +235,8 @@ func (t *blackjackTable) action(name, action string) []Event {
}
p := t.players.get(name)
hand := p.hands[t.activeHand]
- t.timer.start(t.Rules.ActionTicks)
+ // The action window only restarts when a new turn is actually prompted
+ // (see prompt()); a rejected command can no longer stall the table.
switch action {
case "hit":
if fromSplitAces(hand) {
@@ -421,6 +425,9 @@ func (t *blackjackTable) beginPlay() {
}
func (t *blackjackTable) prompt() []Event {
+ // Every freshly-prompted turn gets a full action window; rejected inputs
+ // and timeouts can never extend the deadline.
+ t.timer.start(t.Rules.ActionTicks)
name := t.activeName()
if name == "" {
return t.settle()
diff --git a/internal/casino/blackjack_timer_test.go b/internal/casino/blackjack_timer_test.go
new file mode 100644
index 0000000..5a57682
--- /dev/null
+++ b/internal/casino/blackjack_timer_test.go
@@ -0,0 +1,50 @@
+package casino
+
+import "testing"
+
+// Regression: the blackjack action window may only restart when a new turn
+// is actually prompted. Rejected actions must never extend the deadline, and
+// each successive player must receive a full window.
+func TestBlackjackActionWindowBoundaries(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack,
+ Blackjack: BlackjackConfig{ActionTicks: 3}}, newTestRand())
+ for _, name := range []string{"alice", "bob", "carol"} {
+ table.players.add(name, &blackjackPlayer{
+ Participant: Participant{Name: name, Connected: true, HasBet: true, Wallet: &testWallet{credits: 100}},
+ hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "6"}}}},
+ })
+ }
+ table.dealer.cards = []card{{rank: "10"}, {rank: "7"}}
+ table.beginPlay()
+ table.prompt()
+
+ // Alice burns one tick, then submits an invalid action and one she cannot
+ // take (surrender disabled by default). Neither may reset her window.
+ table.tick()
+ table.action("alice", "invalid")
+ table.action("alice", "surrender")
+ if table.timer.clock != 1 {
+ t.Fatalf("rejected actions extended or reset the deadline: timer=%+v", table.timer)
+ }
+
+ // Two more ticks expire her window; the timeout advances to Bob.
+ table.tick()
+ table.tick()
+ if table.activeName() != "bob" {
+ t.Fatalf("timeout did not advance to bob: active=%s", table.activeName())
+ }
+ if table.timer.clock != 0 {
+ t.Fatalf("bob did not receive a fresh action window: timer=%+v", table.timer)
+ }
+
+ // Bob must survive two ticks and time out on the third.
+ table.tick()
+ table.tick()
+ if table.activeName() != "bob" {
+ t.Fatalf("bob timed out early: active=%s", table.activeName())
+ }
+ table.tick()
+ if table.activeName() != "carol" {
+ t.Fatalf("bob did not time out after his window: active=%s", table.activeName())
+ }
+}
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 2f8b753..15c612d 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -26,6 +26,7 @@ type Machine struct {
reel int
clock int
freeSpins int
+ inFreeSpins bool
spinBet int
payoutRemainder float64
profile SlotProfile
@@ -237,8 +238,13 @@ func (m *Machine) finishSpin() []Event {
winEvents[0].Color = "casino_win"
events = append(events, winEvents...)
}
- if m.spin.ScatterCount >= 3 && m.freeSpins == 0 {
+ // A paid feature in progress never retriggers: scatters landing during a
+ // free spin are ignored, matching the CalculateRTP model (the feature is
+ // a fixed number of automatic spins). Retriggering only on a paid spin
+ // keeps the machine's actual return aligned with that model.
+ if m.spin.ScatterCount >= 3 && m.freeSpins == 0 && !m.inFreeSpins {
m.freeSpins = m.profile.FreeSpinCount
+ m.inFreeSpins = true
events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...)
}
if m.freeSpins > 0 {
@@ -247,8 +253,11 @@ func (m *Machine) finishSpin() []Event {
m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile)
m.reel, m.clock, m.suspense = 0, 0, false
events = append(events, m.public(fmt.Sprintf("%d free spins remain.", m.freeSpins))...)
- } else if !m.autoBet {
- events = append(events, m.menuEvents("")...)
+ } else {
+ m.inFreeSpins = false
+ if !m.autoBet {
+ events = append(events, m.menuEvents("")...)
+ }
}
return events
}
@@ -277,6 +286,7 @@ func (m *Machine) resetSeat() {
m.phase = machineReady
m.reel, m.clock, m.suspense = 0, 0, false
m.freeSpins = 0
+ m.inFreeSpins = false
m.payoutRemainder = 0
m.autoBet, m.autoCountdown = false, 0
}
diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go
index 53fe2e6..349a452 100644
--- a/internal/casino/machine_test.go
+++ b/internal/casino/machine_test.go
@@ -113,3 +113,34 @@ func TestMachineDisconnectFreesSeatAndForfeitsSpin(t *testing.T) {
t.Fatalf("empty machine emitted events after disconnect: %+v", events)
}
}
+
+func TestMachineFreeSpinsDoNotRetrigger(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7)))
+ m.player = &Participant{Name: "alice", Connected: true, Wallet: &testWallet{credits: 100}}
+ m.spinBet = 10
+ m.inFreeSpins = true
+ m.freeSpins = 1
+ m.spin = &SlotSpin{ScatterCount: 3}
+ events := m.finishSpin()
+ for _, e := range events {
+ if hasMessage([]Event{e}, "trigger") {
+ t.Fatalf("scatters during free spins retriggered: %+v", events)
+ }
+ }
+ // The last free spin resolved; the feature is now over.
+ m.spin = &SlotSpin{ScatterCount: 3}
+ m.finishSpin()
+ if m.freeSpins != 0 || m.inFreeSpins {
+ t.Fatalf("free-spin feature did not end: freeSpins=%d inFreeSpins=%v", m.freeSpins, m.inFreeSpins)
+ }
+ // Scatters on a paid spin trigger normally again.
+ m.phase = machineReady
+ m.spin = &SlotSpin{ScatterCount: 3}
+ events = m.finishSpin()
+ if m.freeSpins != m.profile.FreeSpinCount-1 || !m.inFreeSpins {
+ t.Fatalf("paid-spin trigger broken: freeSpins=%d inFreeSpins=%v", m.freeSpins, m.inFreeSpins)
+ }
+ if !hasMessage(events, "trigger") {
+ t.Fatalf("missing trigger message: %+v", events)
+ }
+}