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