aboutsummaryrefslogtreecommitdiff
path: root/internal/game/casino_house_edge.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-02 01:54:37 -0400
committerhistoria <[not public]>2026-10-02 01:54:37 -0400
commitd3a305d1d7962f82af2397760cf8008758bddd1d (patch)
treef91f0deb342c20f2993e5366f772c3432648f426 /internal/game/casino_house_edge.go
parent8eea0e4e06a4aaa6a18edabd9897d3bea1ea2ca0 (diff)
downloadthehouseoficarus-d3a305d1d7962f82af2397760cf8008758bddd1d.tar.gz
feat: better 'list' bet table, multiple bets in baccarat
Diffstat (limited to 'internal/game/casino_house_edge.go')
-rw-r--r--internal/game/casino_house_edge.go190
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"))
+}