diff options
Diffstat (limited to 'internal/casino/blackjack.go')
| -rw-r--r-- | internal/casino/blackjack.go | 78 |
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 } |
