aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/baccarat.go363
-rw-r--r--internal/casino/baccarat_multi_wager_test.go118
-rw-r--r--internal/casino/baccarat_rules.go2
-rw-r--r--internal/casino/baccarat_variants_test.go5
-rw-r--r--internal/casino/baccarat_wagers.go6
-rw-r--r--internal/casino/house_edge.go138
-rw-r--r--internal/casino/house_edge_baccarat.go131
-rw-r--r--internal/casino/house_edge_blackjack.go182
-rw-r--r--internal/casino/house_edge_craps.go136
-rw-r--r--internal/casino/house_edge_fire.go61
-rw-r--r--internal/casino/house_edge_pai_gow.go55
-rw-r--r--internal/casino/house_edge_rules_test.go148
-rw-r--r--internal/casino/house_edge_test.go127
-rw-r--r--internal/casino/house_edge_video_poker.go82
-rw-r--r--internal/casino/manager.go9
-rw-r--r--internal/casino/registry.go11
-rw-r--r--internal/casino/table.go10
-rw-r--r--internal/casino/table_hints.go12
-rw-r--r--internal/casino/table_ux.go7
-rw-r--r--internal/casino/table_ux_test.go2
-rw-r--r--internal/game/casino.go6
-rw-r--r--internal/game/casino_edge_list_test.go97
-rw-r--r--internal/game/casino_house_edge.go190
-rw-r--r--internal/game/casino_rules_list_test.go84
-rw-r--r--internal/game/casino_ux.go41
-rw-r--r--internal/game/house_edge_test.go214
-rw-r--r--internal/game/render_baccarat_felt.go2
-rw-r--r--internal/game/render_baccarat_felt_test.go2
-rw-r--r--internal/game/video_poker_test.go2
-rw-r--r--internal/item/house_edge_test.go29
-rw-r--r--internal/item/item.go4
31 files changed, 2172 insertions, 104 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index 1e7ab23..e30de5b 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -21,15 +21,75 @@ const (
type baccaratPlayer struct {
Participant
- lastBet int
- lastSpot string
- // stakeID is the wallet handle of the wager record committed this
- // round; the settlement returns it inside the payout.
- stakeID string
+ // mainSpot is the spot of the committed main wager (player, banker or
+ // tie). Once the round ends it is what a bare "bet" repeats.
+ mainSpot string
+ // sides holds this round's optional side-bet stakes by spot.
+ sides map[string]Wager
+ // lastBets remembers the last stake placed on each spot so a bare "bet"
+ // can repeat the side bets and the main wager together.
+ lastBets map[string]int
}
func (p *baccaratPlayer) participantPtr() *Participant { return &p.Participant }
+// totalStaked is every credit this seat has committed to the current round.
+func (p *baccaratPlayer) totalStaked() int {
+ total := 0
+ if p.HasBet {
+ total += p.Wager.Total
+ }
+ for _, w := range p.sides {
+ total += w.Total
+ }
+ return total
+}
+
+// sideSpots lists committed side spots in the table's own order so menus and
+// settlements never depend on map iteration.
+func (p *baccaratPlayer) sideSpots(rules BaccaratConfig) []string {
+ var spots []string
+ for _, spot := range rules.spots() {
+ if baccaratIsMainSpot(spot) || p.sides[spot].Total == 0 {
+ continue
+ }
+ spots = append(spots, spot)
+ }
+ return spots
+}
+
+// wagers lists the main wager first, then every side bet, as one settlement
+// and snapshot unit each.
+func (p *baccaratPlayer) wagers(rules BaccaratConfig) []baccaratWager {
+ var out []baccaratWager
+ if p.HasBet {
+ out = append(out, baccaratWager{spot: p.mainSpot, wager: p.Wager})
+ }
+ for _, spot := range p.sideSpots(rules) {
+ out = append(out, baccaratWager{spot: spot, wager: p.sides[spot]})
+ }
+ return out
+}
+
+func (p *baccaratPlayer) hasLastBet() bool {
+ for _, amount := range p.lastBets {
+ if amount > 0 {
+ return true
+ }
+ }
+ return false
+}
+
+// baccaratWager pairs a committed stake with the spot it rides on.
+type baccaratWager struct {
+ spot string
+ wager Wager
+}
+
+func baccaratIsMainSpot(spot string) bool {
+ return spot == baccaratSpotPlayer || spot == baccaratSpotBanker || spot == baccaratSpotTie
+}
+
type baccaratTable struct {
tableCore[*baccaratPlayer]
Rules BaccaratConfig
@@ -47,18 +107,26 @@ func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTabl
}
func (t *baccaratTable) menuEvent(name string) Event {
- if p := t.players.get(name); p != nil && p.HasBet {
+ p := t.players.get(name)
+ if p != nil && p.HasBet {
return t.committedMenu(name)
}
commands := []MenuCommand{}
- if p := t.players.get(name); p != nil && p.lastBet > 0 {
- commands = append(commands, MenuCommand{Command: "bet", Desc: fmt.Sprintf("Repeat %d on %s (also: rebet)", p.lastBet, p.lastSpot)}, MenuCommand{Command: "bet <amount>", Desc: "Change amount; keep " + p.lastSpot})
+ if p != nil && p.hasLastBet() {
+ commands = append(commands, MenuCommand{Command: "bet", Desc: "Repeat your last bets"})
+ }
+ if p != nil && len(p.sides) > 0 {
+ commands = append(commands, MenuCommand{Command: "unbet", Desc: "Take back your side bets"})
}
- commands = append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet), Desc: "Choose banker (b), player (p), or tie (t)"}, MenuCommand{Command: "bet <amount> on <target>", Desc: "One wager per round; targets: list"}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"})
+ commands = append(commands,
+ MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet)},
+ MenuCommand{Command: "bet <amount> on <bet>"},
+ MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"},
+ MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"})
commands = withTableHints(commands, t.wagerRows())
e := t.betMenuEvent(name, commands)
- if p := t.players.get(name); p != nil {
- e.Menu.Repeat = p.lastBet > 0
+ if p != nil {
+ e.Menu.Repeat = p.hasLastBet()
}
return e
}
@@ -90,14 +158,19 @@ func (t *baccaratTable) leave(name string) []Event {
}
// A wager is committed for the round in flight; everyone else may stand
// up whenever they like.
- if p.HasBet {
+ if p.totalStaked() > 0 {
return []Event{t.private(name, "You're in the middle of a baccarat round!")}
}
+ var events []Event
+ events = append(events, t.refundSidesLocked(p)...)
t.removeSeatLocked(name)
- events := []Event{t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up.")}
+ events = append(events, t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up."))
return t.closeIfEmptyLocked(events)
}
+// bet commits one wager: a main bet (player, banker or tie) or a side bet.
+// Side bets may only be placed before the main wager, so the main bet remains
+// the single trigger that ends the betting window and deals the cards.
func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Event {
t.mu.Lock()
defer t.mu.Unlock()
@@ -106,7 +179,7 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even
return []Event{t.private(name, "You aren't playing baccarat.")}
}
if spot == "" {
- spot = p.lastSpot
+ spot = p.mainSpot
}
if spot == "" {
return []Event{t.private(name, "Bet on what? Try bet <amount> on "+t.Rules.betChoices()+".")}
@@ -114,21 +187,61 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even
if !t.Rules.allowsSpot(spot) {
return []Event{t.private(name, "Baccarat bets must be on player, banker, or tie, or an enabled optional bet. Available: "+t.Rules.betChoices()+".")}
}
- if p.HasBet {
- return []Event{t.private(name, "You have already bet this round.")}
+ events, ok := t.placeBetLocked(p, spot, amount, wallet)
+ if !ok {
+ return events
+ }
+ return t.finishBetLocked(events)
+}
+
+// placeBetLocked validates and commits a single wager without deciding
+// whether the round should start. It reports false with a refusal event when
+// the wager is not allowed.
+func (t *baccaratTable) placeBetLocked(p *baccaratPlayer, spot string, amount int, wallet Wallet) ([]Event, bool) {
+ isMain := baccaratIsMainSpot(spot)
+ if isMain && p.HasBet {
+ return []Event{t.private(p.Name, "You have already bet a main wager this round.")}, false
+ }
+ if !isMain {
+ if p.sides[spot].Total > 0 {
+ return []Event{t.private(p.Name, "You have already bet that side wager this round.")}, false
+ }
+ if p.HasBet {
+ return []Event{t.private(p.Name, "Place side bets before your main wager.")}, false
+ }
}
if amount < t.Config.MinBet || amount > t.Config.MaxBet {
- return []Event{t.private(name, fmt.Sprintf("Baccarat bets must be %s.", betRangeText(t.Config.MinBet, t.Config.MaxBet)))}
+ return []Event{t.private(p.Name, fmt.Sprintf("Baccarat bets must be %s.", betRangeText(t.Config.MinBet, t.Config.MaxBet)))}, false
}
- wager, err := wallet.Wager(amount)
+ if wallet == nil {
+ return []Event{t.private(p.Name, "Wallet unavailable.")}, false
+ }
+ stake, err := wallet.Wager(amount)
if err != nil {
- return []Event{t.private(name, walletErrorMessage(err))}
+ return []Event{t.private(p.Name, walletErrorMessage(err))}, false
}
+ p.Wallet = wallet
p.SittingOut = false
- p.HasBet, p.Wager, p.Wallet, p.lastBet, p.lastSpot, p.stakeID = true, wager, wallet, amount, spot, wager.ID
- events := []Event{t.playerAction(name,
- fmt.Sprintf("%s bets %d credits on %s.", name, amount, spot),
- fmt.Sprintf("You bet %d credits on %s.", amount, spot))}
+ if p.lastBets == nil {
+ p.lastBets = map[string]int{}
+ }
+ p.lastBets[spot] = amount
+ if isMain {
+ p.HasBet, p.Wager, p.mainSpot = true, stake, spot
+ } else {
+ if p.sides == nil {
+ p.sides = map[string]Wager{}
+ }
+ p.sides[spot] = stake
+ }
+ return []Event{t.playerAction(p.Name,
+ fmt.Sprintf("%s bets %d credits on %s.", p.Name, amount, spot),
+ fmt.Sprintf("You bet %d credits on %s.", amount, spot))}, true
+}
+
+// finishBetLocked opens the shared betting window and deals the moment every
+// active seat has a main wager. Side bets on their own never deal.
+func (t *baccaratTable) finishBetLocked(events []Event) []Event {
events = t.openBettingLocked(events)
if allBet(t.players.participants()) {
events = append(events, t.deal()...)
@@ -136,23 +249,95 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even
return events
}
+// rebetAll repeats every remembered side bet and then the main wager in one
+// command. It is the baccarat meaning of a bare "bet".
+func (t *baccaratTable) rebetAll(name string, wallet Wallet) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.players.get(name)
+ if p == nil {
+ return []Event{t.private(name, "You aren't playing baccarat.")}
+ }
+ if p.HasBet {
+ return []Event{t.private(name, "You have already bet a main wager this round.")}
+ }
+ if !p.hasLastBet() {
+ return []Event{t.private(name, "Bet on what? Try bet <amount> on "+t.Rules.betChoices()+".")}
+ }
+ var events []Event
+ for _, spot := range t.Rules.spots() {
+ if baccaratIsMainSpot(spot) {
+ continue
+ }
+ if amount := p.lastBets[spot]; amount > 0 {
+ ev, ok := t.placeBetLocked(p, spot, amount, wallet)
+ events = append(events, ev...)
+ if !ok {
+ return events
+ }
+ }
+ }
+ if p.mainSpot != "" {
+ if amount := p.lastBets[p.mainSpot]; amount > 0 {
+ ev, ok := t.placeBetLocked(p, p.mainSpot, amount, wallet)
+ events = append(events, ev...)
+ if !ok {
+ return events
+ }
+ }
+ }
+ return t.finishBetLocked(events)
+}
+
func (t *baccaratTable) tick() []Event {
t.mu.Lock()
defer t.mu.Unlock()
if t.phase != baccaratBetting {
return nil
}
- return t.advanceBettingLocked(t.deal, t.resetRound)
+ sitOuts, goRound, pending := t.window.advance(t.players.participants())
+ if pending {
+ if !t.window.timer.warn() {
+ return nil
+ }
+ var warnings []Event
+ for _, p := range t.players.participants() {
+ if p.Connected && !p.HasBet && !p.SittingOut {
+ warnings = append(warnings, t.warning(p.Name, fmt.Sprintf("Betting closes in %d ticks. Bet now or sit out this hand.", t.window.timer.remaining())))
+ }
+ }
+ return warnings
+ }
+ var events []Event
+ // A seat with side bets but no main wager is not in the round; return the
+ // side stakes rather than letting them ride on a hand their owner skipped.
+ for _, p := range t.players.ordered() {
+ if !p.HasBet && len(p.sides) > 0 {
+ events = append(events, t.refundSidesLocked(p)...)
+ }
+ }
+ for _, name := range sitOuts {
+ events = append(events, t.event(fmt.Sprintf("%s sits out this baccarat round.", name), true))
+ }
+ if goRound {
+ return append(events, t.deal()...)
+ }
+ t.resetRound()
+ return events
}
func (t *baccaratTable) action(name, action string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
if action == "unbet" {
- events, _ := t.unbetLocked(name)
- return events
+ return t.unbetAllLocked(name)
}
if events, handled := t.controlLocked(name, action); handled {
+ // A seat that sits out or leaves before committing a main wager is not
+ // in the round; return any side stakes it already placed.
+ if p := t.players.get(name); p != nil && !p.HasBet && len(p.sides) > 0 {
+ events = append(events, t.refundSidesLocked(p)...)
+ }
if t.phase == baccaratBetting && allBet(t.players.participants()) {
return append(events, t.deal()...)
}
@@ -161,11 +346,76 @@ func (t *baccaratTable) action(name, action string) []Event {
return []Event{t.private(name, "That game has no player actions.")}
}
+// unbetAllLocked returns every stake the seat has committed this round, main
+// wager and side bets alike.
+func (t *baccaratTable) unbetAllLocked(name string) []Event {
+ p := t.players.get(name)
+ if p == nil {
+ return []Event{t.private(name, "You aren't seated at this table.")}
+ }
+ if t.phase != baccaratWaiting && t.phase != baccaratBetting {
+ return []Event{t.private(name, "You can only take back bets before the round starts.")}
+ }
+ total := 0
+ var ids []string
+ if p.HasBet {
+ total += p.Wager.Total
+ if p.Wager.ID != "" {
+ ids = append(ids, p.Wager.ID)
+ }
+ }
+ for _, w := range p.sides {
+ total += w.Total
+ if w.ID != "" {
+ ids = append(ids, w.ID)
+ }
+ }
+ if total == 0 {
+ return []Event{t.private(name, "Nothing to take back.")}
+ }
+ if p.Wallet != nil {
+ if err := p.Wallet.PayCredits(total, ids...); err != nil {
+ return []Event{t.private(name, payoutFailedMessage())}
+ }
+ }
+ p.HasBet, p.Wager = false, Wager{}
+ p.sides = nil
+ return []Event{t.private(name, fmt.Sprintf("You take back %d credits.", total))}
+}
+
+// refundSidesLocked returns every side stake on a seat that never committed a
+// main wager.
+func (t *baccaratTable) refundSidesLocked(p *baccaratPlayer) []Event {
+ total := 0
+ var ids []string
+ for _, spot := range p.sideSpots(t.Rules) {
+ w := p.sides[spot]
+ total += w.Total
+ if w.ID != "" {
+ ids = append(ids, w.ID)
+ }
+ }
+ if total == 0 {
+ return nil
+ }
+ if p.Wallet != nil {
+ if err := p.Wallet.PayCredits(total, ids...); err != nil {
+ return []Event{t.private(p.Name, "Your side bets could not be returned just now; the stake stays pending and will be refunded after the next server restart.")}
+ }
+ }
+ p.sides = nil
+ return []Event{t.private(p.Name, fmt.Sprintf("Your %d in side bets is returned; no main wager was placed.", total))}
+}
+
func (t *baccaratTable) rebet(name string) (string, int) {
t.mu.Lock()
defer t.mu.Unlock()
- if p := t.players.get(name); p != nil && p.lastBet > 0 {
- return p.lastSpot, p.lastBet
+ p := t.players.get(name)
+ if p == nil {
+ return "", t.Config.MinBet
+ }
+ if p.mainSpot != "" && p.lastBets[p.mainSpot] > 0 {
+ return p.mainSpot, p.lastBets[p.mainSpot]
}
return "", t.Config.MinBet
}
@@ -192,17 +442,33 @@ func (t *baccaratTable) deal() []Event {
if !p.HasBet {
continue
}
- result, payout, settleErr := settleBaccaratHand(p.lastSpot, p.Wager.Total, player, banker, t.Rules)
+ wagers := p.wagers(t.Rules)
+ lines := make([]settlementLine, 0, len(wagers))
+ var ids []string
+ totalStake, totalReturn := 0, 0
+ var payoutErr error
+ for _, w := range wagers {
+ result, payout, err := settleBaccaratHand(w.spot, w.wager.Total, player, banker, t.Rules)
+ if err != nil && payoutErr == nil {
+ payoutErr = err
+ }
+ if w.wager.ID != "" {
+ ids = append(ids, w.wager.ID)
+ }
+ totalStake += w.wager.Total
+ totalReturn += payout
+ line := settlementLine{Label: w.spot, Odds: baccaratSettlementOdds(w.spot, player, banker, t.Rules), Result: result, Stake: w.wager.Total, Returned: payout}
+ if result == "win" && w.spot == baccaratSpotTie {
+ line.Tag = "casino_jackpot_win"
+ }
+ lines = append(lines, line)
+ }
// The shared settlement path pays before announcing and leaves the
- // stake pending on any failure, so a refund can never be paid on top
+ // stakes pending on any failure, so a refund can never be paid on top
// of a payout that was not durable.
- payoutFailed := settleStakeOutcome(p.Wallet, payout, []string{p.stakeID}, settleErr)
- publicResult, _ := baccaratResultMessages(p.Name, result, payout)
- line := settlementLine{Label: p.lastSpot, Odds: baccaratSettlementOdds(p.lastSpot, player, banker, t.Rules), Result: result, Stake: p.Wager.Total, Returned: payout}
- if result == "win" && p.lastSpot == baccaratSpotTie {
- line.Tag = "casino_jackpot_win"
- }
- privateResult := settlementText([]settlementLine{line})
+ payoutFailed := settleStakeOutcome(p.Wallet, totalReturn, ids, payoutErr)
+ publicResult, _ := baccaratResultMessages(p.Name, returnResult(totalStake, totalReturn), totalReturn)
+ privateResult := settlementText(lines)
if payoutFailed {
privateResult = payoutFailedMessage()
publicResult = p.Name + "’s settlement is pending recovery."
@@ -212,21 +478,21 @@ func (t *baccaratTable) deal() []Event {
// departures/sit-outs need it attached to the settlement.
settlement.ShowBalance = !payoutFailed && (p.LeaveAfter || p.SitOutAfter || !p.Connected)
if !payoutFailed {
- settlement.Settlement = []SettlementLine{line}
+ settlement.Settlement = lines
switch {
- case line.Tag != "":
- settlement.Color = line.Tag
- case result == "win":
+ case len(lines) == 1 && lines[0].Tag != "":
+ settlement.Color = lines[0].Tag
+ case totalReturn > totalStake:
settlement.Color = "casino_result_win"
- case result == "push":
+ case totalReturn == totalStake:
settlement.Color = "casino_result_push"
default:
settlement.Color = "casino_result_loss"
}
}
events = append(events, settlement)
- // settleStakeOutcome already consumed this stake by handle (a payout
- // removed it, a push/loss resolved it). Deliberately do NOT call the
+ // settleStakeOutcome already consumed these stakes by handle (a payout
+ // removed them, a push/loss resolved them). Deliberately do NOT call the
// player-wide ResolveStakes here: it would also consume a stake left
// pending by an earlier failed payout, which must stay refundable.
}
@@ -252,10 +518,9 @@ func (t *baccaratTable) snapshot(player, banker []card) *BaccaratSnapshot {
snapshot.Banker = append(snapshot.Banker, CardView{Rank: c.rank, Suit: c.suit})
}
for _, p := range t.players.ordered() {
- if !p.HasBet {
- continue
+ for _, w := range p.wagers(t.Rules) {
+ snapshot.Bets = append(snapshot.Bets, BaccaratBetView{Name: p.Name, Spot: w.spot, Amount: w.wager.Total})
}
- snapshot.Bets = append(snapshot.Bets, BaccaratBetView{Name: p.Name, Spot: p.lastSpot, Amount: p.Wager.Total})
}
return snapshot
}
@@ -265,7 +530,7 @@ func (t *baccaratTable) resetRound() {
t.window.close()
clearBets(t.players.participants())
for _, p := range t.players.ordered() {
- p.stakeID = ""
+ p.sides = nil
}
}
@@ -281,7 +546,7 @@ func (t *baccaratTable) markDisconnected(name string) []Event {
if !t.players.has(name) {
return nil
}
- if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil {
+ if p := t.players.get(name); p != nil && p.totalStaked() > 0 && p.Wallet != nil {
if _, _, err := p.Wallet.RefundOutstanding(); err != nil {
// The stake stays pending on disk and startup recovery will return
// it, but tell the departing player instead of silently swallowing
diff --git a/internal/casino/baccarat_multi_wager_test.go b/internal/casino/baccarat_multi_wager_test.go
new file mode 100644
index 0000000..2a56e63
--- /dev/null
+++ b/internal/casino/baccarat_multi_wager_test.go
@@ -0,0 +1,118 @@
+package casino
+
+import "testing"
+
+// TestBaccaratSideBetsRideWithMain pins the multi-wager contract: a side bet
+// is committed before the main wager, both settle in the same round, and only
+// the main wager deals the cards.
+func TestBaccaratSideBetsRideWithMain(t *testing.T) {
+ cfg := TableConfig{ID: "baccarat", Game: GameBaccarat, Baccarat: BaccaratConfig{PairBets: true}}
+ table := newBaccaratTestTable(cfg)
+ table.join("alice")
+ forceShoe(table, baccaratCards("5 5 5 5 5 5")...)
+ w := &testWallet{credits: 1000}
+ if events := table.bet("alice", "player_pair", 10, w); hasMessage(events, "New baccarat hand!") {
+ t.Fatal("side bet alone dealt a round")
+ }
+ if table.players.get("alice").HasBet {
+ t.Fatal("side bet counted as the round's main wager")
+ }
+ events := table.bet("alice", "player", 10, w)
+ if !hasMessage(events, "New baccarat hand!") {
+ t.Fatalf("main wager did not deal: %+v", events)
+ }
+ // Player and banker tie at 5; the player main pushes and the player pair wins 11:1.
+ if w.paid != 130 {
+ t.Fatalf("paid=%d, want 130", w.paid)
+ }
+ var labels []string
+ for _, e := range events {
+ for _, line := range e.Settlement {
+ labels = append(labels, line.Label)
+ }
+ }
+ if len(labels) != 2 || labels[0] != "player" || labels[1] != "player_pair" {
+ t.Fatalf("settlement labels=%v, want [player player_pair]", labels)
+ }
+ // The snapshot carries both bets for the table view.
+ var snapshot *BaccaratSnapshot
+ for _, e := range events {
+ if s, ok := e.Snapshot.(*BaccaratSnapshot); ok && len(s.Bets) > 0 {
+ snapshot = s
+ }
+ }
+ if snapshot == nil || len(snapshot.Bets) != 2 ||
+ snapshot.Bets[0].Spot != "player" || snapshot.Bets[0].Amount != 10 ||
+ snapshot.Bets[1].Spot != "player_pair" || snapshot.Bets[1].Amount != 10 {
+ t.Fatalf("snapshot bets=%+v", snapshot)
+ }
+}
+
+// A side wager may not follow the main wager: the main is the deal trigger.
+func TestBaccaratSideBetAfterMainRefused(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, Baccarat: BaccaratConfig{PairBets: true}, MaxPlayers: 2})
+ table.join("alice")
+ table.join("bob")
+ w := &testWallet{credits: 1000}
+ table.bet("alice", "player", 10, w)
+ if events := table.bet("alice", "player_pair", 10, w); !hasMessage(events, "before your main wager") {
+ t.Fatalf("side bet after main accepted: %+v", events)
+ }
+}
+
+// A bare "bet" repeats the remembered side bets and the main wager together.
+func TestBaccaratRebetAll(t *testing.T) {
+ cfg := TableConfig{ID: "baccarat", Game: GameBaccarat, Baccarat: BaccaratConfig{PairBets: true}}
+ table := newBaccaratTestTable(cfg)
+ table.join("alice")
+ forceShoe(table, baccaratCards("5 5 5 5 5 5")...)
+ table.bet("alice", "player_pair", 10, &testWallet{credits: 1000})
+ table.bet("alice", "player", 10, &testWallet{credits: 1000})
+ forceShoe(table, baccaratCards("5 5 5 5 5 5")...)
+ w := &testWallet{credits: 1000}
+ events := table.rebetAll("alice", w)
+ if !hasMessage(events, "New baccarat hand!") || w.paid != 130 {
+ t.Fatalf("rebet all did not replay both wagers: paid=%d events=%+v", w.paid, events)
+ }
+}
+
+// unbet returns the main wager and every side bet in one step.
+func TestBaccaratUnbetReturnsAllWagers(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, Baccarat: BaccaratConfig{PairBets: true}})
+ table.join("alice")
+ w := &testWallet{credits: 1000}
+ table.bet("alice", "player_pair", 10, w)
+ table.bet("alice", "banker_pair", 15, w)
+ if events := table.unbetAllLocked("alice"); !hasMessage(events, "take back 25") {
+ t.Fatalf("unbet did not return both side stakes: %+v", events)
+ }
+ if w.paid != 25 || len(w.open) != 0 {
+ t.Fatalf("paid=%d open=%d, want 25/0", w.paid, len(w.open))
+ }
+}
+
+// A side-only seat is not part of the round: the timer returns the side stake
+// and the other seat's main wager still deals.
+func TestBaccaratSideOnlySeatRefundedOnTimeout(t *testing.T) {
+ cfg := TableConfig{ID: "baccarat", Game: GameBaccarat, Baccarat: BaccaratConfig{PairBets: true}, MaxPlayers: 2, BettingTicks: 2}
+ table := newBaccaratTestTable(cfg)
+ table.join("alice")
+ table.join("bob")
+ wa, wb := &testWallet{credits: 1000}, &testWallet{credits: 1000}
+ table.bet("alice", "player_pair", 10, wa)
+ table.bet("bob", "player", 10, wb)
+ forceShoe(table, baccaratCards("5 5 5 5 5 5")...)
+ var events []Event
+ for i := 0; i < 4 && !hasMessage(events, "New baccarat hand!"); i++ {
+ events = append(events, table.tick()...)
+ }
+ if !hasMessage(events, "New baccarat hand!") {
+ t.Fatalf("side-only seat held the round open: %+v", events)
+ }
+ if wa.paid != 10 {
+ t.Fatalf("alice side stake not returned: paid=%d", wa.paid)
+ }
+ if !hasMessage(events, "side bets is returned") {
+ t.Fatalf("missing side-bet refund notice: %+v", events)
+ }
+}
diff --git a/internal/casino/baccarat_rules.go b/internal/casino/baccarat_rules.go
index 82cc294..7b11a92 100644
--- a/internal/casino/baccarat_rules.go
+++ b/internal/casino/baccarat_rules.go
@@ -46,7 +46,7 @@ func (c TableConfig) BaccaratRulesText() string {
"Banker, if Player stands: Draw on 0–5, stand on 6–7",
"Banker, if Player draws: 0–2 draw; 3 draws except against 8; 4 against 2–7; 5 against 4–7; 6 against 6–7; 7 stands",
"Fractional credits: Rounded down; any commission rounds up and is collected on each win",
- "Wagers: One per player per round, including optional bets; no simultaneous main/optional wagers",
+ "Wagers: One main bet (player, banker or tie) plus any enabled side bets; side bets are placed before the main wager",
"Available bets: " + b.betChoices(),
"Bet limits (all wagers): " + betRangeText(c.MinBet, c.MaxBet) + " credits",
fmt.Sprintf("Seats: %d; betting window: %d ticks", c.MaxPlayers, c.BettingTicks),
diff --git a/internal/casino/baccarat_variants_test.go b/internal/casino/baccarat_variants_test.go
index 094412e..fd84c25 100644
--- a/internal/casino/baccarat_variants_test.go
+++ b/internal/casino/baccarat_variants_test.go
@@ -105,7 +105,8 @@ func TestBaccaratEZLiveSettlementAndOptionalMenus(t *testing.T) {
forceShoe(table, baccaratCards("A A A A 2 5")...)
wa, wb := &testWallet{credits: 100}, &testWallet{credits: 100}
table.bet("alice", "banker", 10, wa)
- events = table.bet("bob", "dragon_7", 10, wb)
+ table.bet("bob", "dragon_7", 10, wb)
+ events = table.bet("bob", "player", 10, wb)
if wa.paid != 10 || wb.paid != 410 {
t.Fatalf("banker=%d dragon=%d", wa.paid, wb.paid)
}
@@ -125,7 +126,7 @@ func TestBaccaratRuleCard(t *testing.T) {
for _, variant := range []string{BaccaratCommission, BaccaratSuper6, BaccaratEZ} {
cfg := TableConfig{ID: "test", Game: GameBaccarat, Baccarat: BaccaratConfig{Variant: variant, Decks: 6, TiePayout: 9}}
text := cfg.BaccaratRulesText()
- for _, want := range []string{"Number of decks: 6", "Tie pays 9:1", "Player pair (player_pair): Not offered", "Dragon 7 (dragon_7): Not offered", "Panda 8 (panda_8): Not offered", "Super 6 bet (super_6): Not offered", "One per player per round"} {
+ for _, want := range []string{"Number of decks: 6", "Tie pays 9:1", "Player pair (player_pair): Not offered", "Dragon 7 (dragon_7): Not offered", "Panda 8 (panda_8): Not offered", "Super 6 bet (super_6): Not offered", "One main bet (player, banker or tie)"} {
if !strings.Contains(text, want) {
t.Errorf("missing %q: %s", want, text)
}
diff --git a/internal/casino/baccarat_wagers.go b/internal/casino/baccarat_wagers.go
index cf2653e..d457cdc 100644
--- a/internal/casino/baccarat_wagers.go
+++ b/internal/casino/baccarat_wagers.go
@@ -16,9 +16,9 @@ func (t *baccaratTable) wagerRows() []WagerRow {
banker = "1:1 (3-card 7 pushes)"
}
rows := []WagerRow{
- {"player (p)", "1:1", "Player's last digit beats Banker's"},
- {"banker (b)", banker, "Banker's last digit beats Player's"},
- {"tie (t)", fmt.Sprintf("%g:1", rules.TiePayout), "Both hands finish equal"},
+ {"player", "1:1", "Player's last digit beats Banker's"},
+ {"banker", banker, "Banker's last digit beats Player's"},
+ {"tie", fmt.Sprintf("%g:1", rules.TiePayout), "Both hands finish equal"},
}
if rules.PairBets {
rows = append(rows,
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
+}
diff --git a/internal/casino/house_edge_baccarat.go b/internal/casino/house_edge_baccarat.go
new file mode 100644
index 0000000..1598fd6
--- /dev/null
+++ b/internal/casino/house_edge_baccarat.go
@@ -0,0 +1,131 @@
+package casino
+
+import (
+ "strings"
+ "sync"
+)
+
+type baccaratProbabilities struct {
+ player, banker, tie, dragon, panda, six float64
+}
+
+var baccaratEdgeCache sync.Map // deck count -> baccaratProbabilities
+
+func baccaratHouseEdge(cfg TableConfig, bet string) HouseEdge {
+ c := cfg.Baccarat.Normalize()
+ spot := strings.Fields(bet)
+ if len(spot) == 0 {
+ return unknownEdge("unrecognized baccarat target")
+ }
+ p := baccaratFreshProbabilities(c.Decks)
+ var e HouseEdge
+ switch spot[0] {
+ case "player":
+ e = edge(p.banker - p.player)
+ case "banker":
+ loss := p.player - c.BankerPayout*p.banker
+ if c.Variant == BaccaratSuper6 {
+ loss += .5 * p.six
+ }
+ if c.Variant == BaccaratEZ {
+ loss += p.dragon
+ }
+ e = edge(loss)
+ case "tie":
+ e = edge(1 - (1+c.TiePayout)*p.tie)
+ case "player_pair", "banker_pair":
+ e = edge(1 - 12*float64(4*c.Decks-1)/float64(52*c.Decks-1))
+ case "dragon_7":
+ e = edge(1 - 41*p.dragon)
+ case "panda_8":
+ e = edge(1 - 26*p.panda)
+ case "super_6":
+ e = edge(1 - 13*p.six)
+ default:
+ return unknownEdge("unrecognized baccarat target")
+ }
+ e.Note = "fresh shoe; before fractional-credit rounding"
+ return e
+}
+
+// Enumerate value sequences WITHOUT replacement from the configured shoe.
+// Tens/J/Q/K coalesce into value zero; rank-pair probability is separately
+// exact by symmetry. At most 10^6 branches, cached by deck count. Uses the
+// same banker tableau as playTableau; no sampled or generic casino rates.
+func baccaratFreshProbabilities(decks int) baccaratProbabilities {
+ if v, ok := baccaratEdgeCache.Load(decks); ok {
+ return v.(baccaratProbabilities)
+ }
+ var out baccaratProbabilities
+ counts := [10]int{}
+ for i := range counts {
+ counts[i] = 4 * decks
+ }
+ counts[0] = 16 * decks
+ var cards [6]int
+ var deal func(int, float64)
+ finish := func(pt, bt, pn, bn int, prob float64) {
+ switch {
+ case pt > bt:
+ out.player += prob
+ if pn == 3 && pt == 8 {
+ out.panda += prob
+ }
+ case bt > pt:
+ out.banker += prob
+ if bn == 3 && bt == 7 {
+ out.dragon += prob
+ }
+ if bt == 6 {
+ out.six += prob
+ }
+ default:
+ out.tie += prob
+ }
+ }
+ deal = func(n int, prob float64) {
+ if n >= 4 {
+ pt, bt := (cards[0]+cards[1])%10, (cards[2]+cards[3])%10
+ if pt >= 8 || bt >= 8 {
+ finish(pt, bt, 2, 2, prob)
+ return
+ }
+ playerDraw := pt <= 5
+ if playerDraw && n == 4 { // draw Player's third below
+ } else {
+ third, pn := -1, 2
+ if playerDraw {
+ third = cards[4]
+ pt = (pt + third) % 10
+ pn = 3
+ }
+ bankerDraw := (third < 0 && bt <= 5) || (third >= 0 && bankerDraws(bt, third))
+ if !bankerDraw {
+ finish(pt, bt, pn, 2, prob)
+ return
+ }
+ needed := 5
+ if playerDraw {
+ needed = 6
+ }
+ if n == needed {
+ finish(pt, (bt+cards[n-1])%10, pn, 3, prob)
+ return
+ }
+ }
+ }
+ remaining := float64(52*decks - n)
+ for value, count := range counts {
+ if count == 0 {
+ continue
+ }
+ counts[value]--
+ cards[n] = value
+ deal(n+1, prob*float64(count)/remaining)
+ counts[value]++
+ }
+ }
+ deal(0, 1)
+ baccaratEdgeCache.Store(decks, out)
+ return out
+}
diff --git a/internal/casino/house_edge_blackjack.go b/internal/casino/house_edge_blackjack.go
new file mode 100644
index 0000000..884cf75
--- /dev/null
+++ b/internal/casino/house_edge_blackjack.go
@@ -0,0 +1,182 @@
+package casino
+
+import "math"
+
+// A small infinite-shoe decision model supplies the effect of payout, H17,
+// DAS and the game's unusual doubled-split-21 rule. Deck count and resplitting
+// use the published basic-strategy rule adjustments at:
+// https://wizardofodds.com/games/blackjack/rule-variations/
+// This is a reference estimate, not a finite-shoe/composition strategy solver.
+func blackjackMainEdge(r BlackjackConfig) float64 {
+ r = r.Normalize()
+ base := BlackjackConfig{}.Normalize()
+ no := false
+ base.DoubleSplitBlackjack = &no
+ loss := .0043 - (blackjackReferenceEV(r) - blackjackReferenceEV(base))
+ // Improvements relative to eight decks; interpolate uncommon deck counts.
+ decks := []float64{0, .0048, .0019, .00125, .0006, .0003, .0002, .0001, 0}
+ if r.Decks < len(decks) {
+ loss -= decks[r.Decks]
+ } else {
+ loss += .0006 * (1 - 8/float64(r.Decks))
+ }
+ if r.MaxSplitHands == 2 {
+ loss += .001
+ } else if r.MaxSplitHands == 3 {
+ loss += .0001
+ }
+ if r.MaxSplitHands > 1 && r.AllowsResplitAces() {
+ loss -= .0008
+ }
+ return loss
+}
+
+// The split model allows one split; published adjustments above account for
+// the configured resplit limit. Unlike-ten splitting does not change normal
+// optimal play (tens are stood on). Shuffle penetration is not an off-top rule.
+func blackjackReferenceEV(r BlackjackConfig) float64 {
+ probability := func(v int) float64 {
+ if v == 10 {
+ return 4.0 / 13
+ }
+ return 1.0 / 13
+ }
+ add := func(total, soft, v int) (int, int) {
+ if v == 1 {
+ total += 11
+ soft++
+ } else {
+ total += v
+ }
+ for total > 21 && soft > 0 {
+ total -= 10
+ soft--
+ }
+ return total, soft
+ }
+ result := 0.0
+ for up := 1; up <= 10; up++ {
+ // Outcomes 17..21 and bust (22), conditional on no dealer natural.
+ type state struct{ total, soft int }
+ memoDealer := map[state][23]float64{}
+ var dealer func(int, int) [23]float64
+ dealer = func(total, soft int) [23]float64 {
+ key := state{total, soft}
+ if v, ok := memoDealer[key]; ok {
+ return v
+ }
+ var out [23]float64
+ if total > 21 {
+ out[22] = 1
+ } else if total >= 17 && !(total == 17 && soft > 0 && r.DealerHitsSoft17()) {
+ out[total] = 1
+ } else {
+ for v := 1; v <= 10; v++ {
+ t, s := add(total, soft, v)
+ child := dealer(t, s)
+ for i, p := range child {
+ out[i] += probability(v) * p
+ }
+ }
+ }
+ memoDealer[key] = out
+ return out
+ }
+ natural := 0.0
+ if up == 1 {
+ natural = probability(10)
+ }
+ if up == 10 {
+ natural = probability(1)
+ }
+ var outcomes [23]float64
+ for hole := 1; hole <= 10; hole++ {
+ if up+hole == 11 && (up == 1 || hole == 1) {
+ continue
+ }
+ t, s := add(0, 0, up)
+ t, s = add(t, s, hole)
+ dist := dealer(t, s)
+ for i, p := range dist {
+ outcomes[i] += probability(hole) * p / (1 - natural)
+ }
+ }
+ stand := func(total int) float64 {
+ if total > 21 {
+ return -1
+ }
+ ev := outcomes[22]
+ for d := 17; d <= 21; d++ {
+ if total > d {
+ ev += outcomes[d]
+ } else if total < d {
+ ev -= outcomes[d]
+ }
+ }
+ return ev
+ }
+ memoPlayer := map[state]float64{}
+ var play func(int, int) float64
+ play = func(total, soft int) float64 {
+ if total >= 21 {
+ return stand(total)
+ }
+ key := state{total, soft}
+ if v, ok := memoPlayer[key]; ok {
+ return v
+ }
+ hit := 0.0
+ for v := 1; v <= 10; v++ {
+ t, s := add(total, soft, v)
+ hit += probability(v) * play(t, s)
+ }
+ ev := math.Max(stand(total), hit)
+ memoPlayer[key] = ev
+ return ev
+ }
+ hand := func(a, b int, split bool) float64 {
+ total, soft := add(0, 0, a)
+ total, soft = add(total, soft, b)
+ if split && a == 1 {
+ return stand(total)
+ } // one card on split aces
+ ev := play(total, soft)
+ if total < 21 && (!split || r.AllowsDoubleAfterSplit()) {
+ doubled := 0.0
+ for v := 1; v <= 10; v++ {
+ t, _ := add(total, soft, v)
+ value := stand(t)
+ if split && t == 21 && r.CountsDoubleSplitBlackjack() {
+ value = r.BlackjackPayout
+ }
+ doubled += 2 * probability(v) * value
+ }
+ ev = math.Max(ev, doubled)
+ }
+ if !split && r.AllowsSurrender() {
+ ev = math.Max(ev, -.5)
+ }
+ return ev
+ }
+ for a := 1; a <= 10; a++ {
+ for b := 1; b <= 10; b++ {
+ ev := 0.0
+ if a+b == 11 && (a == 1 || b == 1) {
+ ev = (1 - natural) * r.BlackjackPayout
+ } else {
+ ev = hand(a, b, false)
+ if a == b && r.MaxSplitHands > 1 {
+ split := 0.0
+ for v := 1; v <= 10; v++ {
+ split += 2 * probability(v) * hand(a, v, true)
+ }
+ ev = math.Max(ev, split)
+ }
+ ev = (1-natural)*ev - natural
+ }
+ result += probability(up) * probability(a) * probability(b) * ev
+ }
+ }
+ }
+ return result
+}
diff --git a/internal/casino/house_edge_craps.go b/internal/casino/house_edge_craps.go
new file mode 100644
index 0000000..06a6629
--- /dev/null
+++ b/internal/casino/house_edge_craps.go
@@ -0,0 +1,136 @@
+package casino
+
+import (
+ "strconv"
+ "strings"
+)
+
+func crapsHouseEdge(cfg TableConfig, bet string) HouseEdge {
+ c := cfg.Craps.Normalize()
+ switch bet {
+ case "pass", "come", "dontpass", "dontcome":
+ return edge(-crapsContractEV(c, bet, 0))
+ case "odds pass", "odds come <n>", "odds dontpass", "odds dontcome <n>":
+ return edge(0)
+ case "buy <n>", "lay <n>":
+ e := HouseEdge{Note: "5% commission on wins, before upward credit rounding; small stakes cost more"}
+ for _, n := range []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12} {
+ if !c.number(n) {
+ continue
+ }
+ // Buy: 5% of stake times P(number before 7).
+ // Lay: 5% of true-odds profit times P(7 before number).
+ // Both reduce to .05 * ways(number)/(ways(number)+6).
+ w := float64(diceWays(n))
+ e.Parts = append(e.Parts, EdgePart{strconv.Itoa(n), .05 * w / (w + 6)})
+ }
+ return e
+ case "hard 4 / hard 10":
+ return edge(-crapsResolvedEV(c, "hard", 4))
+ case "hard 6 / hard 8":
+ return edge(-crapsResolvedEV(c, "hard", 6))
+ case "eleven / ace-deuce":
+ return edge(-crapsResolvedEV(c, "eleven", 0))
+ case "aces / boxcars":
+ return edge(-crapsResolvedEV(c, "aces", 0))
+ case "hop <a> <b>":
+ return HouseEdge{Parts: []EdgePart{{"unequal dice", -crapsResolvedEV(c, "hop", 12)}, {"doubles", -crapsResolvedEV(c, "hop", 11)}}}
+ case "small / tall":
+ return edge(1 - 35*crapsCollectProbability([]int{2, 3, 4, 5, 6}))
+ case "all":
+ return edge(1 - 176*crapsCollectProbability([]int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12}))
+ case "fire":
+ return edge(1 - crapsFireReturn())
+ case "field", "seven", "craps", "horn":
+ return edge(-crapsResolvedEV(c, bet, 0))
+ }
+ if strings.HasPrefix(bet, "place ") {
+ n, err := strconv.Atoi(strings.TrimPrefix(bet, "place "))
+ if err == nil && c.number(n) {
+ return edge(-crapsResolvedEV(c, "place", n))
+ }
+ }
+ return unknownEdge("unrecognized craps target")
+}
+
+func diceWays(n int) int {
+ if n < 2 || n > 12 {
+ return 0
+ }
+ if n > 7 {
+ return 13 - n
+ }
+ return n - 1
+}
+
+// Enumerate the actual outcome function, skipping neutral rolls. Off-on-
+// come-out wagers have the same eventual EV when left working to resolution.
+func crapsResolvedEV(c CrapsConfig, kind string, n int) float64 {
+ sum, resolved := 0.0, 0.0
+ for a := 1; a <= 6; a++ {
+ for b := 1; b <= 6; b++ {
+ ok, x, y := c.outcome(kind, n, a, b, 6)
+ if ok {
+ sum += float64(x) / float64(y)
+ resolved++
+ }
+ }
+ }
+ return sum / resolved
+}
+
+// A contract starts at n=0 and travels once to its personal point. Self
+// loops (neutral dice or high-point come-out 2/3) are eliminated exactly.
+func crapsContractEV(c CrapsConfig, kind string, n int) float64 {
+ sum, effective := 0.0, 0.0
+ for a := 1; a <= 6; a++ {
+ for b := 1; b <= 6; b++ {
+ if c.variant() == "die_rich" && b != 1 {
+ continue
+ }
+ bb := b
+ if c.variant() == "die_rich" {
+ bb = 0
+ }
+ ok, x, y := c.outcome(kind, n, a, bb, 6)
+ if ok {
+ sum += float64(x) / float64(y)
+ effective++
+ continue
+ }
+ if n == 0 && c.pointNumber(a+bb) {
+ sum += crapsContractEV(c, kind, a+bb)
+ effective++
+ }
+ }
+ }
+ return sum / effective
+}
+
+// Probability of collecting all listed totals before ANY seven. All other
+// rolls are self loops. This also accounts for duplicate rolls correctly.
+func crapsCollectProbability(numbers []int) float64 {
+ full := (1 << len(numbers)) - 1
+ memo := make(map[int]float64)
+ var visit func(int) float64
+ visit = func(mask int) float64 {
+ if mask == full {
+ return 1
+ }
+ if p, ok := memo[mask]; ok {
+ return p
+ }
+ numerator, denominator := 0.0, 6.0
+ for i, n := range numbers {
+ if mask&(1<<i) != 0 {
+ continue
+ }
+ w := float64(diceWays(n))
+ numerator += w * visit(mask|(1<<i))
+ denominator += w
+ }
+ memo[mask] = numerator / denominator
+ return memo[mask]
+ }
+ return visit(0)
+}
diff --git a/internal/casino/house_edge_fire.go b/internal/casino/house_edge_fire.go
new file mode 100644
index 0000000..3bb1697
--- /dev/null
+++ b/internal/casino/house_edge_fire.go
@@ -0,0 +1,61 @@
+package casino
+
+import "math/bits"
+
+// crapsFireReturn is the fire bet's exact expected return (profit plus stake
+// where the bet wins; nothing where it loses) per unit staked. The bet
+// resolves only on a point-phase seven-out — a come-out seven keeps the
+// shooter's hand and the bet alive — paying 24/249/999:1 for 4/5/6 distinct
+// made points. The state is the six-bit mask of distinct points made in the
+// hand; come-out rolls and rerolls of an already-made point are self loops.
+// Solved by fixed-point iteration over the 64 masks (the chain is absorbing
+// and monotone, so iteration converges to the exact solution).
+func crapsFireReturn() float64 {
+ numbers := []int{4, 5, 6, 8, 9, 10}
+ ways := make([]int, len(numbers))
+ for i, n := range numbers {
+ ways[i] = diceWays(n)
+ }
+ pay := func(made int) float64 {
+ switch made {
+ case 4:
+ return 25 // 24:1 profit plus the returned stake
+ case 5:
+ return 250
+ case 6:
+ return 1000
+ }
+ return 0
+ }
+ vCome := make([]float64, 64)
+ for it := 0; it < 2000; it++ {
+ next := make([]float64, 64)
+ for mask := 0; mask < 64; mask++ {
+ sum := 0.0
+ for i := range numbers {
+ w := ways[i]
+ nm := mask | 1<<i
+ // Point-phase value of an established point n: making it
+ // moves to the come-out with the grown mask; a seven (6/36)
+ // resolves the bet at the CURRENT mask (not the grown one —
+ // the point has not been made).
+ made := bits.OnesCount(uint(mask))
+ pointValue := (float64(w)/36*vCome[nm] + 6.0/36*pay(made)) / (1 - float64(36-w-6)/36)
+ sum += float64(w) / 36 * pointValue
+ }
+ next[mask] = sum / (24.0 / 36.0)
+ }
+ done := true
+ for i := range next {
+ if diff := next[i] - vCome[i]; diff > 1e-12 || diff < -1e-12 {
+ done = false
+ break
+ }
+ }
+ vCome = next
+ if done {
+ break
+ }
+ }
+ return vCome[0]
+}
diff --git a/internal/casino/house_edge_pai_gow.go b/internal/casino/house_edge_pai_gow.go
new file mode 100644
index 0000000..82cff09
--- /dev/null
+++ b/internal/casino/house_edge_pai_gow.go
@@ -0,0 +1,55 @@
+package casino
+
+// Main-game reference estimates: published house-way play, not a solver for
+// this implementation's house way or a player's chosen arrangement. Sources:
+// https://wizardofodds.com/games/pai-gow-poker/
+// https://wizardofodds.com/games/ez-pai-gow-poker/
+// https://wizardofodds.com/games/face-up-pai-gow-poker/
+// The wheel convention can change individual results; these rounded reference
+// estimates do not resolve its small effect on the full strategy distribution.
+func paiGowHouseEdge(c TableConfig, bet string) HouseEdge {
+ r := c.PaiGow.Normalize()
+ switch bet {
+ case "main":
+ switch r.Variant {
+ case PaiGowClassic:
+ return edge(.027212)
+ case PaiGowEZ:
+ return edge(.0254)
+ case PaiGowFaceUp:
+ return edge(.0181)
+ case PaiGowNoPush:
+ // No published table for our tiebreaker variant. Use published classic
+ // win/loss/push frequencies, remove commission, then price tied extra
+ // cards as dealer wins. Five ace-valued cards (including joker), 12x4
+ // other ranks. Card-removal correlation is ignored in this estimate.
+ return edge(.303847 - .291195 + .404958*(5.0*4+12*4*3)/(53*52))
+ }
+ case "fortune":
+ profit := -121694196.0
+ for i, row := range FortunePayTable(r.Envy) {
+ profit += float64(row.Odds * fortuneCombinations[i])
+ }
+ return edge(-profit / 154143080)
+ case "pushace":
+ if rates, ok := r.pushAceRates(); ok {
+ // Published event probabilities (both ace-high, joker, plain ace-high).
+ return edge(1 - (.008700*float64(rates[2]+1) + .004100*float64(rates[1]+1) + .080800*float64(rates[0]+1)))
+ }
+ }
+ return unknownEdge("unrecognized pai gow wager")
+}
+
+// Fortune table 8 category counts, also pinned by pai_gow_test.go.
+var fortuneCombinations = []int{32, 72, 196, 1128, 26020, 184644, 307472, 4188528, 6172088, 7672500, 11034204, 2862000}
+
+func fortuneEnvyReturn(others, stake int) float64 {
+ if stake < 5 {
+ return 0
+ }
+ total := 0.0
+ for i, row := range FortunePayTable(true) {
+ total += float64(row.Envy * fortuneCombinations[i])
+ }
+ return total / 154143080 * float64(others) / float64(stake)
+}
diff --git a/internal/casino/house_edge_rules_test.go b/internal/casino/house_edge_rules_test.go
new file mode 100644
index 0000000..b79888f
--- /dev/null
+++ b/internal/casino/house_edge_rules_test.go
@@ -0,0 +1,148 @@
+package casino
+
+import (
+ "math"
+ "math/rand"
+ "strings"
+ "testing"
+)
+
+func TestBlackjackEdgeRules(t *testing.T) {
+ no, yes := false, true
+ base := BlackjackConfig{DoubleSplitBlackjack: &no}.Normalize()
+ want := blackjackMainEdge(base)
+ if math.Abs(want-.0043) > 1e-10 {
+ t.Fatalf("standard eight decks: %g", want)
+ }
+ cases := []struct {
+ name string
+ change func(*BlackjackConfig)
+ higher bool
+ }{
+ {"one deck", func(r *BlackjackConfig) { r.Decks = 1 }, false},
+ {"two decks", func(r *BlackjackConfig) { r.Decks = 2 }, false},
+ {"H17", func(r *BlackjackConfig) { r.DealerHitSoft17 = &yes }, true},
+ {"6:5", func(r *BlackjackConfig) { r.BlackjackPayout = 1.2 }, true},
+ {"no DAS", func(r *BlackjackConfig) { r.DoubleAfterSplit = &no }, true},
+ {"surrender", func(r *BlackjackConfig) { r.Surrender = &yes }, false},
+ {"no resplit", func(r *BlackjackConfig) { r.MaxSplitHands = 2 }, true},
+ {"no split", func(r *BlackjackConfig) { r.MaxSplitHands = 1 }, true},
+ {"resplit aces", func(r *BlackjackConfig) { r.ResplitAces = &yes }, false},
+ {"double split blackjack", func(r *BlackjackConfig) { r.DoubleSplitBlackjack = &yes }, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ r := base
+ tc.change(&r)
+ got := blackjackMainEdge(r)
+ if (got > want) != tc.higher || math.Abs(got-want) < 1e-8 {
+ t.Fatalf("edge %g base %g", got, want)
+ }
+ })
+ }
+ h17 := base
+ h17.DealerHitSoft17 = &yes
+ if math.Abs(blackjackMainEdge(h17)-want-.0022) > .0003 {
+ t.Fatal("H17 adjustment departed from published reference")
+ }
+ for _, action := range []string{"hit", "stand", "split", "double", "decline", "surrender"} {
+ if got := (TableConfig{Game: GameBlackjack}).HouseEdge(action).Text(2); got != "" {
+ t.Errorf("action %s has standalone edge: %s", action, got)
+ }
+ }
+}
+
+func TestVideoPokerEdgeEveryCabinetSchedule(t *testing.T) {
+ for cabinet, games := range cabinetGames {
+ for _, variant := range games {
+ for _, tier := range []string{"low", "normal", "full"} {
+ c := (VideoPokerConfig{Cabinet: cabinet, Payout: tier, Variant: variant}).Normalize()
+ for _, hands := range CabinetHandCounts(cabinet, variant) {
+ for _, feature := range []bool{false, true} {
+ for coins := 1; coins <= 5; coins++ {
+ e := pokerHouseEdge(c, "main", coins, feature, hands)
+ if len(e.Parts) != 1 || math.IsNaN(e.Parts[0].Loss) {
+ t.Fatalf("missing edge %s %s %s hands=%d feature=%v coins=%d: %+v", cabinet, variant, tier, hands, feature, coins, e)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestVideoPokerEdgeReferences(t *testing.T) {
+ for _, tc := range []struct {
+ c VideoPokerConfig
+ coins int
+ feature bool
+ hands int
+ want float64
+ }{
+ {VideoPokerConfig{}, 5, false, 1, .00456096},
+ {VideoPokerConfig{Payout: "low"}, 5, false, 1, .038528},
+ {VideoPokerConfig{}, 1, false, 1, .00456096 + 550*.00002476},
+ {VideoPokerConfig{Cabinet: CabinetUXSingle}, 5, true, 1, .0028},
+ {VideoPokerConfig{Cabinet: CabinetUXMulti}, 5, true, 3, .007022},
+ {VideoPokerConfig{Cabinet: CabinetUXMulti}, 5, true, 10, .005780},
+ {VideoPokerConfig{Cabinet: CabinetSuperTimesPay}, 5, true, 1, 1 - .99543904*(18.05/15)*5/6},
+ {VideoPokerConfig{Cabinet: CabinetHotRoll}, 5, true, 3, .00456096},
+ {VideoPokerConfig{Variant: "deuces_wild"}, 5, false, 1, -.007620},
+ } {
+ e := pokerHouseEdge(tc.c, "main", tc.coins, tc.feature, tc.hands)
+ if len(e.Parts) != 1 || math.Abs(e.Parts[0].Loss-tc.want) > 1e-9 {
+ t.Fatalf("%+v: %+v want %g", tc, e, tc.want)
+ }
+ }
+}
+
+func TestHouseEdgeUsesVideoPokerSeat(t *testing.T) {
+ cfg := (TableConfig{Game: GameVideoPoker}).Normalize()
+ game := newVideoPokerTable(1, cfg, rand.New(rand.NewSource(1))).(*videoPokerTable)
+ game.players["alice"] = &pokerPlayer{config: (VideoPokerConfig{Variant: "deuces_wild"}).Normalize(), loaded: 5, hands: 1}
+ table := &Table{game: game}
+ if got := table.HouseEdge("alice", "main").Text(2); got != "House edge: -0.76%" {
+ t.Fatal(got)
+ }
+ game.players["alice"].loaded = 1
+ if got := table.HouseEdge("alice", "main").Text(2); got == "House edge: -0.76%" {
+ t.Fatal("coin selection ignored")
+ }
+}
+
+func TestPaiGowEdgeReferences(t *testing.T) {
+ for _, tc := range []struct {
+ r PaiGowConfig
+ bet string
+ want float64
+ }{
+ {PaiGowConfig{}, "fortune", .034318},
+ {PaiGowConfig{Envy: true}, "fortune", .044292},
+ {PaiGowConfig{Variant: PaiGowFaceUp, PushAceHigh: "5_15_40"}, "pushace", .0929},
+ {PaiGowConfig{Variant: PaiGowFaceUp, PushAceHigh: "5_12_40"}, "pushace", .1052},
+ } {
+ e := paiGowHouseEdge(TableConfig{PaiGow: tc.r}, tc.bet)
+ if math.Abs(e.Parts[0].Loss-tc.want) > .000001 {
+ t.Fatalf("%+v: %+v", tc, e)
+ }
+ }
+ for _, variant := range []string{PaiGowClassic, PaiGowEZ, PaiGowNoPush, PaiGowFaceUp} {
+ if e := paiGowHouseEdge(TableConfig{PaiGow: PaiGowConfig{Variant: variant}}, "main"); len(e.Parts) != 1 {
+ t.Fatal(variant, e)
+ }
+ }
+ if fortuneEnvyReturn(2, 5) <= fortuneEnvyReturn(1, 5) || fortuneEnvyReturn(2, 4) != 0 {
+ t.Fatal("Envy eligibility/scaling")
+ }
+}
+
+func TestHouseEdgeTextOnlyPercentage(t *testing.T) {
+ e := HouseEdge{Parts: []EdgePart{{Label: "something", Loss: .027027}}, Note: "explanation"}
+ if e.Text(0) != "House edge: 3%" || e.Text(2) != "House edge: 2.70%" {
+ t.Fatal(e.Text(2))
+ }
+ if strings.Contains(e.Text(2), "explanation") || unknownEdge("unknown").Text(2) != "" {
+ t.Fatal("extra prose")
+ }
+}
diff --git a/internal/casino/house_edge_test.go b/internal/casino/house_edge_test.go
new file mode 100644
index 0000000..5f75e44
--- /dev/null
+++ b/internal/casino/house_edge_test.go
@@ -0,0 +1,127 @@
+package casino
+
+import (
+ "strings"
+ "testing"
+)
+
+// TestKnownHouseEdges pins the exact expected-loss figures against
+// independently derived values (the engine's own resolution rules for
+// variant-specific bets, standard published mathematics for the classics).
+// A changed payout or rule must break this test, never silently reprice the
+// reference.
+func TestKnownHouseEdges(t *testing.T) {
+ cases := []struct {
+ name string
+ cfg TableConfig
+ bet string
+ want float64 // expected loss per unit stake (unrounded fraction)
+ }{
+ {"roulette straight", TableConfig{Game: GameRoulette}, "<number>", 1.0 / 37},
+ {"roulette even money", TableConfig{Game: GameRoulette}, "red / black", 1.0 / 37},
+ {"roulette DZ straight", TableConfig{Game: GameRoulette, Roulette: RouletteConfig{DoubleZero: true}}, "<number>", 2.0 / 38},
+ {"roulette DZ basket", TableConfig{Game: GameRoulette, Roulette: RouletteConfig{DoubleZero: true}}, "basket", 3.0 / 38},
+ {"craps pass", TableConfig{Game: GameCraps}, "pass", 0.014141414141414},
+ {"craps dontpass", TableConfig{Game: GameCraps}, "dontpass", 0.013636363636364},
+ {"craps come", TableConfig{Game: GameCraps}, "come", 0.014141414141414},
+ {"craps dontcome", TableConfig{Game: GameCraps}, "dontcome", 0.013636363636364},
+ {"craps odds", TableConfig{Game: GameCraps}, "odds pass", 0},
+ {"craps field 2", TableConfig{Game: GameCraps}, "field", 0.055555555555556},
+ {"craps field 3", TableConfig{Game: GameCraps, Craps: CrapsConfig{Field12: 3}}, "field", 0.027777777777778},
+ {"craps seven", TableConfig{Game: GameCraps}, "seven", 1.0 / 6},
+ {"craps craps", TableConfig{Game: GameCraps}, "craps", 1.0 / 9},
+ {"craps eleven", TableConfig{Game: GameCraps}, "eleven / ace-deuce", 1.0 / 9},
+ {"craps aces", TableConfig{Game: GameCraps}, "aces / boxcars", 5.0 / 36},
+ {"craps horn", TableConfig{Game: GameCraps}, "horn", 0.125},
+ {"craps place 4", TableConfig{Game: GameCraps}, "place 4", 0.066666666666667},
+ {"craps place 5", TableConfig{Game: GameCraps}, "place 5", 0.04},
+ {"craps place 6", TableConfig{Game: GameCraps}, "place 6", 0.015151515151515},
+ {"craps hard 4", TableConfig{Game: GameCraps}, "hard 4 / hard 10", 1.0 / 9},
+ {"craps hard 6", TableConfig{Game: GameCraps}, "hard 6 / hard 8", 1.0 / 11},
+ {"craps small", TableConfig{Game: GameCraps}, "small / tall", 0.077613176297387},
+ {"craps all", TableConfig{Game: GameCraps}, "all", 0.074644079069427},
+ {"craps fire", TableConfig{Game: GameCraps}, "fire", 0.207627504982314},
+ {"craps simplified", TableConfig{Game: GameCraps, Craps: CrapsConfig{Variant: "simplified"}}, "pass", 1.0 / 36},
+ {"craps high point", TableConfig{Game: GameCraps, Craps: CrapsConfig{Variant: "high_point"}}, "pass", 0.023569023569024},
+ {"craps die rich", TableConfig{Game: GameCraps, Craps: CrapsConfig{Variant: "die_rich"}}, "pass", 0},
+ {"craps crapless", TableConfig{Game: GameCraps, Craps: CrapsConfig{Variant: "crapless"}}, "pass", 0.053823953823954},
+ {"craps new york", TableConfig{Game: GameCraps, Craps: CrapsConfig{Variant: "new_york"}}, "pass", 0.014141414141414},
+ {"craps new york dontpass", TableConfig{Game: GameCraps, Craps: CrapsConfig{Variant: "new_york"}}, "dontpass", 0.013636363636364},
+ {"baccarat player", TableConfig{Game: GameBaccarat}, "player", 0.012350813289149},
+ {"baccarat banker", TableConfig{Game: GameBaccarat}, "banker", 0.010579057842466},
+ {"baccarat tie", TableConfig{Game: GameBaccarat}, "tie", 0.143596287787302},
+ {"baccarat pair", TableConfig{Game: GameBaccarat}, "player_pair", 0.103614457831325},
+ {"baccarat dragon", TableConfig{Game: GameBaccarat, Baccarat: BaccaratConfig{Dragon7: true}}, "dragon_7", 0.076113344724498},
+ {"baccarat panda", TableConfig{Game: GameBaccarat, Baccarat: BaccaratConfig{Panda8: true}}, "panda_8", 0.101876321693189},
+ {"baccarat super6 bet", TableConfig{Game: GameBaccarat, Baccarat: BaccaratConfig{Super6Bet: true}}, "super_6", 0.299771693848976},
+ {"baccarat super6 banker", TableConfig{Game: GameBaccarat, Baccarat: BaccaratConfig{Variant: BaccaratSuper6}}, "banker", 0.014581044639736},
+ {"baccarat ez banker", TableConfig{Game: GameBaccarat, Baccarat: BaccaratConfig{Variant: BaccaratEZ}}, "banker", 0.010183007571229},
+ {"baccarat 6 decks player", TableConfig{Game: GameBaccarat, Baccarat: BaccaratConfig{Decks: 6}}, "player", 0.012374148986428},
+ }
+ for _, tc := range cases {
+ if tc.bet == "" {
+ continue
+ }
+ got := tc.cfg.HouseEdge(tc.bet)
+ if strings.Contains(got.Note, "not verified") {
+ t.Fatalf("%s: expected a verified edge, got %q", tc.name, got.Text(2))
+ }
+ if len(got.Parts) != 1 || abs64(got.Parts[0].Loss-tc.want) > 5e-13 {
+ t.Fatalf("%s: loss = %.18f, want %.18f", tc.name, got.Parts[0].Loss, tc.want)
+ }
+ }
+}
+
+// Strategy games have reference estimates rather than missing readouts.
+func TestStrategyHouseEdges(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ cfg TableConfig
+ bet string
+ }{
+ {"blackjack main", TableConfig{Game: GameBlackjack}, "main"},
+ {"paigow main", TableConfig{Game: GamePaiGow}, "main"},
+ {"paigow fortune", TableConfig{Game: GamePaiGow}, "fortune"},
+ {"video poker", TableConfig{Game: GameVideoPoker}, "main"},
+ } {
+ got := tc.cfg.HouseEdge(tc.bet)
+ if len(got.Parts) != 1 || got.Text(2) == "" {
+ t.Fatalf("%s: missing reference edge: %+v", tc.name, got)
+ }
+ }
+}
+
+// TestUnrecognizedTargetNeverInherits: a target the analysis does not know
+// must be flagged, not silently given another bet's edge.
+func TestUnrecognizedTargetNeverInherits(t *testing.T) {
+ for _, cfg := range []TableConfig{
+ {Game: GameRoulette},
+ {Game: GameCraps},
+ {Game: GameBaccarat},
+ {Game: GameBlackjack},
+ {Game: GamePaiGow},
+ {Game: GameVideoPoker},
+ } {
+ if got := cfg.HouseEdge("not-a-bet"); got.Note == "" {
+ t.Fatalf("%s: unrecognized target inherited an edge: %s", cfg.Game, got.Text(2))
+ }
+ }
+}
+
+// TestHopBetsTwoParts pins the split hop analysis (easy vs doubles).
+func TestHopBetsTwoParts(t *testing.T) {
+ got := (TableConfig{Game: GameCraps}).HouseEdge("hop <a> <b>")
+ if len(got.Parts) != 2 {
+ t.Fatalf("hop analysis: %s", got.Text(2))
+ }
+ if abs64(got.Parts[0].Loss-1.0/9) > 5e-13 || abs64(got.Parts[1].Loss-5.0/36) > 5e-13 {
+ t.Fatalf("hop losses = %s", got.Text(2))
+ }
+}
+
+func abs64(x float64) float64 {
+ if x < 0 {
+ return -x
+ }
+ return x
+}
diff --git a/internal/casino/house_edge_video_poker.go b/internal/casino/house_edge_video_poker.go
new file mode 100644
index 0000000..52af719
--- /dev/null
+++ b/internal/casino/house_edge_video_poker.go
@@ -0,0 +1,82 @@
+package casino
+
+// Published optimal max-coin returns and royal frequencies. Source pages:
+// https://wizardofodds.com/games/video-poker/tables/{jacks-or-better,
+// bonus-poker,bonus-poker-deluxe,double-bonus,double-double-bonus,
+// deuces-wild,joker-poker-kings-or-better}/
+// Below max coins we price the reduced royal using that schedule's strategy;
+// this is a reference estimate rather than a second optimal-strategy solver.
+type pokerReturn struct{ rtp, royal float64 }
+
+var pokerReturns = map[string]map[string]pokerReturn{
+ "jacks_or_better": {"9/6": {.99543904, .00002476}, "9/5": {.984498, .000025}, "8/6": {.983927, .000025}, "8/5": {.97298434, .00002489}, "7/5": {.961472, .000025}},
+ "bonus_poker": {"8/5": {.991660, .000025}, "7/5": {.980147, .000025}, "6/5": {.968687, .000025}},
+ "bonus_poker_deluxe": {"9/6": {.996417, .000024}, "9/5": {.985495, .000025}, "8/6": {.984928, .000024}, "8/5": {.974009, .000025}, "7/5": {.962526, .000025}},
+ "double_bonus": {"10/7": {1.001725, .000021}, "9/7": {.991065, .000021}, "9/6/5": {.978062, .000024}, "9/6/4": {.963754, .000024}, "9/5/4": {.952738, .000025}, "8/5": {.941897, .000025}},
+ "double_double_bonus": {"10/6": {1.000670, .000025}, "9/6": {.989808, .000025}, "8/6": {.978949, .000025}, "9/5": {.978729, .000025}, "8/5": {.967861, .000025}},
+ "deuces_wild": {"25/15/9/5/3/2": {1.007620, .000022}, "25/15/9/4/4/3": {.989131, .000023}, "20/12/10/4/4/3": {.975791, .000023}, "25/16/13/4/3/2": {.967651, .000023}},
+ "joker_poker": {"50/17/7/5/3": {.980854, .000025}, "50/16/7/5/3": {.972349, .000025}, "50/15/7/5/3": {.963848, .000025}},
+}
+
+// Ultimate X is a continuing-game reference, not the value of cashing in a
+// particular banked multiplier. Entries are single / three / five / ten hands.
+// Sources: .../tables/ultimate-x-single-line/ and .../tables/ultimate-x/.
+var pokerUXReturns = map[string]map[string][4]float64{
+ "jacks_or_better": {"9/6": {.9972, 0, 0, 0}, "8/6": {.9857, .992978, .994008, .994220}, "8/5": {.9738, .978730, .979671, .979795}, "7/5": {.9623, .967211, .968142, .968267}},
+ "bonus_poker": {"8/5": {.9921, .992828, .993408, .994034}, "7/5": {.9809, .981242, .981817, .982439}, "6/5": {.9694, 0, 0, 0}},
+ "bonus_poker_deluxe": {"9/6": {.9996, 0, 0, 0}, "8/6": {.9882, .995712, .996777, .997022}, "8/5": {.9747, .981052, .982014, .982169}, "7/5": {.9632, .969549, .970502, .970658}},
+ "double_bonus": {"9/7": {.9921, .988866, .989590, .990359}, "9/6/5": {.9811, .984517, .985227, .985987}, "9/6/4": {.9662, .973059, .973764, .974517}, "9/5/4": {.9539, .959471, .960118, .960810}},
+ "double_double_bonus": {"9/6": {.9905, .997261, .997928, .998663}, "9/5": {.9793, .983633, .984253, .984940}, "8/5": {.9680, .972266, .972859, .973521}},
+ "deuces_wild": {"25/15/9/4/4/3": {.9905, .991282, .992657, .994362}, "20/12/10/4/4/3": {.9788, .979863, .981217, .982817}, "25/16/13/4/3/2": {.9681, .969387, .970777, .972477}},
+ "joker_poker": {"50/17/7/5/3": {.9824, .989856, .990144, .990445}, "50/16/7/5/3": {.9738, .981190, .981474, .981771}, "50/15/7/5/3": {.9652, .972528, .972808, .973102}},
+}
+
+func videoPokerHouseEdge(c TableConfig, bet string) HouseEdge {
+ r := c.VideoPoker.Normalize()
+ return pokerHouseEdge(r, bet, 5, true, CabinetHandCounts(r.Cabinet, r.Variant)[0])
+}
+
+func pokerHouseEdge(c VideoPokerConfig, bet string, coins int, feature bool, hands int) HouseEdge {
+ if bet != "main" {
+ return unknownEdge("unrecognized video poker wager")
+ }
+ c = c.Normalize()
+ ref, ok := pokerReturns[c.Variant][c.PayTable]
+ if !ok {
+ return unknownEdge("unrecognized poker schedule")
+ }
+ rtp := ref.rtp
+ if coins > 0 && coins < 5 {
+ rtp -= 550 * ref.royal
+ feature = false
+ }
+ if feature {
+ switch c.Feature {
+ case PokerUltimateX:
+ ix := 0
+ if c.Cabinet == CabinetUXMulti {
+ switch hands {
+ case 3:
+ ix = 1
+ case 5:
+ ix = 2
+ case 10:
+ ix = 3
+ default:
+ return unknownEdge("unsupported hand count")
+ }
+ }
+ rtp = pokerUXReturns[c.Variant][c.PayTable][ix]
+ if rtp == 0 {
+ return unknownEdge("unsupported Ultimate X schedule")
+ }
+ case PokerSuperTimesPay:
+ // E[multiplier] = 14/15 + (2*.17+3*.33+4*.16+5*.24+8*.06+10*.04)/15.
+ rtp *= (14.0/15 + 4.05/15) * 5 / 6
+ case PokerHotRoll:
+ // Five extra coins buy 5/6 normal hands and 1/6 at mean 7x.
+ rtp *= (5.0/6 + 7.0/6) / 2
+ }
+ }
+ return edge(1 - rtp)
+}
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index 2162656..5fa6439 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -285,6 +285,15 @@ func (m *Manager) Rebet(roomID int, tableID, name string) (string, int) {
return "", 0
}
+// RebetAll repeats every remembered wager for games that keep several; other
+// games return nil and keep their single-wager rebet path.
+func (m *Manager) RebetAll(roomID int, tableID, name string, wallet Wallet) []Event {
+ if table := m.Table(roomID, tableID); table != nil {
+ return table.RebetAll(name, wallet)
+ }
+ return nil
+}
+
// MarkDisconnected removes the player from every table and machine,
// forfeiting any wager they had committed, so seats free up immediately.
func (m *Manager) MarkDisconnected(name string) []Event {
diff --git a/internal/casino/registry.go b/internal/casino/registry.go
index 25fbce5..dbf3b31 100644
--- a/internal/casino/registry.go
+++ b/internal/casino/registry.go
@@ -13,6 +13,9 @@ import (
// apart. The rule structs stay on TableConfig; this does not introduce a
// config union.
type tableGameDef struct {
+ // houseEdge analyzes this game's own configured wagers; nil means unverified,
+ // never zero. See house_edge.go for the loss/stake convention.
+ houseEdge func(TableConfig, string) HouseEdge
// validate checks a raw (pre-Normalize) table config.
validate func(TableConfig) error
// normalize applies this game's rule defaults to a table config.
@@ -42,7 +45,8 @@ var tableGames map[GameName]tableGameDef
func init() {
tableGames = map[GameName]tableGameDef{
GamePaiGow: {
- validate: validatePaiGow,
+ houseEdge: paiGowHouseEdge,
+ validate: validatePaiGow,
normalize: func(c TableConfig) TableConfig {
c.PaiGow = c.PaiGow.Normalize()
c.MaxPlayers = min(c.MaxPlayers, 6)
@@ -56,6 +60,7 @@ func init() {
helpTopic: func(TableConfig) string { return "pai gow poker" },
},
GameVideoPoker: {
+ houseEdge: videoPokerHouseEdge,
validate: func(c TableConfig) error {
if c.MaxPlayers > 1 {
return fmt.Errorf("video poker cabinets have one seat")
@@ -70,6 +75,7 @@ func init() {
helpTopic: func(c TableConfig) string { return pokerHelpTopic(c.VideoPoker) },
},
GameCraps: {
+ houseEdge: crapsHouseEdge,
validate: validateCraps,
normalize: func(c TableConfig) TableConfig { c.Craps = c.Craps.Normalize(); return c },
ruleBlockName: "craps",
@@ -79,6 +85,7 @@ func init() {
helpTopic: func(c TableConfig) string { return c.Craps.helpTopic() },
},
GameRoulette: {
+ houseEdge: rouletteHouseEdge,
validate: func(c TableConfig) error {
if c.Roulette.SpinTicks < 0 || c.Roulette.SpinTicks > maxSpinTicks {
return fmt.Errorf("roulette spin_ticks must be between 0 and %d", maxSpinTicks)
@@ -101,6 +108,7 @@ func init() {
helpTopic: func(TableConfig) string { return "roulette" },
},
GameBlackjack: {
+ houseEdge: blackjackHouseEdge,
validate: validateBlackjackTable,
normalize: normalizeBlackjackTable,
ruleBlockName: "blackjack",
@@ -112,6 +120,7 @@ func init() {
helpTopic: func(TableConfig) string { return "blackjack" },
},
GameBaccarat: {
+ houseEdge: baccaratHouseEdge,
validate: validateBaccaratTable,
normalize: normalizeBaccaratTable,
ruleBlockName: "baccarat",
diff --git a/internal/casino/table.go b/internal/casino/table.go
index b1beab9..2f4473b 100644
--- a/internal/casino/table.go
+++ b/internal/casino/table.go
@@ -107,6 +107,16 @@ func (t *Table) tick() []Event { return t.game.tick() }
func (t *Table) action(name, action string) []Event { return t.game.action(name, action) }
func (t *Table) rebet(name string) (string, int) { return t.game.rebet(name) }
+// RebetAll repeats every remembered wager for a game that keeps more than one
+// (currently baccarat's side bets plus main bet). It returns nil for games
+// whose bare "bet" is a single-wager repeat.
+func (t *Table) RebetAll(name string, wallet Wallet) []Event {
+ if b, ok := t.game.(*baccaratTable); ok {
+ return b.rebetAll(name, wallet)
+ }
+ return nil
+}
+
func (t *Table) markDisconnected(name string) []Event { return t.game.markDisconnected(name) }
func (t *Table) hasParticipant(name string) bool { return t.game.hasParticipant(name) }
diff --git a/internal/casino/table_hints.go b/internal/casino/table_hints.go
index 00eea53..51dda1a 100644
--- a/internal/casino/table_hints.go
+++ b/internal/casino/table_hints.go
@@ -69,17 +69,21 @@ func baccaratHintCommands(t *baccaratTable, name string) []MenuCommand {
p := t.players.get(name)
if p != nil && p.HasBet {
return []MenuCommand{
+ {Command: "unbet", Desc: "Take back all your wagers before the round"},
{Command: "stop", Desc: "Leave; during a round, leave afterward"},
{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"},
}
}
commands := []MenuCommand{}
- if p != nil && p.lastBet > 0 {
- commands = append(commands, MenuCommand{Command: "bet", Desc: fmt.Sprintf("Repeat %d on %s (also: rebet)", p.lastBet, p.lastSpot)}, MenuCommand{Command: "bet <amount>", Desc: "Change amount; keep " + p.lastSpot})
+ if p != nil && p.hasLastBet() {
+ commands = append(commands, MenuCommand{Command: "bet", Desc: "Repeat your last bets"})
+ }
+ if p != nil && len(p.sides) > 0 {
+ commands = append(commands, MenuCommand{Command: "unbet", Desc: "Take back your side bets"})
}
return append(commands,
- MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet), Desc: "Choose banker (b), player (p), or tie (t)"},
- MenuCommand{Command: "bet <amount> on <target>", Desc: "One wager per round; targets: list"},
+ MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet)},
+ MenuCommand{Command: "bet <amount> on <bet>"},
MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"},
MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"},
)
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index 569128b..c7b9fd1 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -257,11 +257,16 @@ func (t *baccaratTable) status(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
e := t.private(name, "")
+ p := t.players.get(name)
if text := t.seatStatusLocked(name); text != "" {
+ // Report every committed stake, not just the main wager.
+ if p != nil && p.HasBet && len(p.sides) > 0 && strings.HasPrefix(text, "Wager committed") {
+ text = fmt.Sprintf("Wager committed: %d", p.totalStaked())
+ }
e.Message = text
}
events := []Event{e}
- if p := t.players.get(name); p != nil && !p.HasBet {
+ if p != nil && !p.HasBet {
events = append(events, t.menuEvent(name))
}
return events
diff --git a/internal/casino/table_ux_test.go b/internal/casino/table_ux_test.go
index ea8bc04..4d58a5f 100644
--- a/internal/casino/table_ux_test.go
+++ b/internal/casino/table_ux_test.go
@@ -223,7 +223,7 @@ func TestRebetMenusAndMidRoundJoin(t *testing.T) {
}
bac.bet("alice", "banker", 25, &testWallet{credits: 100})
menu := bac.menuEvent("alice").Menu
- if !menu.Repeat || !strings.Contains(menu.Commands[0].Desc, "Repeat 25 on banker") {
+ if !menu.Repeat || menu.Commands[0].Command != "bet" || !strings.Contains(menu.Commands[0].Desc, "Repeat") {
t.Fatalf("missing rebet details: %+v", menu)
}
}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 8238b7c..21f9627 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -419,6 +419,12 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, strings.TrimSpace("coins "+strings.Join(args, " "))))
return
}
+ // Baccarat repeats every remembered side bet and the main bet on a bare
+ // "bet"; games with one wager keep the generic single-rebet path below.
+ if table.Config.Game == casino.GameBaccarat && len(args) == 0 {
+ g.emitCasinoEvents(g.Casino.RebetAll(table.RoomID, table.Config.ID, p.Name, casinoWallet{g: g, p: p}))
+ return
+ }
// Table bet syntax: bet | bet <amount> [on] <target> | bet min|max [on] <target>.
// BEFORE an "on" keyword, a token that parses as an amount (or min/max)
// sets the wager and any other token is the bet target; AFTER "on", the
diff --git a/internal/game/casino_edge_list_test.go b/internal/game/casino_edge_list_test.go
new file mode 100644
index 0000000..ff0b965
--- /dev/null
+++ b/internal/game/casino_edge_list_test.go
@@ -0,0 +1,97 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/player"
+)
+
+func TestEdgeListAllTableGames(t *testing.T) {
+ for _, game := range []string{"blackjack", "baccarat", "roulette", "craps", "paigow", "video_poker"} {
+ t.Run(game, func(t *testing.T) {
+ g, sess, conn := houseEdgeGame(t, "name: Casino\ncasino_tables:\n - id: test\n game: "+game+"\n")
+ writeHouseEdgeManual(t, g, sess, 2)
+ g.executePlay(sess, []string{game}, "play "+game)
+ if strings.Contains(stripANSI(string(conn.data)), "RTP") {
+ t.Fatal("readout on join")
+ }
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text := stripANSI(string(conn.data))
+ if !strings.Contains(text, "RTP") {
+ t.Fatalf("no RTP column for %s: %s", game, text)
+ }
+ assertRTPColumn(t, text)
+ sess.Player.Inventory = nil
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ if strings.Contains(stripANSI(string(conn.data)), "RTP") {
+ t.Fatal("readout after reference removed")
+ }
+ })
+ }
+}
+
+func TestEdgeListBlackjackActionsOmitRTP(t *testing.T) {
+ g, sess, conn := houseEdgeGame(t, "name: BJ\ncasino_tables:\n - id: bj\n game: blackjack\n")
+ writeHouseEdgeManual(t, g, sess, 2)
+ g.executePlay(sess, []string{"bj"}, "play bj")
+ table, _ := g.Casino.Participation(sess.Player.Name)
+ for i := 0; i < 10 && len(table.Wagers(sess.Player.Name)) == 0; i++ {
+ g.executeBet(sess, []string{"1"}, "bet 1")
+ }
+ rows := table.Wagers(sess.Player.Name)
+ if len(rows) == 0 {
+ t.Skip("no action rows this deal")
+ }
+ insurance := false
+ for _, r := range rows {
+ if r.Bet == "insurance" {
+ insurance = true
+ }
+ }
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text := stripANSI(string(conn.data))
+ if insurance {
+ // Insurance is a real wager and carries an RTP.
+ if !strings.Contains(text, "RTP") {
+ t.Fatalf("insurance list lost its RTP:\n%s", text)
+ }
+ return
+ }
+ if strings.Contains(text, "RTP") {
+ t.Fatalf("action-only list showed an RTP column:\n%s", text)
+ }
+}
+
+func TestEdgeListRealInstruments(t *testing.T) {
+ g, sess, conn := houseEdgeGame(t, roomRoulette)
+ g.ItemStore = item.NewItemStore("../../data")
+ sess.Player.Inventory = map[int]*player.InventorySlot{
+ 0: {ItemID: "house_edge_tile", Quantity: 1},
+ 1: {ItemID: "house_edge_computus", Quantity: 1},
+ }
+ g.executePlay(sess, []string{"roulette"}, "play roulette")
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text := stripANSI(string(conn.data))
+ if strings.Count(text, "97.30%") != 12 {
+ t.Fatalf("best instrument not used: %s", text)
+ }
+ sess.Player.Inventory[1].Quantity = 0
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text = stripANSI(string(conn.data))
+ if strings.Count(text, "97%") != 12 {
+ t.Fatalf("tile not used: %s", text)
+ }
+ sess.Player.Inventory[0].Quantity = 0
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ if strings.Contains(stripANSI(string(conn.data)), "RTP") {
+ t.Fatal("zero-quantity item activated")
+ }
+}
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"))
+}
diff --git a/internal/game/casino_rules_list_test.go b/internal/game/casino_rules_list_test.go
index 3d91ecf..e83c277 100644
--- a/internal/game/casino_rules_list_test.go
+++ b/internal/game/casino_rules_list_test.go
@@ -85,6 +85,28 @@ func TestRulesOutsideCasinoFallsBackToGuide(t *testing.T) {
}
// TestListFollowsGameState covers the list contract end to end: come-out
+// listTargets returns the bet names rendered in the first table column,
+// working for both the unicode and ASCII table borders.
+func listTargets(text string) map[string]bool {
+ targets := map[string]bool{}
+ for _, line := range strings.Split(text, "\n") {
+ line = strings.TrimSpace(line)
+ if !strings.HasPrefix(line, "║") && !strings.HasPrefix(line, "|") {
+ continue
+ }
+ line = strings.TrimPrefix(strings.TrimPrefix(line, "║"), "|")
+ for _, sep := range []string{"│", "|"} {
+ if at := strings.Index(line, sep); at >= 0 {
+ line = line[:at]
+ }
+ }
+ if cell := strings.TrimSpace(line); cell != "" {
+ targets[cell] = true
+ }
+ }
+ return targets
+}
+
// lists the line and one-roll bets, a point adds place/odds rows, bonus
// bets close after the first roll, and single-bet editions print one line.
func TestListFollowsGameState(t *testing.T) {
@@ -95,13 +117,14 @@ func TestListFollowsGameState(t *testing.T) {
conn.data = nil
g.executeList(sess, nil, "list")
text := stripANSI(string(conn.data))
- for _, want := range []string{"bet <amount> on pass", "bet <amount> on dontpass", "bet <amount> on field", "bet <amount> on craps", "bet <amount> on fire"} {
- if !strings.Contains(text, want) {
+ targets := listTargets(text)
+ for _, want := range []string{"pass", "dontpass", "field", "craps", "fire"} {
+ if !targets[want] {
t.Fatalf("come-out list missing %q: %q", want, text)
}
}
for _, gone := range []string{"place 6", "odds pass"} {
- if strings.Contains(text, gone) {
+ if targets[gone] {
t.Fatalf("come-out list advertised %q: %q", gone, text)
}
}
@@ -111,37 +134,39 @@ func TestListFollowsGameState(t *testing.T) {
conn.data = nil
g.executeList(sess, nil, "list")
text = stripANSI(string(conn.data))
- if !strings.Contains(text, "place 6") || !strings.Contains(text, "odds pass") {
+ targets = listTargets(text)
+ if !targets["place 6"] || !targets["odds pass"] {
t.Fatalf("point list missing place/odds: %q", text)
}
- if strings.Contains(text, "bet <amount> on pass\n") || strings.Contains(text, "fire") {
+ if targets["fire"] {
t.Fatalf("point list still advertising closed bets: %q", text)
}
}
-// TestListSingleBetGamesStayBrief: editions with one wager (Simplified) and
-// one-wager games (blackjack) answer in one line, no table.
-func TestListSingleBetGamesStayBrief(t *testing.T) {
+// TestListSingleBetGamesRenderOneRow: editions with one wager (Simplified)
+// and one-wager games (blackjack) answer with a one-row table.
+func TestListSingleBetGamesRenderOneRow(t *testing.T) {
room := "name: Simple\ncasino_tables:\n - id: simple\n game: craps\n craps:\n variant: simplified\n"
g, sess, conn := casinoCrapsGame(t, room)
g.executePlay(sess, []string{"craps"}, "play craps")
conn.data = nil
g.executeList(sess, nil, "list")
text := stripANSI(string(conn.data))
- if strings.Contains(text, "Bet") && strings.Contains(text, "Pays") && strings.Contains(text, "Win if") {
- t.Fatalf("single-bet game rendered a table: %q", text)
+ if !strings.Contains(text, "Odds") {
+ t.Fatalf("single-bet list did not render the table header: %q", text)
}
- if !strings.Contains(text, "bet <amount> on pass") {
- t.Fatalf("single-bet line missing the wager: %q", text)
+ if targets := listTargets(text); !targets["pass"] {
+ t.Fatalf("single-bet table missing the wager: %q", text)
}
- // Blackjack: one wager, no table, and the list command still answers.
+ // Blackjack: one wager, one row, and the list command still answers.
room = "name: BJ\ncasino_tables:\n - id: bj\n game: blackjack\n"
g, sess, conn = casinoCrapsGame(t, room)
g.executePlay(sess, []string{"bj"}, "play bj")
conn.data = nil
g.executeList(sess, nil, "list")
- if text := stripANSI(string(conn.data)); !strings.Contains(text, "only wager") {
+ text = stripANSI(string(conn.data))
+ if targets := listTargets(text); !targets["bet <amount>"] {
t.Fatalf("blackjack list: %q", text)
}
}
@@ -225,8 +250,9 @@ func TestListRouletteAndBaccarat(t *testing.T) {
conn.data = nil
g.executeList(sess, nil, "list")
text := stripANSI(string(conn.data))
- for _, want := range []string{"bet <amount> on <number>", "bet <amount> on red / black", "bet <amount> on voisins", "bet <amount> on basket"} {
- if !strings.Contains(text, want) {
+ targets := listTargets(text)
+ for _, want := range []string{"<number>", "red / black", "voisins", "basket"} {
+ if !targets[want] {
t.Fatalf("roulette list missing %q: %q", want, text)
}
}
@@ -237,9 +263,31 @@ func TestListRouletteAndBaccarat(t *testing.T) {
conn.data = nil
g.executeList(sess, nil, "list")
text = stripANSI(string(conn.data))
- for _, want := range []string{"bet <amount> on banker (b)", "1:1 (3-card 7 pushes)", "bet <amount> on dragon_7", "bet <amount> on panda_8"} {
- if !strings.Contains(text, want) {
+ targets = listTargets(text)
+ for _, want := range []string{"banker", "dragon_7", "panda_8"} {
+ if !targets[want] {
t.Fatalf("baccarat list missing %q: %q", want, text)
}
}
+ if !strings.Contains(text, "1:1 (3-card 7 pushes)") {
+ t.Fatalf("baccarat list dropped the EZ banker rule: %q", text)
+ }
+}
+
+// The commission is implied by the 0.95:1 ratio and must not be repeated.
+func TestListBaccaratDropsCommissionProse(t *testing.T) {
+ g, sess, conn := casinoCrapsGame(t, "name: B\ncasino_tables:\n - id: b\n game: baccarat\n")
+ g.executePlay(sess, []string{"b"}, "play b")
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text := stripANSI(string(conn.data))
+ if strings.Contains(text, "commission") {
+ t.Fatalf("list repeated the implied commission: %q", text)
+ }
+ if targets := listTargets(text); !targets["banker"] {
+ t.Fatalf("baccarat list lost the banker row: %q", text)
+ }
+ if !strings.Contains(text, "0.95:1") {
+ t.Fatalf("baccarat list lost the banker ratio: %q", text)
+ }
}
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index 6d666f7..28fc361 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -365,7 +365,11 @@ func (g *Game) executeList(sess *net.Session, args []string, rawInput string) {
return
}
if machine, ok := g.Casino.MachineFor(p.Name); ok {
- sess.WriteLine(g.colorize(sess, "casino_action", "A "+casinoGameLabel(machine.Config.Game)+" takes one wager: bet <amount> (or bet max), then spin or autospin."))
+ row := casinoListRow{bet: "bet <amount> / max", odds: "paytable", win: "spin / autospin"}
+ if _, enabled := g.casinoEdgePrecision(sess); enabled {
+ row.edge = machine.Config.HouseEdge()
+ }
+ g.showCasinoListTable(sess, "", []casinoListRow{row})
return
}
if table, playing := g.Casino.Participation(p.Name); playing && table != nil {
@@ -375,9 +379,8 @@ func (g *Game) executeList(sess *net.Session, args []string, rawInput string) {
g.executeShopList(sess, args, rawInput)
}
-// showCasinoWagerList renders the game's live wager list. Two or more rows
-// become a brief table; a single row is one line; an empty list means the
-// only bet is the ordinary amount-only wager.
+// showCasinoWagerList renders the live bets/actions as a compact table.
+// An empty wager catalogue can still represent an ordinary amount-only bet.
func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) {
if t.Config.Game == casino.GameVideoPoker {
for _, h := range t.Hints(sess.Player.Name) {
@@ -386,24 +389,18 @@ func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) {
return
}
}
- sess.WriteLine("This cabinet has one wager: load coins with bet [1-4|max], then deal. Use paytable for payouts.")
+ rows := g.casinoListRows(sess, t, []casino.WagerRow{{Bet: "main", Payout: "paytable", Win: "deal"}})
+ rows[0].bet = "bet [1-4|max]"
+ g.showCasinoListTable(sess, "", rows)
return
}
rows := t.Wagers(sess.Player.Name)
// Blackjack rows are actions, not targets for a new bet.
if t.Config.Game == casino.GameBlackjack && len(rows) > 0 {
- commands := make([]casino.MenuCommand, 0, len(rows))
- for _, r := range rows {
- commands = append(commands, casino.MenuCommand{Command: r.Bet, Desc: strings.Trim(r.Payout+" | "+r.Win, " |")})
- }
- sess.WriteLine(g.renderCasinoCommands(sess, g.casinoBalance(sess), "Available actions", commands))
+ g.showCasinoListTable(sess, "", g.casinoListRows(sess, t, rows))
return
}
- switch {
- case len(rows) == 1:
- r := rows[0]
- sess.WriteLine(g.colorize(sess, "casino_action", "Your only wager: bet <amount> on "+r.Bet+" — "+r.Payout+" — "+r.Win+"."))
- case len(rows) == 0:
+ if len(rows) == 0 {
canBet := false
for _, h := range t.Hints(sess.Player.Name) {
if strings.HasPrefix(h.Command, "bet ") {
@@ -414,14 +411,16 @@ func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) {
sess.WriteLine(g.colorize(sess, "casino_action", "No new wagers are available right now."))
return
}
- sess.WriteLine(g.colorize(sess, "casino_action", "The only wager here is bet <amount>") + " (" + betRangeWords(t.Config.MinBet, t.Config.MaxBet) + ").")
- default:
- commands := make([]casino.MenuCommand, 0, len(rows))
- for _, r := range rows {
- commands = append(commands, casino.MenuCommand{Command: "bet <amount> on " + r.Bet, Desc: r.Payout + " | " + r.Win})
+ wager := casino.WagerRow{Bet: "main", Win: betRangeWords(t.Config.MinBet, t.Config.MaxBet)}
+ if t.Config.Game == casino.GameBlackjack {
+ wager.Payout = fmt.Sprintf("1:1; BJ %g:1", t.Config.Blackjack.Normalize().BlackjackPayout)
}
- sess.WriteLine(g.renderCasinoCommands(sess, g.casinoBalance(sess), t.Config.Label()+" — available bets", commands))
+ list := g.casinoListRows(sess, t, []casino.WagerRow{wager})
+ list[0].bet = "bet <amount>"
+ g.showCasinoListTable(sess, "", list)
+ return
}
+ g.showCasinoListTable(sess, "bet <amount> on <bet>", g.casinoListRows(sess, t, rows))
}
// showCasinoGameLook appends the live game state and hint menu for the
diff --git a/internal/game/house_edge_test.go b/internal/game/house_edge_test.go
new file mode 100644
index 0000000..82aa966
--- /dev/null
+++ b/internal/game/house_edge_test.go
@@ -0,0 +1,214 @@
+package game
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+// houseEdgeGame runs a Game over a copy of the shipped data directory (so
+// the real item, room and help definitions load) plus one synthetic room 1.
+func houseEdgeGame(t *testing.T, roomYAML string) (*Game, *net.Session, *casinoTestConn) {
+ t.Helper()
+ dir := t.TempDir()
+ for _, name := range []string{"rooms", "objects", "help", "items", "mobs", "recipes"} {
+ source := filepath.Join("../../data", name)
+ entries, err := os.ReadDir(source)
+ if err != nil {
+ continue // data subdirectory not shipped in this repository state
+ } else if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ data, err := os.ReadFile(filepath.Join(source, entry.Name()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, name, entry.Name()), data, 0644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ return houseEdgeGameIn(t, dir, roomYAML)
+}
+
+func houseEdgeGameIn(t *testing.T, dir, roomYAML string) (*Game, *net.Session, *casinoTestConn) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte(roomYAML), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(dir, "players", "characters"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ p := player.New("alice")
+ sess := &net.Session{Player: p, Conn: &casinoTestConn{}}
+ g := &Game{
+ Deps: Deps{
+ World: world.New(dir), ObjectStore: object.NewObjectStore(dir),
+ MobStore: world.NewMobStore(dir), AccountStore: player.NewAccountStore(dir),
+ DataDir: dir, ItemStore: item.NewItemStore(dir),
+ },
+ Casino: casino.NewManager(1),
+ Combat: combat.NewTracker(),
+ safespot: NewSafespotManager(),
+ restTimers: make(map[string]uint64),
+ loggedInChars: map[string]*net.Session{"alice": sess},
+ }
+ p.Credits = 100
+ p.RoomID = 1
+ return g, sess, sess.Conn.(*casinoTestConn)
+}
+
+// writeHouseEdgeManual writes the named reference item into the copied data
+// directory, reloads the store and places one in the player's inventory.
+func writeHouseEdgeManual(t *testing.T, g *Game, sess *net.Session, precision int) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Join(g.Deps.DataDir, "items"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ yaml := []byte("name: edge reference\nhouse_edge_precision: " + strconv.Itoa(precision) + "\nvalue: 0\n")
+ if err := os.WriteFile(filepath.Join(g.Deps.DataDir, "items", "edge_reference.yaml"), yaml, 0644); err != nil {
+ t.Fatal(err)
+ }
+ g.ItemStore.Reload(g.Deps.DataDir)
+ sess.Player.Inventory = map[int]*player.InventorySlot{0: {ItemID: "edge_reference", Quantity: 1}}
+}
+
+var rtpCellRE = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?%$`)
+
+// The RTP column carries only percentages: no notes, labels or explanations.
+func assertRTPColumn(t *testing.T, text string) {
+ t.Helper()
+ if !strings.Contains(text, "RTP") {
+ t.Fatalf("no RTP column:\n%s", text)
+ }
+ for _, line := range strings.Split(text, "\n") {
+ if !strings.Contains(line, "│") {
+ continue
+ }
+ fields := strings.Split(line, "│")
+ cell := strings.TrimSpace(fields[len(fields)-1])
+ cell = strings.TrimSpace(strings.Trim(cell, "║"))
+ if cell == "" || cell == "RTP" || cell == "—" {
+ continue
+ }
+ if !rtpCellRE.MatchString(cell) {
+ t.Fatalf("RTP cell is not a bare percentage: %q in %q", cell, line)
+ }
+ }
+}
+
+const roomRoulette = "name: RT\ncasino_tables:\n - id: rt\n game: roulette\n"
+const roomCraps = "name: CR\ncasino_tables:\n - id: cr\n game: craps\n"
+const roomSlots = "name: MB\ncasino_machines:\n - id: m1\n game: slots\n"
+
+// Merely joining must never display the item's readout.
+func TestHouseEdgeJoinSilentTable(t *testing.T) {
+ g, sess, conn := houseEdgeGame(t, roomRoulette)
+ writeHouseEdgeManual(t, g, sess, 0)
+ g.executePlay(sess, []string{"roulette"}, "play roulette")
+ text := stripANSI(string(conn.data))
+ if strings.Contains(text, "RTP") {
+ t.Fatalf("join displayed an edge:\n%s", text)
+ }
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text = stripANSI(string(conn.data))
+ if strings.Count(text, "97%") != 12 {
+ t.Fatalf("list missing the twelve roulette RTPs:\n%s", text)
+ }
+ assertRTPColumn(t, text)
+}
+
+// TestHouseEdgeListAnnotatesEveryRow: the list command carries the same
+// figures on each row while the state-based list is up.
+func TestHouseEdgeListAnnotatesEveryRow(t *testing.T) {
+ g, sess, conn := houseEdgeGame(t, roomCraps)
+ writeHouseEdgeManual(t, g, sess, 0)
+ g.executePlay(sess, []string{"craps"}, "play craps")
+ g.executeBet(sess, []string{"10", "pass"}, "bet 10 pass")
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text := stripANSI(string(conn.data))
+ if strings.Count(text, "│") < 8 {
+ t.Fatalf("list did not annotate the wagers:\n%s", text)
+ }
+ assertRTPColumn(t, text)
+}
+
+// Paytable is not list and must not carry the item's readout.
+func TestHouseEdgePaytableSilentVideoPoker(t *testing.T) {
+ g, sess, conn := houseEdgeGame(t, "name: VP\ncasino_tables:\n - id: vp\n game: video_poker\n")
+ writeHouseEdgeManual(t, g, sess, 2)
+ g.executePlay(sess, []string{"vp"}, "play vp")
+ conn.data = nil
+ g.executeTableAction(sess, "paytable")
+ if text := stripANSI(string(conn.data)); strings.Contains(text, "RTP") {
+ t.Fatalf("paytable displayed the edge:\n%s", text)
+ }
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text := stripANSI(string(conn.data))
+ if !strings.Contains(text, "99.54%") {
+ t.Fatalf("video poker list missing RTP: %s", text)
+ }
+ assertRTPColumn(t, text)
+}
+
+// Slots are silent on join and show two-decimal precision on list.
+func TestHouseEdgeMachineJoinSilentListPrecision(t *testing.T) {
+ g, sess, conn := houseEdgeGame(t, roomSlots)
+ writeHouseEdgeManual(t, g, sess, 2)
+ g.executePlay(sess, []string{"slots"}, "play slots")
+ text := stripANSI(string(conn.data))
+ if strings.Contains(text, "RTP") {
+ t.Fatalf("machine join displayed edge: %s", text)
+ }
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text = stripANSI(string(conn.data))
+ if !strings.Contains(text, "97.00%") {
+ t.Fatalf("machine list missing precision: %s", text)
+ }
+ assertRTPColumn(t, text)
+}
+
+// TestHouseEdgeAbsentSilentWithoutItem pins the privacy contract: no item,
+// no figures, not even on the same screens.
+func TestHouseEdgeAbsentSilentWithoutItem(t *testing.T) {
+ g, sess, conn := houseEdgeGame(t, roomRoulette)
+ g.executePlay(sess, []string{"roulette"}, "play roulette")
+ g.executeList(sess, nil, "list")
+ if text := stripANSI(string(conn.data)); strings.Contains(text, "RTP") {
+ t.Fatalf("edge shown without any item:\n%s", text)
+ }
+}
+
+// TestHouseEdgeMachineList: the machine's one-line list also carries it.
+func TestHouseEdgeMachineList(t *testing.T) {
+ g, sess, conn := houseEdgeGame(t, roomSlots)
+ writeHouseEdgeManual(t, g, sess, 0)
+ g.executePlay(sess, []string{"m1"}, "play m1")
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ if text := stripANSI(string(conn.data)); !strings.Contains(text, "RTP") {
+ t.Fatalf("machine list missing the edge:\n%s", text)
+ }
+}
diff --git a/internal/game/render_baccarat_felt.go b/internal/game/render_baccarat_felt.go
index 84f91e3..4757764 100644
--- a/internal/game/render_baccarat_felt.go
+++ b/internal/game/render_baccarat_felt.go
@@ -67,7 +67,7 @@ func renderBaccaratRulesFelt(cfg casino.TableConfig, mode string, unicode bool)
"PLAYER: DRAWS 0-5; STANDS 6-7. BANKER VS A STANDING PLAYER: DRAWS 0-5.",
"BANKER VS A DRAWING PLAYER: 0-2 ALWAYS DRAWS; 3 DRAWS UNLESS VS 8;",
"4 DRAWS VS 2-7; 5 DRAWS VS 4-7; 6 DRAWS VS 6-7; 7 STANDS.",
- "ONE WAGER PER PLAYER PER ROUND; SIDE BETS SETTLE INDEPENDENTLY.",
+ "ONE MAIN BET (PLAYER/BANKER/TIE) PLUS SIDE BETS; SIDE BETS FIRST.",
}
for i, line := range draw {
c.text(3, 17+i, line, crapsInk)
diff --git a/internal/game/render_baccarat_felt_test.go b/internal/game/render_baccarat_felt_test.go
index dabaa36..a3bb01c 100644
--- a/internal/game/render_baccarat_felt_test.go
+++ b/internal/game/render_baccarat_felt_test.go
@@ -30,7 +30,7 @@ func TestBaccaratRulesFeltGeometryAndPhrases(t *testing.T) {
"DRAGON 7", "PANDA 8", "SUPER 6", "NOT OFFERED",
"DECKS", "SHUFFLE AT", "BET LIMITS", "1-100 CREDITS", "35 TICKS",
"DRAWING — AUTOMATIC PUNTO BANCO", "DRAWS 0-5", "6 DRAWS VS 6-7",
- "ONE WAGER PER PLAYER PER ROUND",
+ "ONE MAIN BET (PLAYER/BANKER/TIE) PLUS SIDE BETS",
} {
if !strings.Contains(art, want) {
t.Fatalf("missing %q in unicode=%v felt", want, unicode)
diff --git a/internal/game/video_poker_test.go b/internal/game/video_poker_test.go
index f2ea43f..8ec44d7 100644
--- a/internal/game/video_poker_test.go
+++ b/internal/game/video_poker_test.go
@@ -73,7 +73,7 @@ func TestBlackjackListUsesRealActionCommands(t *testing.T) {
conn.data = nil
g.executeList(sess, nil, "list")
text := stripANSI(string(conn.data))
- if strings.Contains(text, "bet <amount> on") || !strings.Contains(text, "Available actions") {
+ if strings.Contains(text, "bet <amount> on") || len(listTargets(text)) == 0 {
t.Fatal(text)
}
}
diff --git a/internal/item/house_edge_test.go b/internal/item/house_edge_test.go
new file mode 100644
index 0000000..574a68f
--- /dev/null
+++ b/internal/item/house_edge_test.go
@@ -0,0 +1,29 @@
+package item
+
+import "testing"
+
+// TestHouseEdgeItemDefinitions pins the two house-edge reference items: the
+// tile shows whole percent, the computus shows hundredths. Neither stacks
+// (the precision is a capability, not a quantity) and both are valuable
+// enough that a player will bank them rather than drop them.
+func TestHouseEdgeItemDefinitions(t *testing.T) {
+ store := NewItemStore("../../data")
+ for _, tc := range []struct {
+ id string
+ precision int
+ }{
+ {"house_edge_tile", 0},
+ {"house_edge_computus", 2},
+ } {
+ def, err := store.Load(tc.id)
+ if err != nil {
+ t.Fatalf("load %s: %v", tc.id, err)
+ }
+ if def.HouseEdgePrecision == nil || *def.HouseEdgePrecision != tc.precision {
+ t.Fatalf("%s: house_edge_precision = %v, want %d", tc.id, def.HouseEdgePrecision, tc.precision)
+ }
+ if def.Stackable {
+ t.Fatalf("%s must not stack (its precision is a capability, not a quantity)", tc.id)
+ }
+ }
+}
diff --git a/internal/item/item.go b/internal/item/item.go
index 809959e..c083d8e 100644
--- a/internal/item/item.go
+++ b/internal/item/item.go
@@ -91,6 +91,10 @@ type ItemDef struct {
Tool *ToolDef `yaml:"tool,omitempty"`
Requirements map[string]int `yaml:"requirements,omitempty"`
+ // Optional inventory-only casino reference capability. Supported precision:
+ // 0 (whole percent) or 2 (hundredths of a percent); nil grants nothing.
+ HouseEdgePrecision *int `yaml:"house_edge_precision,omitempty"`
+
MaxQuality int `yaml:"max_quality"`
Craft CraftList `yaml:"craft,omitempty"`