aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/house_edge.go
blob: e175e2470aa7e85b57ad1acb13d4206b8a71c39a (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
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 p, ok := t.game.(*plinkoTable); ok {
		p.mu.Lock()
		defer p.mu.Unlock()
		if p.name == name {
			return edge(1 - plinkoReturn(p.risk, p.Config.Plinko.Normalize().RTP))
		}
	}
	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
}