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 } // 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 failed save reverts the in-memory debit and // ledger entry, so a player is never charged for a stake that is not durable. 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.g.AccountStore.SaveCharacter(w.p); err != nil { if errors.Is(err, persistence.ErrDurabilityUncertain) { // The character file was replaced but its directory entry may not // be durable. The debit and the pending entry are in the visible // file, so keep them: a surviving save refunds the stake at // startup, and a lost one means the player still has the money. // Either way the stake is never charged twice. fmt.Fprintf(os.Stderr, "casino wager: %s save durability uncertain: %v — stake kept pending\n", w.p.Name, err) return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, 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 // failed save reverts both the credit and the ledger and reports persistence // failure, so the caller never announces a win that is not durable. 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 any // 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.g.AccountStore.SaveCharacter(w.p); err != nil { w.p.Credits = creditsBefore w.p.PendingWagers = append(w.p.PendingWagers, removed...) return casino.ErrWalletPersistence } return nil } // ResolveStakes consumes every stake still pending for the player: the round // resolved as a loss, a forfeit, or a slots outcome whose payout carried no // stake linkage. The pending ledger and the save are the commit point. func (w casinoWallet) ResolveStakes() { if len(w.p.PendingWagers) == 0 { return } w.p.PendingWagers = nil if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v\n", w.p.Name, err) } } // 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 == "" || !removePendingWager(w.p, stakeID) { return } if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { fmt.Fprintf(os.Stderr, "casino resolve stake: save for %s: %v\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. On a failed // save the in-memory return and ledger are reverted so a later attempt (or // startup recovery) retries cleanly. 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.g.AccountStore.SaveCharacter(w.p); 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 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 } for _, cfg := range room.CasinoMachines { if strings.EqualFold(string(cfg.Game), gameName) || strings.EqualFold(cfg.Variant, gameName) { machine := g.Casino.EnsureMachine(roomID, cfg) return machine, machine != nil } } // Migrate older room definitions that declared slots under casino_tables; // slots are still always instantiated as one-player machines. if strings.EqualFold(gameName, "slots") { for _, cfg := range room.CasinoTables { if strings.EqualFold(string(cfg.Game), "slots") { machine := g.Casino.EnsureMachine(roomID, casino.LegacySlotMachine(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 || len(args) == 0 { sess.WriteLine("Play what?") 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 } g.startCasinoGame(sess, p, args[0]) } 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 } g.emitCasinoEvents(g.Casino.Join(table.RoomID, table.Config, p.Name)) } 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 , or bet max.") return } table, playing := g.Casino.Participation(p.Name) if !playing || table == nil { sess.WriteLine("You're not playing a game.") return } // Table bet syntax: bet | bet [on] | bet min|max [on] . // 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. amount, spot, hasAmount, sawOn := 0, "", false, false for _, arg := range args { if strings.EqualFold(arg, "on") { sawOn = true continue } if !sawOn { if strings.EqualFold(arg, "min") { if hasAmount { sess.WriteLine("Use bet, bet [on ], bet min, or bet max.") return } amount, hasAmount = table.Config.MinBet, true continue } if strings.EqualFold(arg, "max") { if hasAmount { sess.WriteLine("Use bet, bet [on ], bet min, or bet max.") return } 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 { if hasAmount { sess.WriteLine("Use bet, bet [on ], bet min, or bet max.") return } amount, hasAmount = value, true continue } } if spot != "" { sess.WriteLine("Use bet, bet [on ], bet min, or bet max.") return } spot = strings.ToLower(arg) } 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 } g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, action)) } 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 { 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 } message := event.Message if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { message += "\n" + rendered } sess.WriteLine(g.colorize(sess, category, message)) } } if event.PrivateTo == "" { continue } text := event.PrivateMessage if text == "" { text = event.Message } if text == "" && event.Menu == nil { continue } g.charsMu.Lock() sess := g.loggedInChars[event.PrivateTo] g.charsMu.Unlock() if sess == nil { continue } privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, text) if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { privateMessage += "\n" + rendered } sess.WriteLine(g.renderCasinoMessage(sess, category, event.Menu, privateMessage)) if category == "casino_menu" { g.writePrompt(sess) } } } // 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 category == "casino_menu" && menu != nil { return message } return g.colorize(sess, category, message) } func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string { if category != "casino_menu" || menu == nil { return message } balance := "" if menu.ShowBalance { chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player))) credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits)) balance = fmt.Sprintf("You have %s chips and %s credits.", chips, credits) } heading := menu.Heading if heading == "" { heading = message } return g.renderCasinoCommands(sess, balance, heading, menu.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") switch s := snapshot.(type) { case *casino.SlotSnapshot: return renderSlotSnapshot(s, mode, unicode) case *casino.BlackjackSnapshot: return renderBlackjackSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) case *casino.BaccaratSnapshot: return renderBaccaratSnapshot(s, mode, unicode, sess.Player.OptionInt("card_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 { var sb strings.Builder if balance != "" { sb.WriteString(balance) sb.WriteString("\n\n") } if heading != "" { sb.WriteString(heading) sb.WriteString("\n\n") } width := 0 for _, c := range commands { if n := utf8.RuneCountInString(c.Command); n > width { width = n } } for _, c := range commands { padded := c.Command + strings.Repeat(" ", width-utf8.RuneCountInString(c.Command)) cmd := g.colorize(sess, "command", padded) if c.Desc != "" { fmt.Fprintf(&sb, " %s %s\n", cmd, c.Desc) } else { fmt.Fprintf(&sb, " %s\n", cmd) } } return strings.TrimRight(sb.String(), "\n") } func renderBlackjackSnapshot(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store) 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 + "):" } lines = append(lines, cardLine(mode, dealerTitle)) lines = append(lines, cardsLine(mode, snapshot.Dealer, size, unicode, style, art)...) for _, player := range snapshot.Players { lines = append(lines, cardLine(mode, fmt.Sprintf("%s (%s):", player.Name, player.Score))) 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 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 { 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 } func cardRenderSize(unicode bool, size int) int { 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": "♣"} 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 + strings.ToUpper(card.Suit[:1]) if configured := art.Art(key, size); len(configured) == size { 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) }