aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/blackjack.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/blackjack.go')
-rw-r--r--internal/casino/blackjack.go78
1 files changed, 67 insertions, 11 deletions
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index 0a0551c..7b30699 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -72,12 +72,22 @@ type blackjackTable struct {
phase blackjackPhase
window bettingWindow
timer countdown
+ ceiling countdown
activePlayer int
activeHand int
dealer blackjackHand
shoe *shoe
}
+// loneTurnGrace multiplies the action window into an absolute per-turn
+// ceiling. A lone connected player is deliberately never timed out by the
+// action timer (a solo player's think time must not end the hand), but
+// "never" cannot mean forever: a hung-but-connected client would hold the
+// hand, the seat, and the stake open with no automatic resolution, since
+// "leave" refuses mid-round. After this many ticks on one turn the table
+// stands the hand for the player and plays on.
+const loneTurnGrace = 10
+
func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTable {
cfg = cfg.Normalize()
rules := cfg.Blackjack.Normalize()
@@ -209,6 +219,18 @@ func (t *blackjackTable) tick() []Event {
t.activePlayer++
return t.finishHand()
}
+ // The absolute turn ceiling bounds every turn, including a lone
+ // connected player's: the action timer below deliberately never
+ // expires for a solo player, so without this a hung-but-connected
+ // client would hold the stake and the phase open forever.
+ t.ceiling.tick()
+ if t.ceiling.expired() {
+ active.hands[t.activeHand].stood = true
+ events := []Event{t.private(name, "You've been idle too long; standing.")}
+ // Restart the action timer so the next hand gets a full window.
+ t.timer.start(t.Rules.ActionTicks)
+ return append(events, t.finishHand()...)
+ }
if active.Connected && t.bettorCount() <= 1 {
// A lone connected player is never timed out. A disconnected
// active player always is, so the table can never stall.
@@ -395,8 +417,13 @@ func splitCompatible(a, b card, unlikeTens bool) bool {
}
func (t *blackjackTable) startRound() []Event {
- // The cut-card reshuffle happens between rounds, never mid-hand.
+ // The cut-card reshuffle and a best-effort round-coverage reshuffle
+ // happen at this boundary, before a single card of the round is dealt,
+ // so a boundary reshuffle cannot redeal a card the round already holds.
+ // draw() can still rebuild an exhausted shoe mid-round (a deliberate
+ // shuffle-through), so this is a heuristic, not a guarantee.
t.shoe.shuffleIfDue()
+ t.shoe.ensureForRound(t.roundCardCeiling())
t.dealer = blackjackHand{cards: []card{t.shoe.draw(), t.shoe.draw()}}
for _, p := range t.players.ordered() {
if !p.HasBet {
@@ -440,10 +467,23 @@ func (t *blackjackTable) beginPlay() {
t.activePlayer, t.activeHand = 0, 0
}
+// roundCardCeiling is the heuristic number of cards this table's round is
+// assumed to consume (see blackjackRoundCards): the dealer's hand plus every
+// seat's maximum number of split hands. startRound uses it to decide whether
+// to reshuffle at the round boundary. It is NOT a certified maximum (an
+// all-ace hand can exceed the per-hand estimate), so the shoe is not
+// guaranteed to hold it and draw() may still rebuild an exhausted shoe
+// mid-round (a deliberate shuffle-through).
+func (t *blackjackTable) roundCardCeiling() int {
+ return blackjackRoundCards(t.Config.MaxPlayers, t.Rules.MaxSplitHands)
+}
+
func (t *blackjackTable) prompt() []Event {
// Every freshly-prompted turn gets a full action window; rejected inputs
- // and timeouts can never extend the deadline.
+ // and timeouts can never extend the deadline. The turn ceiling restarts
+ // with the window for the same reason.
t.timer.start(t.Rules.ActionTicks)
+ t.ceiling.start(t.Rules.ActionTicks * loneTurnGrace)
name := t.activeName()
if name == "" {
return t.settle()
@@ -669,6 +709,7 @@ func (t *blackjackTable) resetRound() {
t.phase = blackjackWaiting
t.window.close()
t.timer.stop()
+ t.ceiling.stop()
for _, p := range t.players.ordered() {
p.HasBet, p.Wager, p.hands, p.insurance, p.insuranceStakeID, p.declinedInsurance = false, Wager{}, nil, 0, "", false
}
@@ -685,6 +726,8 @@ func (t *blackjackTable) resolveHandStakes(wallet Wallet, hand *blackjackHand) {
}
func (t *blackjackTable) rebet(name string) (string, int) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
if p := t.players.get(name); p != nil && p.lastBet > 0 {
return "", p.lastBet
}
@@ -753,10 +796,13 @@ func blackjackResultMessages(name, result string, payout int) (string, string) {
}
}
-// markDisconnected removes the player from the table, forfeiting any wager
-// they committed (settle only pays seated bettors). Removing a seat shifts
-// the seat indices the active turn points into, so mid-round play is fixed
-// up and handed to the next bettor (or settled) immediately.
+// markDisconnected removes the player from the table. A wager is forfeited
+// only when a hand is actually in flight (cards are face up, so a free
+// refund would hand back information the round carries); a stake placed in
+// the betting window has revealed nothing yet and is REFUNDED, so a
+// transient disconnect cannot destroy an undelt wager. Removing a seat
+// shifts the seat indices the active turn points into, so mid-round play is
+// fixed up and handed to the next bettor (or settled) immediately.
func (t *blackjackTable) markDisconnected(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
@@ -768,10 +814,16 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event {
if index < 0 {
return nil
}
- // A disconnected player forfeits the round in flight: resolve the
- // stakes now so startup recovery does not refund them later.
if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil {
- p.Wallet.ResolveStakes()
+ if t.phase == blackjackPlaying || t.phase == blackjackInsurance {
+ // A disconnected player forfeits the round in flight: resolve the
+ // stakes now so startup recovery does not refund them later.
+ p.Wallet.ResolveStakes()
+ } else {
+ // No card has been dealt for this stake yet, so refund it: nothing
+ // the bettor paid for has been revealed or played.
+ p.Wallet.RefundOutstanding()
+ }
}
t.players.remove(name)
events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)}
@@ -801,9 +853,12 @@ func (t *blackjackTable) hasParticipant(name string) bool {
return t.players.has(name)
}
-// refundAll resolves every committed wager at this table (graceful
+// refundAll refunds every committed wager at this table (graceful
// shutdown/eviction): the wallet returns every outstanding stake — opening
-// bet, doubles, splits, and insurance — exactly once.
+// bet, doubles, splits, and insurance — exactly once. It then RESETS the
+// round, so a refunded round can never settle (and pay) afterwards: calling
+// it twice, or letting the table keep running, cannot pay the same round
+// twice.
func (t *blackjackTable) refundAll() []Event {
t.mu.Lock()
defer t.mu.Unlock()
@@ -825,6 +880,7 @@ func (t *blackjackTable) refundAll() []Event {
fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund)))
}
+ t.resetRound()
return events
}