From 9666676766bf21364f4fc4d0d1d336e72f2a91b4 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 6 Oct 2026 12:57:23 -0400 Subject: refactor: split casino.go, dedup helpers, remove audit docs --- internal/game/casino.go | 622 +----------------------------------------------- 1 file changed, 11 insertions(+), 611 deletions(-) (limited to 'internal/game/casino.go') diff --git a/internal/game/casino.go b/internal/game/casino.go index 87b7176..a0cc675 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -1,211 +1,15 @@ 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 @@ -349,18 +153,13 @@ func (g *Game) executePlay(sess *net.Session, args []string, rawInput string) { 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) - } + // Let executeBet parse the wager/target tokens. Pre-validating here + // rejected the advertised "play GAME N on BET" form (len > 3) and any + // machine amount (choice.table is false for machines); executeBet's own + // parser already produces precise errors for each game's syntax. + g.executeBet(sess, args[1:], rawInput) } } @@ -667,8 +466,8 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { // 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. +// heading (falling back to the event message), and a copy of the commands. +// Unaffordable commands are filtered at render time by casinoCommandColumns. func (g *Game) casinoMenuParts(sess *net.Session, menu *casino.MenuView, message string) (balance, heading string, commands []casino.MenuCommand) { balance = "" if menu.ShowBalance { @@ -682,11 +481,9 @@ func (g *Game) casinoMenuParts(sess *net.Session, menu *casino.MenuView, message if !g.casinoHintsVisible(sess, menu) { commands = nil } - for i := range commands { - if commands[i].Cost > casinoAffordable(sess.Player) { - commands[i].Desc += " [unavailable: insufficient funds]" - } - } + // Unaffordable commands are hidden by casinoCommandColumns; an + // "unavailable: insufficient funds" annotation here would be dead, since + // the renderer drops exactly those commands with the same test. return balance, heading, commands } @@ -726,7 +523,7 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas if !isCashHunt && !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { return "", false } - if !isCashHunt && !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { + if isRoulette && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { // The compact reminder is already a short block; leave it intact. return "", false } @@ -881,400 +678,3 @@ func (g *Game) casinoMenuHeader(sess *net.Session, balance, heading string) stri 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) -} -- cgit v1.2.3