diff options
Diffstat (limited to 'internal/game/casino_house_edge.go')
| -rw-r--r-- | internal/game/casino_house_edge.go | 190 |
1 files changed, 190 insertions, 0 deletions
diff --git a/internal/game/casino_house_edge.go b/internal/game/casino_house_edge.go new file mode 100644 index 0000000..8bc3a5b --- /dev/null +++ b/internal/game/casino_house_edge.go @@ -0,0 +1,190 @@ +package game + +import ( + "fmt" + "math" + "strings" + "unicode/utf8" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/ui" +) + +// Only carried, positive-quantity items count. The most precise instrument wins. +func (g *Game) casinoEdgePrecision(sess *net.Session) (int, bool) { + if sess == nil || sess.Player == nil || g.ItemStore == nil { + return 0, false + } + precision, found := 0, false + for _, slot := range sess.Player.Inventory { + if slot == nil || slot.Quantity <= 0 { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil || def.HouseEdgePrecision == nil { + continue + } + p := *def.HouseEdgePrecision + if p != 0 && p != 2 { + continue + } + precision = max(precision, p) + found = true + } + return precision, found +} + +// Convert the unrounded loss, not the displayed/rounded house edge. +func casinoRTP(e casino.HouseEdge, precision int) string { + if len(e.Parts) != 1 { + return "—" + } + value := math.Round((1-e.Parts[0].Loss)*100*math.Pow10(precision)) / math.Pow10(precision) + if value == 0 { + value = 0 + } // no negative zero + return fmt.Sprintf("%.*f%%", precision, value) +} + +type casinoListRow struct { + bet, odds, win string + edge casino.HouseEdge +} + +// Only remove redundant commission prose. Exceptions such as Banker 6 paying +// half or three-card 7 pushing are rules, not redundant explanations. +func conciseCasinoOdds(odds string) string { + if at := strings.Index(odds, " ("); at >= 0 && strings.HasSuffix(odds, "% commission)") { + return odds[:at] + } + return odds +} + +func (g *Game) casinoListRows(sess *net.Session, t *casino.Table, wagers []casino.WagerRow) []casinoListRow { + _, enabled := g.casinoEdgePrecision(sess) + var rows []casinoListRow + for _, wager := range wagers { + row := casinoListRow{bet: wager.Bet, odds: conciseCasinoOdds(wager.Payout), win: wager.Win} + if enabled { + row.edge = t.HouseEdge(sess.Player.Name, wager.Bet) + } + if len(row.edge.Parts) <= 1 { + rows = append(rows, row) + continue + } + // Different target returns need distinct rows, not a list of unlabeled RTPs. + for _, part := range row.edge.Parts { + entry := row + if strings.Contains(wager.Bet, "<n>") { + entry.bet = strings.ReplaceAll(wager.Bet, "<n>", part.Label) + } else { + entry.win = part.Label + "; " + entry.win + } + entry.edge = casino.HouseEdge{Parts: []casino.EdgePart{part}} + rows = append(rows, entry) + } + } + return rows +} + +// Explicit list-only presentation. Wrap plain cells before applying color and +// handing them to the existing ANSI-aware table renderer; never truncate rules. +// The table stands alone: list names no heading and no balance above it. +func (g *Game) showCasinoListTable(sess *net.Session, hint string, rows []casinoListRow) { + precision, hasItem := g.casinoEdgePrecision(sess) + // The item adds a column only when at least one listed wager has a real + // analysis; decision actions such as hit/stand have none. + rtp := false + if hasItem { + for _, r := range rows { + if len(r.edge.Parts) > 0 { + rtp = true + break + } + } + } + headers := []string{"Bet", "Odds", "Win / action"} + categories := []string{"command", "casino_payout", ""} + if rtp { + headers = append(headers, "RTP") + categories = append(categories, "casino_emphasis") + } + cells := make([][]string, 0, len(rows)) + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = utf8.RuneCountInString(h) + } + for _, r := range rows { + odds := r.odds + if odds == "" { + odds = "—" + } + row := []string{r.bet, odds, r.win} + if rtp { + row = append(row, casinoRTP(r.edge, precision)) + } + for i, c := range row { + widths[i] = max(widths[i], utf8.RuneCountInString(c)) + } + cells = append(cells, row) + } + width, unicode := 80, true + if p := sess.Player; p != nil { + unicode = p.OptionBool("unicode") + if wrap := p.OptionInt("wrap_width"); wrap > 0 { + width = min(width, wrap) + } + } + budget := max(len(headers), width-3*len(headers)-1) + for { + sum, widest := 0, 0 + for i, w := range widths { + sum += w + if w > widths[widest] { + widest = i + } + } + if sum <= budget || widths[widest] <= 1 { + break + } + widths[widest]-- + } + table := ui.Table{} + // Headers wrap too, so narrow terminals never lose a column label. + all := append([][]string{headers}, cells...) + for rowIndex, row := range all { + wrapped := make([][]string, len(row)) + height := 1 + for i, c := range row { + wrapped[i] = casinoWrapWords(c, widths[i]) + height = max(height, len(wrapped[i])) + } + for line := 0; line < height; line++ { + physical := make([]string, len(row)) + for i, parts := range wrapped { + if line >= len(parts) { + continue + } + category := categories[i] + if rowIndex == 0 { + category = "casino_action" + } + physical[i] = parts[line] + if category != "" { + physical[i] = g.colorize(sess, category, physical[i]) + } + } + if rowIndex == 0 && line == 0 { + table.Columns = physical + } else { + table.Rows = append(table.Rows, physical) + } + } + } + out := table.Render(unicode, width) + if hint != "" { + out = append(out, g.colorize(sess, "command", hint)) + } + sess.WriteLine(strings.Join(out, "\n")) +} |
