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.go163
1 files changed, 136 insertions, 27 deletions
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index a1cb112..2e7d7e9 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -102,11 +102,23 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa
}
func (t *blackjackTable) menuEvent(name string) Event {
- return t.betMenuEvent(name, []MenuCommand{
- {Command: "bet", Desc: "Place a wager (min/max accepted)"},
- {Command: "bet <amount>", Desc: "Place a specific wager"},
- {Command: "stop", Desc: "Leave the table"},
+ if t.phase == blackjackPlaying || t.phase == blackjackInsurance {
+ return t.private(name, "Round in progress. You’ll join the next hand; table shows the current action.")
+ }
+ amount, desc := t.Config.MinBet, "Table minimum"
+ if p := t.players.get(name); p != nil && p.lastBet > 0 {
+ amount, desc = p.lastBet, "Repeat wager"
+ }
+ e := t.betMenuEvent(name, []MenuCommand{
+ {Command: "bet", Desc: fmt.Sprintf("%s: %d credits (also: rebet)", desc, amount)},
+ {Command: "bet <amount>", Desc: "Change wager; min/max accepted"},
+ {Command: "stop", Desc: "Leave; during a round, leave afterward"},
+ {Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"},
})
+ if p := t.players.get(name); p != nil {
+ e.Menu.Repeat = p.lastBet > 0
+ }
+ return e
}
func (t *blackjackTable) join(name string) []Event {
@@ -166,6 +178,7 @@ func (t *blackjackTable) bet(name, spot string, amount int, wallet Wallet) []Eve
if err != nil {
return []Event{t.private(name, walletErrorMessage(err))}
}
+ p.SittingOut = false
p.HasBet, p.Wager, p.Wallet, p.lastBet = true, wager, wallet, amount
events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits.", name, amount), fmt.Sprintf("You bet %d credits.", amount))}
events = t.openBettingLocked(events)
@@ -187,7 +200,15 @@ func (t *blackjackTable) tick() []Event {
}
t.timer.tick()
if !t.timer.expired() {
- return nil
+ var events []Event
+ if t.timer.warn() {
+ for _, p := range t.players.ordered() {
+ if p.HasBet && p.insurance == 0 && !p.declinedInsurance && p.Connected {
+ events = append(events, t.warning(p.Name, fmt.Sprintf("Insurance closes in %d ticks. No response means decline.", t.timer.remaining())))
+ }
+ }
+ }
+ return events
}
return t.finishInsurance()
case blackjackPlaying:
@@ -208,7 +229,7 @@ func (t *blackjackTable) tick() []Event {
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.")}
+ events := []Event{t.warning(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()...)
@@ -216,14 +237,20 @@ func (t *blackjackTable) tick() []Event {
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.
+ if t.ceiling.warn() {
+ return []Event{t.warning(name, fmt.Sprintf("%d ticks remaining. No response means stand.", t.ceiling.remaining()))}
+ }
return nil
}
t.timer.tick()
if !t.timer.expired() {
+ if t.timer.warn() {
+ return []Event{t.warning(name, fmt.Sprintf("%d ticks remaining. No response means stand.", t.timer.remaining()))}
+ }
return nil
}
active.hands[t.activeHand].stood = true
- events := []Event{t.private(name, "Your action timed out; standing.")}
+ events := []Event{t.warning(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)
@@ -237,12 +264,28 @@ func (t *blackjackTable) tick() []Event {
func (t *blackjackTable) action(name, action string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
+ if events, handled := t.controlLocked(name, action); handled {
+ if t.phase == blackjackBetting && allBet(t.players.participants()) {
+ return append(events, t.startRound()...)
+ }
+ return events
+ }
if t.phase == blackjackInsurance {
return t.insuranceAction(name, action)
}
if t.phase != blackjackPlaying || t.activeName() != name {
return []Event{t.private(name, "It's not your turn.")}
}
+ switch action {
+ case "h":
+ action = "hit"
+ case "st":
+ action = "stand"
+ case "dd":
+ action = "double"
+ case "sp":
+ action = "split"
+ }
p := t.players.get(name)
hand := p.hands[t.activeHand]
// The action window only restarts when a new turn is actually prompted
@@ -259,6 +302,7 @@ func (t *blackjackTable) action(name, action string) []Event {
}
e := t.playerAction(name, fmt.Sprintf("%s hits and receives %s.", name, hand.cards[len(hand.cards)-1]), fmt.Sprintf("You hit and receive %s.", hand.cards[len(hand.cards)-1]))
e.Snapshot = t.snapshot(false)
+ e.Detail = true
events := []Event{e}
if total >= 21 {
events = append(events, t.finishHand()...)
@@ -287,6 +331,7 @@ func (t *blackjackTable) action(name, action string) []Event {
}
e := t.playerAction(name, fmt.Sprintf("%s doubles down.", name), "You double down.")
e.Snapshot = t.snapshot(false)
+ e.Detail = true
return append([]Event{e}, t.finishHand()...)
case "split":
return t.splitHand(name, p, hand)
@@ -302,7 +347,7 @@ func (t *blackjackTable) action(name, action string) []Event {
}
// insuranceAction handles commands during the insurance offer window:
-// "insurance" buys cover for half the base bet; any other action declines.
+// "insurance" buys cover for half the base bet; "decline" explicitly declines.
func (t *blackjackTable) insuranceAction(name, action string) []Event {
p := t.players.get(name)
if p == nil || !p.HasBet {
@@ -312,6 +357,9 @@ func (t *blackjackTable) insuranceAction(name, action string) []Event {
return []Event{t.private(name, "You have already decided about insurance.")}
}
var events []Event
+ if action != "insurance" && action != "decline" {
+ return []Event{t.private(name, "Insurance decision first: insurance to buy, decline to continue. No card action has been taken.")}
+ }
if action == "insurance" {
amount := p.hands[0].bet / 2
if amount <= 0 {
@@ -417,7 +465,7 @@ func (t *blackjackTable) startRound() []Event {
p.declinedInsurance = true
}
}
- roundEvent := t.event("", true)
+ roundEvent := t.event("New blackjack hand!", true)
roundEvent.Snapshot = t.snapshot(false)
events := []Event{roundEvent}
if t.dealer.cards[1].rank == "A" {
@@ -428,7 +476,7 @@ func (t *blackjackTable) startRound() []Event {
if !p.HasBet || !p.Connected || p.declinedInsurance {
continue
}
- events = append(events, t.private(p.Name, fmt.Sprintf("Insure your %d-credit bet for %d credits? Type \"insurance\" to buy, or any other action to decline.", p.hands[0].bet, p.hands[0].bet/2)))
+ events = append(events, t.insuranceMenuLocked(p))
}
if t.insuranceSettled() {
events = append(events, t.finishInsurance()...)
@@ -440,6 +488,8 @@ func (t *blackjackTable) startRound() []Event {
return events
}
t.beginPlay()
+ t.skipCompletedHands()
+ events[0].Snapshot = t.snapshot(false)
return append(events, t.finishHand()...)
}
@@ -473,6 +523,7 @@ func (t *blackjackTable) prompt() []Event {
if name == "" {
return t.settle()
}
+ changed := name != t.turnName || t.activeHand != t.turnHand
// The absolute turn ceiling belongs to the turn, not the prompt: restart
// it only when the active (player, hand) changes. A seat event that
// re-prompts the same turn must not reset a hung bettor's deadline.
@@ -481,6 +532,23 @@ func (t *blackjackTable) prompt() []Event {
t.turnName = name
t.turnHand = t.activeHand
}
+ events := []Event{t.turnMenuLocked(name)}
+ if changed {
+ for _, p := range t.players.ordered() {
+ if p.Name == name || !p.Connected {
+ continue
+ }
+ text := name + "’s turn."
+ if p.HasBet && t.players.indexOf(p.Name) > t.activePlayer {
+ text += " You’re waiting for your turn."
+ }
+ events = append(events, t.private(p.Name, text))
+ }
+ }
+ return events
+}
+
+func (t *blackjackTable) turnMenuLocked(name string) Event {
p := t.players.get(name)
h := p.hands[t.activeHand]
ranks := make([]string, len(h.cards))
@@ -490,16 +558,27 @@ func (t *blackjackTable) prompt() []Event {
actions := t.availableActions(p, h)
e := t.private(name, fmt.Sprintf("Your turn (%s).\nActions: %s", strings.Join(ranks, ","), strings.Join(actions, ", ")))
e.Color = "casino_menu"
- e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Commands: blackjackActionCommands(actions)}
- return []Event{e}
+ deadline := t.timer.remaining()
+ if p.Connected && t.bettorCount() <= 1 {
+ deadline = t.ceiling.remaining()
+ }
+ e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn (%s) | Hand %d/%d | %s | Wager %d | %d ticks remaining", strings.Join(ranks, ","), t.activeHand+1, len(p.hands), handScore(h), h.bet, deadline), Commands: blackjackActionCommands(actions)}
+ for i := range e.Menu.Commands {
+ c := &e.Menu.Commands[i]
+ if c.Command == "double" || c.Command == "split" {
+ c.Cost = h.bet
+ c.Desc += fmt.Sprintf(" (+%d credits)", h.bet)
+ }
+ }
+ return e
}
func blackjackActionCommands(actions []string) []MenuCommand {
descriptions := map[string]string{
- "hit": "Take another card",
- "stand": "Hold your total",
- "double": "Double your bet and take one card",
- "split": "Split your pair into two hands",
+ "hit": "[h] Take another card",
+ "stand": "[st] Hold your total",
+ "double": "[dd] Double your bet and take one card",
+ "split": "[sp] Split your pair into two hands",
"surrender": "Forfeit half your bet",
}
commands := make([]MenuCommand, 0, len(actions))
@@ -592,19 +671,25 @@ func (t *blackjackTable) settle() []Event {
if !p.HasBet {
continue
}
- for _, hand := range p.hands {
+ roundNet, roundFailed := int64(0), false
+ for i, hand := range p.hands {
result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules)
// One shared settlement path for every game: pay before
// announcing, and leave the stakes pending on any failure so a
// refund can never be paid on top of a payout that was not durable.
if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) {
- publicResult, _ := blackjackResultMessages(p.Name, result, payout)
- settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage())
+ roundFailed = true
+ settlement := t.playerAction(p.Name, p.Name+"’s settlement is pending recovery.", payoutFailedMessage())
colorizeBlackjackSettlement(&settlement, result)
events = append(events, settlement)
continue
}
- publicResult, privateResult := blackjackResultMessages(p.Name, result, payout)
+ publicResult, _ := blackjackResultMessages(p.Name, result, payout)
+ roundNet += int64(payout) - int64(hand.bet)
+ privateResult := resultText(result, hand.bet, payout)
+ if len(p.hands) > 1 {
+ privateResult = fmt.Sprintf("Hand %d/%d | %s", i+1, len(p.hands), privateResult)
+ }
settlement := t.playerAction(p.Name, publicResult, privateResult)
colorizeBlackjackSettlement(&settlement, result)
events = append(events, settlement)
@@ -616,13 +701,21 @@ func (t *blackjackTable) settle() []Event {
insurancePayout, insuranceErr = MulPayout(p.insurance, 1+t.Rules.InsurancePayout)
}
if settleStakeOutcome(p.Wallet, insurancePayout, []string{p.insuranceStakeID}, insuranceErr) {
+ roundFailed = true
events = append(events, t.private(p.Name, payoutFailedMessage()))
- } else if insurancePayout > 0 {
- events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout)))
} else {
- events = append(events, t.private(p.Name, "Insurance pays 0 credits."))
+ roundNet += int64(insurancePayout) - int64(p.insurance)
+ events = append(events, t.private(p.Name, resultText("insurance", p.insurance, insurancePayout)))
}
}
+ if !roundFailed {
+ e := t.private(p.Name, fmt.Sprintf("Round complete | Net %+d credits (insurance included)", roundNet))
+ e.ShowBalance = true
+ if roundNet > 0 {
+ e.Color = "casino_big_win"
+ }
+ events = append(events, e)
+ }
// Each hand's stakes were already consumed per-handle by
// settleStakeOutcome (a payout removes them in PayCredits; a loss
// resolves them), and the insurance stake likewise. Deliberately do
@@ -631,8 +724,9 @@ func (t *blackjackTable) settle() []Event {
// refundable (see the Wallet contract).
}
t.resetRound()
+ events = append(events, t.finishControlsLocked()...)
for _, p := range t.players.ordered() {
- if p.Connected {
+ if p.Connected && !p.SittingOut {
events = append(events, t.menuEvent(p.Name))
}
}
@@ -724,8 +818,23 @@ func (t *blackjackTable) snapshot(revealDealer bool) *BlackjackSnapshot {
if !p.HasBet {
continue
}
- for _, hand := range p.hands {
- view := BlackjackPlayerView{Name: p.Name, Score: handScore(hand)}
+ for i, hand := range p.hands {
+ status := "Waiting"
+ switch {
+ case hand.bust:
+ status = "Busted"
+ case hand.surrendered:
+ status = "Surrendered"
+ case hand.stood:
+ status = "Standing"
+ case hand.blackjack(t.Rules):
+ status = "Blackjack"
+ }
+ active := t.phase == blackjackPlaying && t.activeName() == p.Name && t.activeHand == i && !revealDealer
+ if active {
+ status = "Your turn"
+ }
+ view := BlackjackPlayerView{Name: p.Name, Score: handScore(hand), Hand: i + 1, Hands: len(p.hands), Bet: hand.bet, Active: active, Status: status}
for _, card := range hand.cards {
view.Cards = append(view.Cards, CardView{Rank: card.rank, Suit: card.suit})
}
@@ -745,7 +854,7 @@ func handScore(hand *blackjackHand) string {
if !soft || total == 21 {
return fmt.Sprintf("%d", total)
}
- return fmt.Sprintf("%d or %d", total-10, total)
+ return fmt.Sprintf("Soft %d", total)
}
func blackjackResultMessages(name, result string, payout int) (string, string) {