From b3568f03e4103dd46db2ccc680fbab0a6454d604 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 15 Sep 2026 13:56:10 -0400 Subject: fix: telnet buffer reuse, bank stack handling, transport casino gate, and admin cache invalidation --- internal/casino/blackjack.go | 9 +++++- internal/casino/blackjack_timer_test.go | 50 +++++++++++++++++++++++++++++++++ internal/casino/machine.go | 16 +++++++++-- internal/casino/machine_test.go | 31 ++++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 internal/casino/blackjack_timer_test.go (limited to 'internal/casino') 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) + } +} -- cgit v1.2.3