package game import ( "errors" "fmt" "os" "strconv" "strings" "unicode/utf8" "thehouseoficarus/internal/cardart" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/persistence" "thehouseoficarus/internal/player" ) type casinoWallet struct { g *Game p *player.Player // Optional per-wallet persistence seam for fault-injection tests. saveCharacter func(*player.Player) error } // save treats a completed replacement as applied, even if its durability is // uncertain. Reverting memory then would disagree with the visible file and // could overwrite a paid win or resurrect a resolved stake on the next save. // Report the storage fault, but do not tell the game to retry/refund an // already-applied mutation. Only pre-replacement failures require rollback. func (w casinoWallet) save(operation string) error { save := w.saveCharacter if save == nil { save = w.g.AccountStore.SaveCharacter } err := save(w.p) if errors.Is(err, persistence.ErrDurabilityUncertain) { fmt.Fprintf(os.Stderr, "casino %s: %s save durability uncertain: %v — keeping applied state\n", operation, w.p.Name, err) return nil } return err } // Wager debits the stake and records it in PendingWagers in the SAME atomic // character save. The entry stays until the round resolves: a settlement // removes the stakes it returns (PayCredits) or consumes them // (ResolveStake/ResolveStakes), and startup refunds anything still listed // (see recoverPendingWagers). A pre-replacement save failure reverts the // debit and ledger entry; an uncertain replacement keeps both (see save). func (w casinoWallet) Wager(amount int) (casino.Wager, error) { if amount <= 0 { return casino.Wager{}, casino.ErrInsufficientFunds } creditsBefore := w.p.Credits chips := countChips(w.p) if chips > amount { chips = amount } credits := amount - chips if creditsBefore < credits { return casino.Wager{}, casino.ErrInsufficientFunds } if chips > 0 { w.p.RemoveItem("chips", chips) } w.p.Credits -= credits pending := player.PendingWager{ID: newPendingWagerID(), Chips: chips, Credits: credits} w.p.PendingWagers = append(w.p.PendingWagers, pending) if err := w.save("wager"); err != nil { // Revert the in-memory mutation: the play must not proceed on a // stake that could not be made durable. if chips > 0 { if !w.g.giveChips(w.p, chips) { // Cannot happen (the debit just freed or reduced the chips // stack), but surface it instead of silently losing chips. fmt.Fprintf(os.Stderr, "casino wager revert: no inventory room for %d chips\n", chips) } } w.p.Credits = creditsBefore removePendingWager(w.p, pending.ID) return casino.Wager{}, casino.ErrWalletPersistence } return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, nil } // PayCredits banks a payout durably and, in the SAME save, resolves the // listed stakes. stakeIDs may be empty for a payout that returns no stake (a // free-spin bank already refunded by RefundOutstanding). A table settlement // passes the hand's stake IDs so the credit and the stake return are one // atomic step; a slot win passes its triggering stake for the same reason. // Either way a crash can never pay the payout AND refund the same stake. A // pre-replacement failure reverts both the credit and the ledger and reports // failure. An uncertain replacement is applied and logged (see save). func (w casinoWallet) PayCredits(amount int, stakeIDs ...string) error { return w.payCredits(amount, stakeIDs) } // payCredits credits the payout and, when stakeIDs is non-empty, removes // those stakes from the pending ledger in the same atomic save. On a // pre-replacement failure the credit and the ledger are reverted together. func (w casinoWallet) payCredits(amount int, stakeIDs []string) error { if amount <= 0 && len(stakeIDs) == 0 { return nil } creditsBefore := w.p.Credits sum, capErr := addCreditsChecked(creditsBefore, amount) if capErr != nil { return capErr } var removed []player.PendingWager if len(stakeIDs) > 0 { removed = takePendingWagers(w.p, stakeIDs) } w.p.Credits = sum if err := w.save("payout"); err != nil { w.p.Credits = creditsBefore w.p.PendingWagers = append(w.p.PendingWagers, removed...) return casino.ErrWalletPersistence } return nil } // ResolveStake consumes ONE pending stake by wallet handle: its hand lost or // was paid by a cover, so it must never be refunded. A settlement that // already removed it (PayCredits) makes this a no-op, so no redundant // save is paid. func (w casinoWallet) ResolveStake(stakeID string) { if stakeID == "" { return } removed := takePendingWagers(w.p, []string{stakeID}) if len(removed) == 0 { return } if err := w.save("resolve stake"); err != nil { // The durable file still lists the stake, so restore it in memory to // keep the two records consistent for a later retry/recovery. w.p.PendingWagers = append(w.p.PendingWagers, removed...) fmt.Fprintf(os.Stderr, "casino resolve stake: save for %s: %v (ledger restored in memory)\n", w.p.Name, err) } } // ResolveStakesByIDs consumes the listed pending stakes in one durable save. // A losing split/doubled hand used to call ResolveStake once per handle, each // with its own full character save; batching removes that repeated fsync and // the crash window between saves. func (w casinoWallet) ResolveStakesByIDs(stakeIDs ...string) { if len(stakeIDs) == 0 { return } removed := takePendingWagers(w.p, stakeIDs) if len(removed) == 0 { return } if err := w.save("resolve stakes"); err != nil { // The durable file still lists the stakes, so restore them in memory to // keep the two records consistent for a later retry/recovery. w.p.PendingWagers = append(w.p.PendingWagers, removed...) fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v (ledger restored in memory)\n", w.p.Name, err) } } // RefundOutstanding returns every pending stake to the player — chips in // kind, credits as credits — and clears the ledger in one save. It is used // by graceful shutdown/eviction and by a pre-deal disconnect. A pre-replacement // failure reverts the return and ledger for retry; an uncertain replacement // keeps both applied (see save). func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) { before := append([]player.PendingWager(nil), w.p.PendingWagers...) for _, pending := range before { chips += pending.Chips credits += pending.Credits } if chips == 0 && credits == 0 { return 0, 0, nil } creditsBefore := w.p.Credits w.p.PendingWagers = nil if chips > 0 { if !w.g.giveChips(w.p, chips) { w.p.PendingWagers = before return 0, 0, casino.ErrWalletPersistence } } sum, capErr := addCreditsChecked(creditsBefore, credits) if capErr != nil { if chips > 0 { w.p.RemoveItem("chips", chips) } w.p.PendingWagers = before return 0, 0, casino.ErrWalletPersistence } w.p.Credits = sum if err := w.save("refund"); err != nil { w.p.Credits = creditsBefore if chips > 0 { w.p.RemoveItem("chips", chips) } w.p.PendingWagers = before return 0, 0, casino.ErrWalletPersistence } return chips, credits, nil } // casinoAffordable is the most a player can wager: chips are spent first, // then credits. func casinoAffordable(p *player.Player) int { return p.Credits + countChips(p) } // leaveCasinoGames departs every casino game the player participates in // using the graceful leave policy each game defines for player-initiated // exits (the same path the "stop" command takes): a table with a wager // committed to the round in flight refuses and emits its own refusal // message, machines stop auto-bet or are left outright. It reports whether // the player is now clear of all casino games; false means a departure is // blocked mid-round and the caller (quit) must abort. Nil-safe for test // scaffolding without a Casino manager. func (g *Game) leaveCasinoGames(p *player.Player) bool { if g.Casino == nil { return true } if table, playing := g.Casino.Participation(p.Name); playing { if table != nil { g.emitCasinoEvents(g.Casino.Leave(table.RoomID, table.Config.ID, p.Name)) } else if machine, ok := g.Casino.MachineFor(p.Name); ok { // Stop autobet first, then take the machine's own leave path: a // machine between spins has no committed stake, so one quit must // free it (and must not warn about a forfeit that cannot happen). // A machine mid-spin refuses the leave, the player stays seated, // and the caller warns about the stake that is really committed. if events, stopped := g.Casino.StopMachineAutoBet(machine.RoomID, machine.Config.ID, p.Name); stopped { g.emitCasinoEvents(events) } g.emitCasinoEvents(g.Casino.LeaveMachine(machine.RoomID, machine.Config.ID, p.Name)) } _, stillPlaying := g.Casino.Participation(p.Name) return !stillPlaying } return true } // inCasinoGame reports whether the player is currently seated at any casino // table or machine. Nil-safe for test scaffolding without a Casino manager. func (g *Game) inCasinoGame(name string) bool { if g.Casino == nil { return false } _, playing := g.Casino.Participation(name) return playing } // casinoTable returns the room's table for the named game. A room addresses // at most one table (or machine) per game name — "play blackjack" carries no // table id — so a second table for the same game would be unreachable // content; ValidateRoomConfigs rejects that configuration instead of silently // dropping it. func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) { room, err := g.World.LoadRoom(roomID) if err != nil { return nil, false } if casino.ValidateRoomConfigs(room.CasinoTables, room.CasinoMachines) != nil { return nil, false } for _, cfg := range room.CasinoTables { if (cfg.Game == casino.GameVideoPoker && strings.EqualFold(cfg.ID, gameName)) || (cfg.Game != casino.GameVideoPoker && strings.EqualFold(string(cfg.Game), gameName)) { table := g.Casino.EnsureTable(roomID, cfg) if table == nil { return nil, false } return table, true } } return nil, false } // casinoMachine returns the room's machine for the named game or variant. // Like casinoTable, only the first match is reachable, which is why // ValidateRoomConfigs rejects a room with two entries addressing the same // game name or variant. func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool) { room, err := g.World.LoadRoom(roomID) if err != nil { return nil, false } if casino.ValidateRoomConfigs(room.CasinoTables, room.CasinoMachines) != nil { return nil, false } // An explicit machine id is the most specific address, so match it first: // the shipped room's machine `huff_puff_01` must be reachable by name even // though it also declares a variant. for _, cfg := range room.CasinoMachines { if strings.EqualFold(cfg.ID, gameName) { machine := g.Casino.EnsureMachine(roomID, cfg) return machine, machine != nil } } // Prefer an exact variant match first: a room may host several variants of // one game, where a bare game name is ambiguous. for _, cfg := range room.CasinoMachines { if cfg.Variant != "" && strings.EqualFold(cfg.Variant, gameName) { machine := g.Casino.EnsureMachine(roomID, cfg) return machine, machine != nil } } // Then a bare game name: prefer a variant-less machine so a machine that // declares its own variant is reached by that variant, and a sole machine // of a game is still reachable by the game name. for _, cfg := range room.CasinoMachines { if cfg.Variant == "" && strings.EqualFold(string(cfg.Game), gameName) { machine := g.Casino.EnsureMachine(roomID, cfg) return machine, machine != nil } } for _, cfg := range room.CasinoMachines { if strings.EqualFold(string(cfg.Game), gameName) { machine := g.Casino.EnsureMachine(roomID, cfg) return machine, machine != nil } } return nil, false } func (g *Game) executePlay(sess *net.Session, args []string, rawInput string) { p := sess.Player if p == nil { return } if _, playing := g.Casino.Participation(p.Name); playing { sess.WriteLine("You're already playing a game. Type \"stop\" between rounds to leave it.") return } choices := g.casinoChoices(p.RoomID) input := "" if len(args) > 0 { input = args[0] } choice, ok := resolveCasinoChoice(choices, input) if !ok { g.showCasinoChoices(sess, choices) return } if len(args) > 1 { amount, err := strconv.Atoi(args[1]) if !choice.table || len(args) > 3 || (args[1] != "min" && args[1] != "max" && (err != nil || amount <= 0)) { sess.WriteLine("Use play GAME [N|min|max] [on BET]. No wager was placed.") return } } g.startCasinoGame(sess, p, choice.address) if len(args) > 1 { if t, joined := g.Casino.Participation(p.Name); joined && t != nil { g.executeBet(sess, args[1:], rawInput) } } } func (g *Game) startCasinoGame(sess *net.Session, p *player.Player, gameName string) { if p == nil || sess == nil { return } if _, playing := g.Casino.Participation(p.Name); playing { sess.WriteLine("You're already playing a game. Type \"stop\" between rounds to leave it.") return } if machine, ok := g.casinoMachine(p.RoomID, gameName); ok { g.emitCasinoEvents(g.Casino.JoinMachine(machine.RoomID, machine.Config, p.Name)) return } table, ok := g.casinoTable(p.RoomID, gameName) if !ok { sess.WriteLine(fmt.Sprintf("There is no %s table here.", gameName)) return } events := g.Casino.Join(table.RoomID, table.Config, p.Name) if table.Config.Game == casino.GameVideoPoker { if joined, playing := g.Casino.Participation(p.Name); playing && joined == table { events = g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, "view", 0, casinoWallet{g: g, p: p}) } } g.emitCasinoEvents(events) } // Keep slots' spin-as-bet behavior, while Big 6 spins already committed wagers. func (g *Game) executeSpin(sess *net.Session, args []string, rawInput string) { if sess.Player != nil { if table, playing := g.Casino.Participation(sess.Player.Name); playing && table != nil && table.Config.Game == casino.GameBigSix { if len(args) != 0 { sess.WriteLine("Use spin after placing your bets.") return } g.executeTableAction(sess, "spin") return } } g.executeBet(sess, args, rawInput) } func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { p := sess.Player if p == nil { sess.WriteLine("Bet how much?") return } if machine, ok := g.Casino.MachineFor(p.Name); ok { if len(args) == 0 { g.emitCasinoEvents(g.Casino.StartMachine(machine.RoomID, machine.Config.ID, p.Name, casinoWallet{g: g, p: p})) return } if len(args) == 1 { if args[0] == "max" { amount := min(machine.Config.MaxBet, casinoAffordable(p)) if amount < 1 { sess.WriteLine("You don't have any chips or credits to bet.") return } g.emitCasinoEvents(g.Casino.SetMachineBet(machine.RoomID, machine.Config.ID, p.Name, amount, casinoWallet{g: g, p: p})) return } if amount, err := strconv.Atoi(args[0]); err == nil && amount > 0 { g.emitCasinoEvents(g.Casino.SetMachineBet(machine.RoomID, machine.Config.ID, p.Name, amount, casinoWallet{g: g, p: p})) return } } sess.WriteLine("Use bet, bet N, or bet max.") return } table, playing := g.Casino.Participation(p.Name) if !playing || table == nil { sess.WriteLine("You're not playing a game.") return } if table.Config.Game == casino.GameVideoPoker { g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, strings.TrimSpace("coins "+strings.Join(args, " ")))) return } // Baccarat repeats every remembered side bet and the main bet on a bare // "bet"; games with one wager keep the generic single-rebet path below. if table.Config.Game == casino.GameBaccarat && len(args) == 0 { g.emitCasinoEvents(g.Casino.RebetAll(table.RoomID, table.Config.ID, p.Name, casinoWallet{g: g, p: p})) return } // Table bet syntax: bet | bet N [on BET] | bet min|max [on BET]. // BEFORE an "on" keyword, a token that parses as an amount (or min/max) // sets the wager and any other token is the bet target; AFTER "on", the // next token is always the target. That makes numeric targets reachable // ("bet 5 on 17"), which roulette, craps, and keno need. // Roulette combinations and craps bets need multi-token targets. Once // an amount and target keyword exist, subsequent numbers belong to the // target even without "on" (bet 10 split 1 2 / bet 10 buy 4). // Other games keep the stricter one-token target. joinTarget := table.Config.Game == casino.GameRoulette || table.Config.Game == casino.GameCraps amount, spot, hasAmount, sawOn := 0, "", false, false for i, arg := range args { if strings.EqualFold(arg, "on") { if sawOn || spot != "" || i == len(args)-1 { sess.WriteLine("Use bet N on BET. No wager was placed.") return } sawOn = true continue } // Once an amount is set, every remaining token belongs to the target // ("bet 2 2" and "bet 2 on 2" are identical). Before that, a numeric // token sets the amount and anything else starts the target. if !sawOn && !hasAmount { if strings.EqualFold(arg, "min") { amount, hasAmount = table.Config.MinBet, true continue } if strings.EqualFold(arg, "max") { amount = min(table.Config.MaxBet, casinoAffordable(p)) if amount < 1 { sess.WriteLine("You don't have any chips or credits to bet.") return } hasAmount = true continue } if value, err := strconv.Atoi(arg); err == nil && value > 0 { amount, hasAmount = value, true continue } } if spot == "" { spot = strings.ToLower(arg) continue } if joinTarget { spot += " " + strings.ToLower(arg) continue } sess.WriteLine("Use bet, bet N [on BET], bet min, or bet max.") return } if table.Config.Game == casino.GameBaccarat { switch spot { case "b": spot = "banker" case "p": spot = "player" case "t": spot = "tie" } } if !hasAmount { var lastSpot string lastSpot, amount = g.Casino.Rebet(table.RoomID, table.Config.ID, p.Name) if spot == "" { spot = lastSpot } if amount <= 0 { sess.WriteLine("Bet how much?") return } } g.emitCasinoEvents(g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, spot, amount, casinoWallet{g: g, p: p})) } func (g *Game) executeAutoBet(sess *net.Session, args []string, rawInput string) { p := sess.Player if p == nil { return } if machine, ok := g.Casino.MachineFor(p.Name); ok { g.emitCasinoEvents(g.Casino.EnableMachineAutoBet(machine.RoomID, machine.Config.ID, p.Name, casinoWallet{g: g, p: p})) return } sess.WriteLine("Autobet is only available at games without player choices.") } func (g *Game) executeTableAction(sess *net.Session, action string) { p := sess.Player if p == nil { return } table, playing := g.Casino.Participation(p.Name) if !playing || table == nil { sess.WriteLine("You're not playing a multiplayer casino game.") return } if table.Config.Game == casino.GameVideoPoker && action == "deal" { g.emitCasinoEvents(g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, "deal", 0, casinoWallet{g: g, p: p})) return } g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, action)) } // Keep the automatic post-command prompt out of a seated player's spin // animation. Settlement menus resume prompting once the table leaves playing. func (g *Game) rouletteSpinning(sess *net.Session) bool { if g.Casino == nil || sess == nil || sess.Player == nil { return false } table, playing := g.Casino.Participation(sess.Player.Name) if !playing || table == nil { return false } if table.Config.Game == casino.GameBigSix { return table.Busy(sess.Player.Name) } if table.Config.Game != casino.GameRoulette { return false } s, ok := table.Board(sess.Player.Name).(*casino.RouletteSnapshot) return ok && s.Phase == "playing" } func (g *Game) CasinoTick() { if g.Casino != nil { g.emitCasinoEvents(g.Casino.Tick()) } } func (g *Game) emitCasinoEvents(events []casino.Event) { for _, event := range events { if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Snapshot == nil && len(event.Settlement) == 0 { continue } category := event.Color if category == "" { category = "casino_action" } if event.Public && g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(event.RoomID) { if event.PrivateTo != "" && sess.Player != nil && sess.Player.Name == event.PrivateTo { continue } details := g.seesCasinoDetails(sess, event) if event.Detail && !details { continue } // Style prose before composing it with independently styled art. // Wrapping the finished message leaks casino_action into borders // and plain spaces between a snapshot's own color resets. message := g.casinoProse(sess, category, g.casinoTimeText(event.Message)) if details { if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { if message != "" { message += "\n" } message += rendered } } if message != "" { sess.WriteLine(message) } } } if event.PrivateTo == "" { continue } text := event.PrivateMessage if text == "" { text = event.Message } if text == "" && event.Menu == nil && event.Snapshot == nil && len(event.Settlement) == 0 { continue } g.charsMu.Lock() sess := g.loggedInChars[event.PrivateTo] g.charsMu.Unlock() if sess == nil { continue } privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, g.casinoTimeText(text)) privateMessage = g.renderCasinoMessage(sess, category, event.Menu, privateMessage) rendered := g.renderCasinoSnapshot(sess, event.Snapshot) if len(event.Settlement) > 0 { receipt := renderCasinoSettlement(event.Settlement, sess.Player.OptionBool("unicode"), func(tag, text string) string { return g.colorize(sess, tag, text) }) if event.SettlementNote != "" { receipt += "\n" + g.casinoProse(sess, "casino_warning", event.SettlementNote) } // Reveal first, then the receipt, then the next-action menu. A // settlement's legacy prose is only a non-UI fallback, not a second payout. rendered = composeTableThenReminder(rendered, receipt) if event.Menu == nil { privateMessage = "" } } if reminder, ok := g.casinoBoardReminder(sess, category, event.Menu, event.Snapshot, g.casinoTimeText(text)); ok { privateMessage = composeTableThenReminder(rendered, reminder) } else if rendered != "" { if privateMessage != "" { privateMessage += "\n" } privateMessage += rendered } if event.ShowBalance { privateMessage += "\n" + g.casinoBalance(sess) } sess.WriteLine(privateMessage) if category == "casino_menu" { g.writePrompt(sess) } } } // casinoMenuParts resolves a structured menu into the shared header/balance // heading/commands used by both menu render paths: the balance line, the // heading (falling back to the event message), and a copy of the commands // annotated with an unavailability note for anything the player can't afford. func (g *Game) casinoMenuParts(sess *net.Session, menu *casino.MenuView, message string) (balance, heading string, commands []casino.MenuCommand) { balance = "" if menu.ShowBalance { balance = g.casinoBalance(sess) } heading = g.casinoTimeText(menu.Heading) if heading == "" { heading = message } commands = append([]casino.MenuCommand(nil), menu.Commands...) if !g.casinoHintsVisible(sess, menu) { commands = nil } for i := range commands { if commands[i].Cost > casinoAffordable(sess.Player) { commands[i].Desc += " [unavailable: insufficient funds]" } } return balance, heading, commands } // casinoHintsVisible decides whether a menu's command list prints. "on" // always shows hints (the historical behavior); "off" never does; the default // "once" shows them only on the menu a player gets when sitting down. Players // can always ask for the live list with "what" or "verbs". func (g *Game) casinoHintsVisible(sess *net.Session, menu *casino.MenuView) bool { if sess == nil || sess.Player == nil || menu == nil { return true } switch sess.Player.OptionString("casino_hints") { case "off": return false case "once": return menu.ShowHints default: return true } } // casinoBoardReminder splits a persistent-board menu (craps, roulette, Big 6, // video poker, Pai Gow) so its instructions print under the board they describe. // Every other game, and compact displays, keep their menu in one block. func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *casino.MenuView, snapshot casino.Snapshot, message string) (reminder string, ok bool) { if category != "casino_menu" || menu == nil || sess.Player == nil { return "", false } _, isCraps := snapshot.(*casino.CrapsSnapshot) _, isPoker := snapshot.(*casino.VideoPokerSnapshot) _, isPlinko := snapshot.(*casino.PlinkoSnapshot) _, isPaiGow := snapshot.(*casino.PaiGowSnapshot) b, isBigSix := snapshot.(*casino.BigSixSnapshot) isBigSix = isBigSix && b.Phase != "playing" && b.Phase != "result" s, isRoulette := snapshot.(*casino.RouletteSnapshot) if !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { return "", false } if !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { // The compact reminder is already a short block; leave it intact. return "", false } balance, heading, commands := g.casinoMenuParts(sess, menu, message) // The board replaces the game-name heading, and the balance sits with the // commands underneath it rather than above the graphic. return g.renderCasinoCommands(sess, balance, heading, commands), true } // composeTableThenReminder joins a board graphic and the wager reminder. The // board snapshot already leads with a blank line, and the reminder keeps // exactly one blank line below the board's last visible row. func composeTableThenReminder(table, reminder string) string { out := strings.TrimRight(table, "\n") if reminder != "" { if out != "" { out += "\n\n" } out += reminder } return out } // renderCasinoMessage colorizes a casino message unless it is an // already-composed structured menu. func (g *Game) renderCasinoMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string { if message == "" { return "" } if category == "casino_menu" && menu != nil { return message } return g.casinoProse(sess, category, message) } // casinoProse colorizes casino event text, expanding inline color tags such as // {casino_result_win}WIN{/} against the session's palette. The event's color // category is the default for untagged text. func (g *Game) casinoProse(sess *net.Session, category, text string) string { return g.expandTagsDefault(sess, g.resolveColor(sess, category), text) } func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string { if category != "casino_menu" || menu == nil { return message } balance, heading, commands := g.casinoMenuParts(sess, menu, message) if sess.Player.OptionString("casino_display") == "compact" && !menu.ShowBalance { var hints []casino.MenuCommand for _, c := range commands { label := c.Command switch c.Command { case "hit": label = "h:hit" case "stand": label = "st:stand" case "double": label = "dd:double" case "split": label = "sp:split" } if c.Cost > 0 { label += fmt.Sprintf(" (+%d)", c.Cost) } hints = append(hints, casino.MenuCommand{Command: label, Cost: c.Cost}) } return g.renderCasinoCommands(sess, balance, heading, hints) } return g.renderCasinoCommands(sess, balance, heading, commands) } // renderCasinoSnapshot renders a game's structured view for one session. It is // the only per-game switch in the presentation layer: adding a game means // adding its snapshot type here, and the Snapshot interface guarantees the // event carries it rather than being silently dropped by a field filter. func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) string { if snapshot == nil || sess.Player == nil { return "" } mode := g.colorMode(sess) unicode := sess.Player.OptionBool("unicode") // Card art is governed by card_size alone; casino_display only controls // how verbose the command menus are. size := sess.Player.OptionInt("card_size") switch s := snapshot.(type) { case *casino.PlinkoSnapshot: return renderPlinko(s, mode, unicode) case *casino.PaiGowSnapshot: return renderPaiGow(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt) case *casino.VideoPokerSnapshot: return renderVideoPoker(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt) case *casino.CrapsSnapshot: return renderCrapsSnapshot(s, mode, unicode, sess.Player.OptionString("card_style")) case *casino.BigSixSnapshot: return renderBigSixSnapshot(s, mode, unicode) case *casino.RouletteSnapshot: return renderRouletteSnapshot(s, mode, unicode, size) case *casino.SlotSnapshot: return renderSlotSnapshot(s, mode, unicode) case *casino.BlackjackSnapshot: return renderBlackjackSnapshotFor(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt, sess.Player.Name) case *casino.BlackjackRulesView: return renderBlackjackRulesFelt(s.Config, mode, unicode) case *casino.BaccaratRulesView: return renderBaccaratRulesFelt(s.Config, mode, unicode) case *casino.BaccaratSnapshot: return renderBaccaratSnapshot(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt) } return "" } // renderCasinoCommands lays every casino menu out in the same style: an // optional balance line, a heading, and an aligned command list with each // command in the "command" color and its description in plain text. func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string, commands []casino.MenuCommand) string { head := g.casinoMenuHeader(sess, balance, heading) body := g.casinoMenuCommands(sess, commands) switch { case head == "": return body case body == "": return head default: return head + "\n\n" + body } } // casinoMenuHeader formats the balance and heading that introduce a casino // menu. The heading (if any) comes first so the balance always sits directly // above the command list. It is split from the command list so the roulette // board can be drawn between them. func (g *Game) casinoMenuHeader(sess *net.Session, balance, heading string) string { var sb strings.Builder if heading != "" { sb.WriteString(g.colorize(sess, "casino_action", heading)) sb.WriteString("\n\n") } if balance != "" { sb.WriteString(balance) sb.WriteString("\n\n") } return strings.TrimRight(sb.String(), "\n") } // casinoMenuCommands formats an aligned, colored command list. func (g *Game) casinoMenuCommands(sess *net.Session, commands []casino.MenuCommand) string { return g.casinoCommandColumns(sess, commands) } func renderBlackjackSnapshotFor(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store, viewer string) string { if snapshot == nil { return "" } size = cardRenderSize(unicode, size) var lines []string dealerTitle := "Dealer:" if snapshot.DealerRevealed { dealerTitle = "Dealer (" + snapshot.DealerScore + "):" } else if snapshot.DealerShownScore != "" { dealerTitle = "Dealer (" + snapshot.DealerShownScore + "):" } if size == 1 { lines = append(lines, cardLine(mode, dealerTitle)+" "+strings.Join(cardsLine(mode, snapshot.Dealer, size, unicode, style, art), " ")) } else { lines = append(lines, cardLine(mode, dealerTitle)) lines = append(lines, cardsLine(mode, snapshot.Dealer, size, unicode, style, art)...) } for _, player := range snapshot.Players { name := player.Name if name == viewer { name = "You" } marker := " " if player.Active { marker = "> " } detail := "" if player.Hand > 0 { detail = fmt.Sprintf(" | Hand %d/%d | Wager %d | %s", player.Hand, player.Hands, player.Bet, player.Status) } if player.Active && player.Name != viewer { detail = strings.ReplaceAll(detail, "Your turn", "Playing") } title := fmt.Sprintf("%s%s (%s)%s:", marker, name, player.Score, detail) if size == 1 { lines = append(lines, cardLine(mode, title)+" "+strings.Join(cardsLine(mode, player.Cards, size, unicode, style, art), " ")) } else { lines = append(lines, cardLine(mode, title)) lines = append(lines, cardsLine(mode, player.Cards, size, unicode, style, art)...) } } return strings.Join(lines, "\n") } // renderBaccaratSnapshot draws the player and banker hands followed by the // list of wagers riding on the round. func renderBaccaratSnapshot(snapshot *casino.BaccaratSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store) string { if snapshot == nil { return "" } size = cardRenderSize(unicode, size) var lines []string if size == 1 { lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Player, size, unicode, style, art), " "), cardLine(mode, "Banker ("+snapshot.BankerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Banker, size, unicode, style, art), " ")) } else { lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):")) lines = append(lines, cardsLine(mode, snapshot.Player, size, unicode, style, art)...) lines = append(lines, cardLine(mode, "Banker ("+snapshot.BankerScore+"):")) lines = append(lines, cardsLine(mode, snapshot.Banker, size, unicode, style, art)...) } for _, bet := range snapshot.Bets { lines = append(lines, cardLine(mode, fmt.Sprintf("%s bets %d on %s.", bet.Name, bet.Amount, bet.Spot))) } return strings.Join(lines, "\n") } func cardLine(mode, text string) string { spec := color.NoColor() spec.Fg = 39 return color.Render(mode, spec, text) } func cardsLine(mode string, cards []casino.CardView, size int, unicode bool, style string, art *cardart.Store) []string { if len(cards) == 0 { return nil } if size == 1 { parts := make([]string, len(cards)) for i, card := range cards { if !unicode { if card.Hidden { parts[i] = "[??]" } else { suit := map[string]string{"hearts": "H", "diamonds": "D", "spades": "S", "clubs": "C"}[card.Suit] parts[i] = card.Rank + suit } } else { parts[i] = colorizeCardSymbol(mode, card) } } return []string{strings.Join(parts, " ")} } artLines := make([][]string, len(cards)) for i, card := range cards { artLines[i] = cardArtLines(card, size, unicode, style, art) } lines := make([]string, size) for row := 0; row < size; row++ { var lineParts []string for i, card := range artLines { lineParts = append(lineParts, colorizeCardLine(mode, card[row], cards[i], row, size, style)) } lines[row] = strings.Join(lineParts, " ") } return lines } // renderCardRow lays cards side by side, each column padded to its own art // or label width, and appends an optional centered label row. minWidth lets // the caller force a floor (video poker's HELD labels need it) while Pai Gow // passes 0. It is the shared "cards with numbers underneath" presentation. func renderCardRow(cards []casino.CardView, labels []string, minWidth int, mode string, unicode bool, size int, style string, art *cardart.Store) []string { if len(cards) == 0 { return nil } artLines := make([][]string, len(cards)) widths := make([]int, len(cards)) height := 0 for i, card := range cards { artLines[i] = cardsLine(mode, []casino.CardView{card}, size, unicode, style, art) height = max(height, len(artLines[i])) widths[i] = minWidth for _, line := range artLines[i] { widths[i] = max(widths[i], color.VisibleLen(line)) } if i < len(labels) { widths[i] = max(widths[i], color.VisibleLen(labels[i])) } } lines := make([]string, 0, height+1) for row := 0; row < height; row++ { parts := make([]string, len(cards)) for i := range cards { text := "" if row < len(artLines[i]) { text = artLines[i][row] } parts[i] = rouletteCenter(text, widths[i]) } lines = append(lines, strings.Join(parts, " ")) } if len(labels) > 0 { parts := make([]string, len(cards)) for i := range cards { text := "" if i < len(labels) { text = labels[i] } parts[i] = rouletteCenter(text, widths[i]) } lines = append(lines, strings.Join(parts, " ")) } return lines } func cardRenderSize(unicode bool, size int) int { if size == 1 { return 1 } if !unicode { return 5 } if size == 1 || size == 5 || size == 7 || size == 9 { return size } return 7 } var suitSymbols = map[string]string{"hearts": "♥", "spades": "♠", "diamonds": "♦", "clubs": "♣", "joker": "★"} func cardSymbol(card casino.CardView) string { if card.Hidden { return "?" } return card.Rank + suitSymbols[card.Suit] } func cardArtLines(card casino.CardView, size int, unicode bool, style string, art *cardart.Store) []string { if card.Hidden { return generatedCardArt("?", "", size, unicode, style) } key := card.Rank if card.Rank == "JK" { return jokerCardArt(size, unicode, style, art) } if card.Suit != "" { key += strings.ToUpper(card.Suit[:1]) } if configured := art.Art(key, size); len(configured) == size { if unicode && style == "light" { // The shared pip art draws a dark box frame, which leaves the top // and bottom rows looking like the dark style. Replace only those // two rows with the half-block edges the white body meets. configured = append([]string(nil), configured...) configured[0] = strings.Repeat("▄", utf8.RuneCountInString(configured[0])) configured[size-1] = strings.Repeat("▀", utf8.RuneCountInString(configured[size-1])) } return configured } return generatedCardArt(card.Rank, card.Suit, size, unicode, style) } func generatedCardArt(rank, suit string, size int, unicode bool, style string) []string { if size < 5 { size = 5 } border := "-" left, right := "+", "+" if unicode { border, left, right = "─", "┌", "┐" } bottomLeft, bottomRight := left, right if unicode { bottomLeft, bottomRight = "└", "┘" } vertical := "|" if unicode { vertical = "│" } if unicode && style == "light" { lines := []string{strings.Repeat("▄", size)} lines = append(lines, generatedCardBody(rank, suit, size, unicode, vertical)...) return append(lines, strings.Repeat("▀", size)) } lines := []string{left + strings.Repeat(border, size-2) + right} lines = append(lines, generatedCardBody(rank, suit, size, unicode, vertical)...) lines = append(lines, bottomLeft+strings.Repeat(border, size-2)+bottomRight) return lines } func generatedCardBody(rank, suit string, size int, unicode bool, vertical string) []string { inner := size - 2 lines := make([]string, 0, size-2) for row := 1; row < size-1; row++ { content := strings.Repeat(" ", inner) if row == 1 { content = rank + strings.Repeat(" ", inner-len(rank)) } if row == (size-1)/2 { suitText := "" if suit != "" { suitText = suitLetter(suit, unicode) } suitWidth := utf8.RuneCountInString(suitText) leftPadding := (inner - suitWidth) / 2 content = strings.Repeat(" ", leftPadding) + suitText + strings.Repeat(" ", inner-leftPadding-suitWidth) } if row == size-2 { content = strings.Repeat(" ", inner-len(rank)) + rank } lines = append(lines, vertical+content+vertical) } return lines } func suitLetter(suit string, unicode bool) string { if !unicode { return strings.ToUpper(suit[:1]) } return suitSymbols[suit] } func colorizeCardLine(mode, line string, card casino.CardView, row, size int, style string) string { red := card.Suit == "hearts" || card.Suit == "diamonds" rankColor := 27 if red { rankColor = 196 } suitColor := 27 if red { suitColor = 196 } rankLine := row == 1 || row == size-2 light := style == "light" bodyBackground := light && row > 0 && row < size-1 var builder strings.Builder for _, r := range line { if strings.ContainsRune("┌─┐└┘│┬┴├┤┼╔╗╚╝═║╦╩╠╣╬▀▄+|-", r) { spec := color.ColorSpec{Fg: 15} if bodyBackground { spec.Bg = 15 } builder.WriteString(color.Render(mode, spec, string(r))) } else if r == ' ' { if bodyBackground { builder.WriteString(color.Render(mode, color.ColorSpec{Fg: 15, Bg: 15}, " ")) } else { builder.WriteRune(r) } } else { value := suitColor if rankLine { value = rankColor } spec := color.ColorSpec{Fg: value} if bodyBackground { spec.Bg = 15 } builder.WriteString(color.Render(mode, spec, string(r))) } } return builder.String() } func colorizeCardSymbol(mode string, card casino.CardView) string { if card.Hidden { return color.Render(mode, color.ColorSpec{Fg: 15}, "?") } rankColor, suitColor := 27, 27 if card.Suit == "hearts" || card.Suit == "diamonds" { rankColor, suitColor = 196, 196 } runes := []rune(cardSymbol(card)) rankLength := len([]rune(card.Rank)) return color.Render(mode, color.ColorSpec{Fg: rankColor}, string(runes[:rankLength])) + color.Render(mode, color.ColorSpec{Fg: suitColor}, string(runes[rankLength:])) } func renderSlotSnapshot(snapshot *casino.SlotSnapshot, mode string, unicode bool) string { if snapshot == nil { return "" } left, junction, right, horizontal, vertical := '+', '+', '+', '-', '|' bottomLeft, bottomJunction, bottomRight := '+', '+', '+' if unicode { left, junction, right, horizontal, vertical = '\u250c', '\u252c', '\u2510', '\u2500', '\u2502' bottomLeft, bottomJunction, bottomRight = '\u2514', '\u2534', '\u2518' } separator := string(left) for reel := 0; reel < 5; reel++ { if reel > 0 { separator += string(junction) } separator += strings.Repeat(string(horizontal), 9) } separator += string(right) lines := []string{casinoFrame(mode, separator)} frameVertical := casinoFrame(mode, string(vertical)) for row := 0; row < 3; row++ { line := frameVertical for reel := 0; reel < 5; reel++ { value := "..." if reel < snapshot.StoppedReels { value = snapshot.Grid[row][reel] } // Pad to the fixed 7-cell reel width; a malformed profile could // otherwise make this negative and panic. Validation caps the id // length, so this is a defence-in-depth backstop. pad := 7 - len(value) if pad < 0 { pad = 0 } line += " " + colorSlotSymbol(mode, value) + strings.Repeat(" ", pad) + " " + frameVertical } lines = append(lines, line) } bottom := string(bottomLeft) for reel := 0; reel < 5; reel++ { if reel > 0 { bottom += string(bottomJunction) } bottom += strings.Repeat(string(horizontal), 9) } bottom += string(bottomRight) lines = append(lines, casinoFrame(mode, bottom)) return strings.Join(lines, "\n") } func casinoFrame(mode, text string) string { spec := color.NoColor() spec.Fg = 244 return color.Render(mode, spec, text) } func colorSlotSymbol(mode, symbol string) string { spec := color.NoColor() spec.Fg = 250 switch symbol { case "straw": spec.Fg = 220 case "stick": spec.Fg = 46 case "brick": spec.Fg = 196 case "hat": spec.Fg = 201 case "wolf": spec.Fg = 39 case "scatter": spec.Fg = 226 default: spec.Dim = true } return color.Render(mode, spec, symbol) }