From d3a305d1d7962f82af2397760cf8008758bddd1d Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 2 Oct 2026 01:54:37 -0400 Subject: feat: better 'list' bet table, multiple bets in baccarat --- AGENTS.md | 3 + data/help/baccarat.yaml | 6 +- data/help/ez_baccarat.yaml | 7 +- data/help/super6_baccarat.yaml | 3 +- data/items/misc/house_edge_computus.yaml | 8 + data/items/misc/house_edge_tile.yaml | 8 + data/rooms/casino/6100.yaml | 7 + internal/casino/baccarat.go | 363 +++++++++++++++++++++++---- internal/casino/baccarat_multi_wager_test.go | 118 +++++++++ internal/casino/baccarat_rules.go | 2 +- internal/casino/baccarat_variants_test.go | 5 +- internal/casino/baccarat_wagers.go | 6 +- internal/casino/house_edge.go | 138 ++++++++++ internal/casino/house_edge_baccarat.go | 131 ++++++++++ internal/casino/house_edge_blackjack.go | 182 ++++++++++++++ internal/casino/house_edge_craps.go | 136 ++++++++++ internal/casino/house_edge_fire.go | 61 +++++ internal/casino/house_edge_pai_gow.go | 55 ++++ internal/casino/house_edge_rules_test.go | 148 +++++++++++ internal/casino/house_edge_test.go | 127 ++++++++++ internal/casino/house_edge_video_poker.go | 82 ++++++ internal/casino/manager.go | 9 + internal/casino/registry.go | 11 +- internal/casino/table.go | 10 + internal/casino/table_hints.go | 12 +- internal/casino/table_ux.go | 7 +- internal/casino/table_ux_test.go | 2 +- internal/game/casino.go | 6 + internal/game/casino_edge_list_test.go | 97 +++++++ internal/game/casino_house_edge.go | 190 ++++++++++++++ internal/game/casino_rules_list_test.go | 84 +++++-- internal/game/casino_ux.go | 41 ++- internal/game/house_edge_test.go | 214 ++++++++++++++++ internal/game/render_baccarat_felt.go | 2 +- internal/game/render_baccarat_felt_test.go | 2 +- internal/game/video_poker_test.go | 2 +- internal/item/house_edge_test.go | 29 +++ internal/item/item.go | 4 + 38 files changed, 2208 insertions(+), 110 deletions(-) create mode 100644 AGENTS.md create mode 100644 data/items/misc/house_edge_computus.yaml create mode 100644 data/items/misc/house_edge_tile.yaml create mode 100644 internal/casino/baccarat_multi_wager_test.go create mode 100644 internal/casino/house_edge.go create mode 100644 internal/casino/house_edge_baccarat.go create mode 100644 internal/casino/house_edge_blackjack.go create mode 100644 internal/casino/house_edge_craps.go create mode 100644 internal/casino/house_edge_fire.go create mode 100644 internal/casino/house_edge_pai_gow.go create mode 100644 internal/casino/house_edge_rules_test.go create mode 100644 internal/casino/house_edge_test.go create mode 100644 internal/casino/house_edge_video_poker.go create mode 100644 internal/game/casino_edge_list_test.go create mode 100644 internal/game/casino_house_edge.go create mode 100644 internal/game/house_edge_test.go create mode 100644 internal/item/house_edge_test.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..62ca10c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +./building_guide contains documentation for human builders on how to use worldbuilding tools. DO NOT create new documents here unless specifically asked. It is NOT meant to describe how all game systems work. + +Do not update AGENTS.md diff --git a/data/help/baccarat.yaml b/data/help/baccarat.yaml index f5e4aad..658e023 100644 --- a/data/help/baccarat.yaml +++ b/data/help/baccarat.yaml @@ -8,7 +8,8 @@ description: | play baccarat; bet 10 on banker (or player / tie). You bet on a hand, not on your own cards. Highest last digit of the total wins: aces = 1, 2-9 = face value, 10/J/Q/K = 0. Drawing is - automatic; there are no hit/stand decisions. One wager per round. + automatic; there are no hit/stand decisions. Place one main bet + (player, banker or tie) and any offered side bets each round. Player Pays 1:1 profit; ties push. Banker Normally pays 0.95:1 after 5% commission; ties push. @@ -24,7 +25,8 @@ description: | panda_8 Player wins with a three-card 8: 25:1. super_6 Banker wins with 6: 12:1. - Side bets settle independently; a pair can win even on a tie. + Side bets must be placed before your main bet; a bare "bet" repeats + your last side bets and main bet together. Higher payouts do not mean better value: rare events win less often. Typing rules at the table shows this page again; table shows the felt and this table's exact payouts and available bets. diff --git a/data/help/ez_baccarat.yaml b/data/help/ez_baccarat.yaml index bfa51c3..c0f25a3 100644 --- a/data/help/ez_baccarat.yaml +++ b/data/help/ez_baccarat.yaml @@ -17,6 +17,7 @@ description: | table / list / rules The table / bets and payouts / this page stop / sitout Leave / sit out after this round - One wager per round. Highest last digit of the total wins (aces = 1, - tens = 0); drawing is automatic. help baccarat covers the shared - rules. + One main bet (player, banker or tie) plus any offered side bets; side + bets go down before the main bet. Highest last digit of the total wins + (aces = 1, tens = 0); drawing is automatic. help baccarat covers the + shared rules. diff --git a/data/help/super6_baccarat.yaml b/data/help/super6_baccarat.yaml index f8940b8..cd16cb8 100644 --- a/data/help/super6_baccarat.yaml +++ b/data/help/super6_baccarat.yaml @@ -16,6 +16,7 @@ description: | table / list / rules The table / bets and payouts / this page stop / sitout Leave / sit out after this round - One wager per round. Player bets still lose when a Banker 6 halves. + One main bet (player, banker or tie) plus any offered side bets; side + bets go down before the main bet. Player bets still lose when a Banker 6 halves. Highest last digit of the total wins (aces = 1, tens = 0); drawing is automatic. help baccarat covers the shared rules. diff --git a/data/items/misc/house_edge_computus.yaml b/data/items/misc/house_edge_computus.yaml new file mode 100644 index 0000000..a5b8ac3 --- /dev/null +++ b/data/items/misc/house_edge_computus.yaml @@ -0,0 +1,8 @@ +name: House Edge Computus +description: A brass-and-glass calculating engine the size of a pocket watch. + While carried, its meshed gears reveal each wager's return to player when you + list the bets at a casino game, reporting to hundredths of a percent. +color: "89" +value: 12000 +recoverable: true +house_edge_precision: 2 diff --git a/data/items/misc/house_edge_tile.yaml b/data/items/misc/house_edge_tile.yaml new file mode 100644 index 0000000..6fb591f --- /dev/null +++ b/data/items/misc/house_edge_tile.yaml @@ -0,0 +1,8 @@ +name: House Edge Tile +description: A matte ceramic tile the size of a card. While carried, its glaze + reveals each wager's return to player when you list the bets at a casino game, + rounded to a whole percent. +color: "B3" +value: 2500 +recoverable: true +house_edge_precision: 0 diff --git a/data/rooms/casino/6100.yaml b/data/rooms/casino/6100.yaml index bcde393..e0734b5 100644 --- a/data/rooms/casino/6100.yaml +++ b/data/rooms/casino/6100.yaml @@ -17,3 +17,10 @@ objects: no wager is needed to explore.' inroom_description: A wayboard stands where travelers can read it. - id: bank_booth +item_spawns: +- id: house_edge_tile + quantity: 1 + respawn_ticks: 100 +- id: house_edge_computus + quantity: 1 + respawn_ticks: 100 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 ", 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 on ", 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 on "}, + 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 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 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 ", "odds dontpass", "odds dontcome ": + return edge(0) + case "buy ", "lay ": + 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 ": + 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< 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}, "", 1.0 / 37}, + {"roulette even money", TableConfig{Game: GameRoulette}, "red / black", 1.0 / 37}, + {"roulette DZ straight", TableConfig{Game: GameRoulette, Roulette: RouletteConfig{DoubleZero: true}}, "", 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 ") + 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 ", 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 on ", Desc: "One wager per round; targets: list"}, + MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet)}, + MenuCommand{Command: "bet on "}, 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 [on] | bet min|max [on] . // 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, "") { + entry.bet = strings.ReplaceAll(wager.Bet, "", 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 on pass", "bet on dontpass", "bet on field", "bet on craps", "bet 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 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 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 "] { 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 on ", "bet on red / black", "bet on voisins", "bet on basket"} { - if !strings.Contains(text, want) { + targets := listTargets(text) + for _, want := range []string{"", "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 on banker (b)", "1:1 (3-card 7 pushes)", "bet on dragon_7", "bet 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 (or bet max), then spin or autospin.")) + row := casinoListRow{bet: "bet / 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 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 ") + " (" + 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 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 " + g.showCasinoListTable(sess, "", list) + return } + g.showCasinoListTable(sess, "bet on ", 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 on") || !strings.Contains(text, "Available actions") { + if strings.Contains(text, "bet 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"` -- cgit v1.2.3