From 13db3e50cb6e29eedbf25416e5284bf08b0adefb Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 24 Sep 2026 20:06:41 -0400 Subject: feat: casino table ux: aliases, rebet hints, deadline notifications, spectator mode --- internal/casino/blackjack.go | 163 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 136 insertions(+), 27 deletions(-) (limited to 'internal/casino/blackjack.go') 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 ", 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 ", 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) { -- cgit v1.2.3