package casino import ( "fmt" "strings" ) // WagerRow is one line of a table's "list" of available bets, in the game's // current state: the bet wording (what follows "bet N on"), its // payout, and a brief description of the win condition. type WagerRow struct { Bet string Payout string Win string } // rebetWager is one remembered wager a multi-bet game can replay on a bare // bet/rebet. type rebetWager struct { spot string amount int } const ( hintCommandRules = "rules" hintCommandList = "list" ) // withTableHints finishes any game's hint list: "rules" is always present // while seated at a table, and "list" follows when the game named more than // one wager a player might want to make (a lone bet is just "bet N"). // Games whose own menus already append these hints are not double-hinted. func withTableHints(commands []MenuCommand, wagerRows []WagerRow) []MenuCommand { for _, c := range commands { if c.Command == hintCommandRules { return commands } } commands = append(commands, MenuCommand{Command: hintCommandRules, Desc: "This game's full help page"}) if len(wagerRows) > 1 { commands = append(commands, MenuCommand{Command: hintCommandList, Desc: "List every bet available now, with payouts"}) } return commands } // stripMenuBalance clears the wallet display from a bet response's menus, so // placing a wager never prints the balance. Explicit status/look and later // settlements keep their balance. func stripMenuBalance(events []Event) []Event { for i := range events { if events[i].Menu != nil { events[i].Menu.ShowBalance = false } } return events } // RulesText is the shared rules view for table commands and room fixtures. func (c TableConfig) RulesText() string { return rulesTextOnTable(c) } func (c TableConfig) Label() string { if c.Game == GamePaiGow { return c.PaiGow.Label() } if c.Game == GameVideoPoker { return c.VideoPoker.CabinetLabel() } if c.Game == GameRoulette { label := "Roulette / Single zero" if c.Roulette.DoubleZero { label = "Roulette / Double zero" } if c.Roulette.Racetrack { label += " / Racetrack" } return label } if c.Game != GameBaccarat { return gameTitle(c.Game) } switch c.Baccarat.Normalize().Variant { case BaccaratSuper6: return "Baccarat / Super 6" case BaccaratEZ: return "Baccarat / EZ" default: return "Baccarat / Commission" } } func (c *tableCore[P]) overview() string { c.mu.Lock() defer c.mu.Unlock() return c.overviewLocked() } func (c *tableCore[P]) overviewLocked() string { // Concise enough to sit beside a look minimap without wrapping into it. // The betting countdown lives in the menu heading and warning lines; the // wager range is shown in the betting menu and on the rule card instead. return fmt.Sprintf("%s | %d/%d seated", c.Config.Label(), c.players.count(), c.Config.MaxPlayers) } // seats lists the seated players in seat order for the look details view. func (c *tableCore[P]) seats() []string { c.mu.Lock() defer c.mu.Unlock() names := make([]string, 0, c.players.count()) for _, p := range c.players.ordered() { names = append(names, p.participantPtr().Name) } return names } // state is the shared lifecycle phase for the look details view. func (c *tableCore[P]) state() string { c.mu.Lock() defer c.mu.Unlock() return string(c.phase) } func (c *tableCore[P]) warning(name, text string) Event { e := c.private(name, text) e.Color = "casino_warning" return e } // committedMenu never advertises another bet over an already committed wager. func (c *tableCore[P]) committedMenu(name string) Event { e := c.private(name, "Your wager is committed; waiting for the round.") e.Color = "casino_menu" commands := []MenuCommand{ {Command: "table", Desc: "View the table"}, {Command: "stop", Desc: "Leave after this round"}, } // Until the round is dealt or spun, the committed wager can still be // taken back with the same universal unbet command every game uses. if c.phase == phaseBetting { commands = append([]MenuCommand{{Command: "unbet", Desc: "Take back all your wagers before the round"}}, commands...) } e.Menu = &MenuView{Heading: e.Message, Commands: withTableHints(commands, nil)} return e } // unbetLocked refunds a seated player's pre-round wager in the shared betting // phase. Every table game routes the single unbet command through it; a stake // already in play (cards dealt, wheel spinning) is refused. Games that keep // their own stake metadata clear it after this returns. func (c *tableCore[P]) unbetLocked(name string) ([]Event, bool) { for _, p := range c.players.participants() { if p.Name != name { continue } if c.phase != phaseWaiting && c.phase != phaseBetting { return []Event{c.private(name, "You can only take back bets before the round starts.")}, true } if !p.HasBet || p.Wager.Total == 0 { return []Event{c.private(name, "Nothing to take back.")}, true } total := p.Wager.Total var ids []string if p.Wager.ID != "" { ids = append(ids, p.Wager.ID) } if p.Wallet != nil { if err := p.Wallet.RefundStakes(ids...); err != nil { return []Event{c.private(name, payoutFailedMessage())}, true } } p.HasBet, p.Wager = false, Wager{} return []Event{c.private(name, fmt.Sprintf("You take back %d credits.", total))}, true } return []Event{c.private(name, "You aren't seated at this table.")}, true } // controlLocked queues departures without changing committed hands or stakes. func (c *tableCore[P]) controlLocked(name, action string) ([]Event, bool) { if action != "leave_after" && action != "sitout" { return nil, false } for _, p := range c.players.participants() { if p.Name != name { continue } if action == "leave_after" { p.LeaveAfter = true return []Event{c.private(name, "You’ll leave after this round. Your current hand still needs your actions.")}, true } if p.HasBet { p.SitOutAfter = true return []Event{c.private(name, "You’ll sit out after this round. Your current wager and actions are unchanged.")}, true } p.SittingOut = true return []Event{c.private(name, "You’re sitting out. Place a bet to rejoin; stop to leave.")}, true } return []Event{c.private(name, "You aren’t seated at this table.")}, true } // Called only after resetRound, when seat removal cannot disturb active indices. func (c *tableCore[P]) finishControlsLocked() []Event { var events []Event for _, p := range c.players.participants() { if p.LeaveAfter { c.removeSeatLocked(p.Name) events = append(events, c.playerAction(p.Name, p.Name+" stands up.", "Round complete. You stand up.")) } else if p.SitOutAfter { p.SitOutAfter = false p.SittingOut = true events = append(events, c.private(p.Name, "You’re now sitting out. Place a bet to rejoin.")) } } return c.closeIfEmptyLocked(events) } func (c *tableCore[P]) seatStatusLocked(name string) string { for _, p := range c.players.participants() { if p.Name != name { continue } if p.LeaveAfter { return "Leaving after this round" } if p.SitOutAfter { return "Sitting out after this round" } if p.SittingOut { return "Sitting out | Bet to rejoin" } if p.HasBet { return fmt.Sprintf("Wager committed: %d", p.Wager.Total) } } var waiting []string for _, p := range c.players.participants() { if p.Connected && !p.SittingOut && !p.HasBet { waiting = append(waiting, p.Name) } } if len(waiting) > 0 { return "Waiting for bets: " + strings.Join(waiting, ", ") } return "" } func (t *blackjackTable) status(name string) []Event { t.mu.Lock() defer t.mu.Unlock() e := t.private(name, "") if text := t.seatStatusLocked(name); text != "" { e.Message = text } events := []Event{e} switch t.phase { case blackjackPlaying: events[0].Snapshot = t.snapshot(false) if t.activeName() == name { events = append(events, t.turnMenuLocked(name)) } else { events[0].Message += "\n" + t.activeName() + "’s turn | You are waiting" } case blackjackInsurance: events[0].Snapshot = t.snapshot(false) if p := t.players.get(name); p != nil && p.HasBet && !p.declinedInsurance && p.insurance == 0 { events = append(events, t.insuranceMenuLocked(p)) } default: if p := t.players.get(name); p != nil && !p.HasBet { events = append(events, t.menuEvent(name)) } } return events } func (t *baccaratTable) status(name string) []Event { t.mu.Lock() defer t.mu.Unlock() e := t.private(name, "") p := t.players.get(name) if text := t.seatStatusLocked(name); text != "" { // Report every committed stake, not just the main wager. if p != nil && p.HasBet && len(p.sides) > 0 && strings.HasPrefix(text, "Wager committed") { text = fmt.Sprintf("Wager committed: %d", p.totalStaked()) } e.Message = text } events := []Event{e} if p != nil && !p.HasBet { events = append(events, t.menuEvent(name)) } return events } func (t *blackjackTable) insuranceMenuLocked(p *blackjackPlayer) Event { cost := p.hands[0].bet / 2 e := t.private(p.Name, "Insurance decision") e.Color = "casino_menu" e.Menu = &MenuView{Heading: fmt.Sprintf("Insurance | Costs %d | Pays %g:1 | %d ticks remaining", cost, t.Rules.InsurancePayout, t.timer.remaining()), Commands: []MenuCommand{{Command: "insurance", Desc: "Buy insurance", Cost: cost}, {Command: "decline", Desc: "Continue without insurance (also the timeout default)"}}} if cost == 0 { e.Menu.Commands = e.Menu.Commands[1:] e.Menu.Heading = "Bet too small to insure | decline to continue" } return e } // SettlementLine is one settled bet within a round. Label names the bet for // the itemized line (empty for a lone anonymous hand); Result is the engine's // settlement word ("win", "loss", "bust", "push", "blackjack", // "surrender", "insurance"). type SettlementLine struct { // Odds are profit odds, not total-return multipliers. Unresolved wagers // retain their stake and are excluded from settlement totals. Odds string Unresolved bool Label string Result string Stake int Returned int Tag string // optional color-category override (jackpot, for example) } type settlementLine = SettlementLine func (l SettlementLine) net() int { if l.Unresolved { return 0 } return l.Returned - l.Stake } // resultTextLabel renders a settled wager as a colored outcome plus its net, // naming the bet on its own line. func resultTextLabel(label, result string, stake, returned int) string { return settlementText([]settlementLine{{Label: label, Result: result, Stake: stake, Returned: returned}}) } // settlementText renders a settlement as a colored headline followed by one // line per named bet. The net is the only figure shown: the old // "Net ... | ... returned (stake included)" split is gone. Wins are green, // losses red and pushes white, with the headline taking the round's overall // result and each line its own. func settlementText(lines []settlementLine) string { net := 0 for _, line := range lines { net += line.net() } var sb strings.Builder fmt.Fprintf(&sb, "{%s}%s{/} {casino_payout}%s{/}", settlementTag(lines), settlementLabel(lines), creditsText(net)) for _, line := range lines { if line.Label == "" { continue } fmt.Fprintf(&sb, "\n {casino_action}%s:{/} {%s}%s{/}", line.Label, lineTag(line), creditsText(line.net())) } return sb.String() } // creditsText formats a signed credit total with the right plural. func creditsText(n int) string { word := "credits" if n == 1 || n == -1 { word = "credit" } return fmt.Sprintf("%+d %s", n, word) } // settlementLabel returns the headline word for a round. func settlementLabel(lines []settlementLine) string { if len(lines) == 1 { switch lines[0].Result { case "blackjack": return "BLACKJACK" case "surrender": return "SURRENDER" } } switch { case settlementNet(lines) > 0: return "WIN" case settlementNet(lines) < 0: return "LOSS" default: return "PUSH" } } // settlementTag returns the headline's color tag. A lone natural keeps the // jackpot gradient; otherwise the net decides green, red or white. func settlementTag(lines []settlementLine) string { if len(lines) == 1 && lines[0].Tag != "" { return lines[0].Tag } if len(lines) == 1 && lines[0].Result == "blackjack" { return "casino_jackpot_win" } switch { case settlementNet(lines) > 0: return "casino_result_win" case settlementNet(lines) < 0: return "casino_result_loss" default: return "casino_result_push" } } // lineTag colors one bet's outcome. Insurance is judged by its net because the // same result word covers a paid and a lost premium. func lineTag(line settlementLine) string { if line.Tag != "" { return line.Tag } switch line.Result { case "blackjack": return "casino_jackpot_win" case "push": return "casino_result_push" case "win": return "casino_result_win" case "insurance": switch { case line.net() > 0: return "casino_result_win" case line.net() < 0: return "casino_result_loss" default: return "casino_result_push" } default: return "casino_result_loss" } } func settlementNet(lines []settlementLine) int { net := 0 for _, line := range lines { net += line.net() } return net }