aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/house_edge.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/house_edge.go')
-rw-r--r--internal/casino/house_edge.go138
1 files changed, 138 insertions, 0 deletions
diff --git a/internal/casino/house_edge.go b/internal/casino/house_edge.go
new file mode 100644
index 0000000..01fe7aa
--- /dev/null
+++ b/internal/casino/house_edge.go
@@ -0,0 +1,138 @@
+package casino
+
+import (
+ "fmt"
+ "math"
+ "strings"
+)
+
+// HouseEdge is expected net loss / initial stake (not loss per roll, nor
+// probability of losing). Parts keep grouped targets with different edges
+// separate. Unknown analyses must never silently become a zero-percent edge.
+// Values are unrounded fractions; only the presentation rounds percentages.
+type HouseEdge struct {
+ Parts []EdgePart
+ Note string
+}
+
+type EdgePart struct {
+ Label string
+ Loss float64
+}
+
+func edge(loss float64) HouseEdge { return HouseEdge{Parts: []EdgePart{{Loss: loss}}} }
+func unknownEdge(reason string) HouseEdge { return HouseEdge{Note: "not verified: " + reason} }
+
+func (e HouseEdge) Text(precision int) string {
+ if precision != 2 {
+ precision = 0
+ }
+ var parts []string
+ for _, p := range e.Parts {
+ // Avoid displaying negative zero while preserving genuine player edges.
+ percent := math.Round(p.Loss*100*math.Pow10(precision)) / math.Pow10(precision)
+ if percent == 0 {
+ percent = 0
+ }
+ value := fmt.Sprintf("%.*f%%", precision, percent)
+ parts = append(parts, value)
+ }
+ if len(parts) == 0 {
+ return ""
+ }
+ return "House edge: " + strings.Join(parts, " / ")
+}
+
+// HouseEdge dispatches through the game's registration. A new game must
+// supply its own analysis; it never inherits another game's percentage.
+// This is an off-the-top, pre-credit-rounding reference, not a card-counting
+// aid or a prediction of the next outcome. Strategy assumptions and sources
+// are documented alongside each game's calculation; Note is diagnostic only.
+func (c TableConfig) HouseEdge(bet string) HouseEdge {
+ c = c.Normalize()
+ if def, ok := tableGameDefFor(c.Game); ok && def.houseEdge != nil {
+ return def.houseEdge(c, bet)
+ }
+ return unknownEdge("this game's rules have no house-edge analysis")
+}
+
+// HouseEdge reads seat-specific settings under the game's lock. It does not
+// alter game state, advance RNGs, or reveal cards from the current shoe.
+func (t *Table) HouseEdge(name, bet string) HouseEdge {
+ if vp, ok := t.game.(*videoPokerTable); ok {
+ vp.mu.Lock()
+ defer vp.mu.Unlock()
+ if p := vp.players[name]; p != nil {
+ return pokerHouseEdge(p.config, bet, p.loaded, p.featureOn, p.hands)
+ }
+ }
+ if pg, ok := t.game.(*paiGowTable); ok && bet == "fortune" && t.Config.PaiGow.Envy {
+ pg.mu.Lock()
+ defer pg.mu.Unlock()
+ others, stake := 0, max(1, t.Config.MinBet)
+ for _, p := range pg.players {
+ if p.name == name {
+ if p.fortune.Total > 0 {
+ stake = p.fortune.Total
+ }
+ } else if p.main.Total > 0 {
+ others++
+ }
+ }
+ e := t.Config.HouseEdge(bet)
+ e.Parts[0].Loss -= fortuneEnvyReturn(others, stake)
+ return e
+ }
+ return t.Config.HouseEdge(bet)
+}
+
+func rouletteHouseEdge(c TableConfig, bet string) HouseEdge {
+ pockets := 37.0
+ if c.Roulette.DoubleZero {
+ pockets = 38
+ }
+ // Recognize the catalogue explicitly: a new target must not accidentally
+ // inherit the standard edge (the American basket is a counterexample).
+ for _, row := range (&rouletteTable{tableCore: newTableCore[*roulettePlayer](&tableBase{Config: c})}).wagerRows() {
+ if row.Bet == bet {
+ if bet == "basket" && c.Roulette.DoubleZero {
+ return edge(3 / pockets)
+ }
+ return edge((pockets - 36) / pockets)
+ }
+ }
+ return unknownEdge("unrecognized roulette target")
+}
+
+func blackjackHouseEdge(c TableConfig, bet string) HouseEdge {
+ if bet == "insurance" {
+ // Given a dealer ace from a fresh shoe; NOT conditional on the player's
+ // visible cards or a running count. Remaining tens = 16 per deck.
+ b := c.Blackjack.Normalize()
+ e := edge(1 - float64(16*b.Decks)/float64(52*b.Decks-1)*(1+b.InsurancePayout))
+ e.Note = "fresh shoe, dealer ace only; visible cards change this"
+ return e
+ }
+ switch bet {
+ case "main":
+ return edge(blackjackMainEdge(c.Blackjack))
+ case "hit", "stand", "decline", "double", "split":
+ return HouseEdge{Note: "not a separate wager; decision EV depends on the hand"}
+ case "surrender":
+ return HouseEdge{Note: "returns half the original stake; not a new wager"}
+ }
+ return unknownEdge("unrecognized blackjack wager")
+}
+
+func (c MachineConfig) HouseEdge() HouseEdge {
+ c = c.Normalize()
+ if c.Game != GameSlots {
+ return unknownEdge("unrecognized machine game")
+ }
+ if _, ok := slotVariants[c.Variant]; !ok {
+ return unknownEdge("unrecognized slot profile")
+ }
+ e := edge(1 - CalculateRTP(machineSlotProfile(c)))
+ e.Note = "includes free spins; before residual fractional-credit loss on leaving"
+ return e
+}