aboutsummaryrefslogtreecommitdiff
path: root/internal/game/casino_house_edge.go
blob: ff3ce590365a0d1bf4d1cb7953a7f1d7959bc9fd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
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 bet, ok := expandBetNumber(wager.Bet, part.Label); ok {
				entry.bet = bet
			} else if part.Label != "" {
				// The label refines the shared win text (hop's doubles), so append
				// it: the base phrase stays "Next roll is a/b (doubles)".
				entry.win = entry.win + " " + part.Label
			}
			entry.edge = casino.HouseEdge{Parts: []casino.EdgePart{part}}
			rows = append(rows, entry)
		}
	}
	return rows
}

// expandBetNumber replaces the standalone number variable N in a grouped
// wager row ("buy N", "odds dont N") with one concrete number. It reports
// false when the row has no N token, leaving the label to refine the win text.
func expandBetNumber(bet, number string) (string, bool) {
	fields := strings.Fields(bet)
	found := false
	for i, f := range fields {
		if f == "N" {
			fields[i] = number
			found = true
		}
	}
	if !found {
		return bet, false
	}
	return strings.Join(fields, " "), true
}

// 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, and
// the first column header carries the bet syntax instead of a hint line below.
func (g *Game) showCasinoListTable(sess *net.Session, betHeader 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{betHeader, "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)
	// Size the pre-wrapped cells with the same shrink algorithm Table.Render
	// uses, instead of a second copy that can disagree on which column yields.
	ui.FitWidths(widths, budget)
	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]
				switch category {
				case "command":
					physical[i] = g.colorizeCommand(sess, physical[i])
				case "":
				default:
					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)
	sess.WriteLine(strings.Join(out, "\n"))
}