diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/baccarat.go | 3 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 17 | ||||
| -rw-r--r-- | internal/casino/craps.go | 26 | ||||
| -rw-r--r-- | internal/casino/craps_ux.go | 4 | ||||
| -rw-r--r-- | internal/casino/machine.go | 49 | ||||
| -rw-r--r-- | internal/casino/money_regression_test.go | 6 | ||||
| -rw-r--r-- | internal/casino/pai_gow.go | 31 | ||||
| -rw-r--r-- | internal/casino/roulette.go | 1 | ||||
| -rw-r--r-- | internal/casino/settlement.go | 153 | ||||
| -rw-r--r-- | internal/casino/settlement_test.go | 252 | ||||
| -rw-r--r-- | internal/casino/table_ux.go | 25 | ||||
| -rw-r--r-- | internal/casino/types.go | 12 | ||||
| -rw-r--r-- | internal/casino/video_poker.go | 17 |
13 files changed, 539 insertions, 57 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index e6af047..1e7ab23 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -198,7 +198,7 @@ func (t *baccaratTable) deal() []Event { // 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, Result: result, Stake: p.Wager.Total, Returned: 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" } @@ -212,6 +212,7 @@ 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} switch { case line.Tag != "": settlement.Color = line.Tag diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 3bc70b4..a059bdb 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -704,11 +704,21 @@ func (t *blackjackTable) settle() []Event { events = append(events, t.playerAction(p.Name, p.Name+"’s settlement is pending recovery.", payoutFailedMessage())) continue } - label := "" + label := "Hand" + if result == "blackjack" { + label = "Blackjack" + } if len(p.hands) > 1 { label = fmt.Sprintf("Hand %d/%d", i+1, len(p.hands)) } - lines = append(lines, settlementLine{Label: label, Result: result, Stake: hand.bet, Returned: payout}) + odds := "1:1" + if result == "blackjack" { + odds = payoutRatio(t.Rules.BlackjackPayout) + } + if result == "surrender" { + label += " (surrender)" + } + lines = append(lines, settlementLine{Label: label, Odds: odds, Result: result, Stake: hand.bet, Returned: payout}) } if p.insurance > 0 { insurancePayout := 0 @@ -719,7 +729,7 @@ func (t *blackjackTable) settle() []Event { if settleStakeOutcome(p.Wallet, insurancePayout, []string{p.insuranceStakeID}, insuranceErr) { events = append(events, t.private(p.Name, payoutFailedMessage())) } else { - lines = append(lines, settlementLine{Label: "Insurance", Result: "insurance", Stake: p.insurance, Returned: insurancePayout}) + lines = append(lines, settlementLine{Label: "Insurance", Odds: payoutRatio(t.Rules.InsurancePayout), Result: "insurance", Stake: p.insurance, Returned: insurancePayout}) } } // One settlement per player reports the round's net once and then @@ -727,6 +737,7 @@ func (t *blackjackTable) settle() []Event { // keeps the headline only; splits and insurance get the itemized list. if len(lines) > 0 { settlement := t.playerAction(p.Name, blackjackPublicResult(p.Name, lines), settlementText(lines)) + settlement.Settlement = lines settlement.ShowBalance = needsBalance settlement.Color = settlementTag(lines) events = append(events, settlement) diff --git a/internal/casino/craps.go b/internal/casino/craps.go index 95d3c21..69a38ed 100644 --- a/internal/casino/craps.go +++ b/internal/casino/craps.go @@ -293,19 +293,13 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { events = append(events, t.syncRoundClock()...) return append(events, t.betSummary(p), t.reminder(name)) } -func (t *crapsTable) pay(p *crapsPlayer, b *crapsBet, profit, den int, oddsProfit int, refundOdds bool) (settlementLine, bool) { +func (t *crapsTable) pay(p *crapsPlayer, b *crapsBet, profit, den int, oddsProfit int, refundOdds bool) bool { payout := crapsReturn(b, profit, den, oddsProfit, refundOdds) ids := []string{b.stake.ID} if b.odds.Total > 0 { ids = append(ids, b.odds.ID) } - line := settlementLine{Label: crapsBetLabel(b), Stake: b.stake.Total + b.odds.Total, Returned: payout, Result: "push"} - if payout > line.Stake { - line.Result = "win" - } else if payout < line.Stake { - line.Result = "loss" - } - return line, p.Wallet.PayCredits(payout, ids...) != nil + return p.Wallet.PayCredits(payout, ids...) != nil } func (t *crapsTable) roll() []Event { a, b := 0, 0 @@ -355,11 +349,10 @@ func (t *crapsTable) resolveRoll(a, b int) []Event { done, x, y := t.outcomeFor(bet, a, b) if done { refundOdds := oldPoint == 0 && bet.kind == "come" - line, err := t.pay(p, bet, x, y, x, refundOdds) - if err { + if t.pay(p, bet, x, y, x, refundOdds) { failed = true } else { - lines = append(lines, line) + lines = append(lines, t.crapsSettlementLines(bet, x, y, refundOdds, false)...) } continue } @@ -370,6 +363,9 @@ func (t *crapsTable) resolveRoll(a, b int) []Event { } p.bets = live p.HasBet = len(live) > 0 + for _, bet := range live { + lines = append(lines, t.crapsSettlementLines(bet, 0, 1, false, true)...) + } if len(lines) > 0 || failed { results = append(results, t.settlementEvent(p, lines, failed)) } @@ -563,15 +559,15 @@ func (t *crapsTable) markDisconnected(name string) []Event { if b.odds.Total > 0 { ids = append(ids, b.odds.ID) } - lines = append(lines, settlementLine{ - Label: crapsBetLabel(b), Stake: b.stake.Total + b.odds.Total, - Returned: 0, Result: "loss", - }) + lines = append(lines, t.crapsSettlementLines(b, -1, 1, false, false)...) } failed := false if p.Wallet != nil { failed = p.Wallet.PayCredits(0, ids...) != nil } + if failed { + lines = nil + } events = append(events, t.settlementEvent(p, lines, failed)) t.remove(name) events = append(events, t.syncRoundClock()...) diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go index 8cd5098..d0d8a62 100644 --- a/internal/casino/craps_ux.go +++ b/internal/casino/craps_ux.go @@ -134,7 +134,11 @@ func (t *crapsTable) settlementEvent(p *crapsPlayer, lines []settlementLine, fai text += payoutFailedMessage() } e := t.private(p.Name, text) + e.Settlement = lines if len(lines) > 0 { + if failed { + e.SettlementNote = payoutFailedMessage() + } e.Color = settlementTag(lines) } return e diff --git a/internal/casino/machine.go b/internal/casino/machine.go index aad6352..610e745 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -246,6 +246,11 @@ func (m *Machine) autoBetTick() []Event { // intact until the feature completes. func (m *Machine) finishSpin() []Event { events := []Event{} + if len(m.spin.Wins) > 0 { + matches := m.private(formatWins(m.spin.Wins, m.profile.Ways)) + matches[0].Color = "casino_win" + events = append(events, matches...) + } result := m.spin.Payout + m.payoutRemainder // Checked conversion: a runaway profile or overflow must clamp at the // economy cap instead of wrapping through a machine-sized int. A NaN or @@ -275,9 +280,9 @@ func (m *Machine) finishSpin() []Event { } payFailed := false - if payNow > 0 && m.player.Wallet != nil { - // Credit the payout and consume the triggering stake in ONE save: a - // crash can then never pay the win AND refund the same stake. + if (!inFeature || featureEnds) && m.player.Wallet != nil { + // Credit the return (including zero) and consume the triggering stake + // in ONE durable save before announcing any completed settlement. if err := m.player.Wallet.PayCredits(payNow, m.stakeID); err != nil { // The payout could not be made durable. Do not announce the win as // paid, stop any autobet chain, and keep the stake PENDING @@ -306,25 +311,33 @@ func (m *Machine) finishSpin() []Event { } if payNow > 0 && !payFailed { message := fmt.Sprintf("\nYou win %d credits.", payNow) - switch { - case inFeature && !featureEnds: - // Banked, not spent: say so, or the player would read the win - // as already in their balance. - message = fmt.Sprintf("\nYou win %d credits, paid when the free spins end.", payNow) - case featureEnds: + if featureEnds { message = fmt.Sprintf("\nThe free spins pay %d credits.", payNow) } winEvents := m.private(message) + label := "Spin" + if featureEnds { + label = "Spin + free spins" + } + winEvents[0].Settlement = []SettlementLine{{Label: label, Odds: "Varies", Stake: m.spinBet, Returned: payNow, Result: "win"}} winEvents[0].Color = "casino_result_win" if payNow > m.spinBet { winEvents[0].Color = "casino_big_win" } events = append(events, winEvents...) } - if len(m.spin.Wins) > 0 { - winEvents := m.private(formatWins(m.spin.Wins, m.profile.Ways)) - winEvents[0].Color = "casino_win" - events = append(events, winEvents...) + if !payFailed && payNow == 0 { + line := SettlementLine{Label: "Spin", Odds: "Varies", Stake: m.spinBet, Result: "loss"} + if inFeature { + line.Label = "Spin + free spins" + } + line.Unresolved = inFeature && !featureEnds + receipt := m.private(settlementText([]settlementLine{line})) + receipt[0].Settlement = []SettlementLine{line} + if line.Unresolved { + receipt[0].SettlementNote = fmt.Sprintf("%d credits banked; paid when free spins end.", int(m.featureBank)) + } + events = append(events, receipt...) } // A paid feature in progress never retriggers: scatters landing during a // free spin are ignored, matching the CalculateRTP model (the feature is @@ -348,10 +361,8 @@ func (m *Machine) finishSpin() []Event { } else { m.inFreeSpins = false if !payFailed && m.player.Wallet != nil { - // A win already consumed this round's stake inside PayCredits; a - // loss (payNow == 0) has not, so consume it here. Either way the - // round is over and the stake must not be refunded. - m.player.Wallet.ResolveStake(m.stakeID) + // PayCredits already consumed this round's stake, even on a loss. + // Never resolve older stakes retained after failed settlements. m.stakeID = "" m.stakeFailed = false } @@ -504,9 +515,9 @@ func stoppedScatters(grid [slotGridRows][slotGridReels]string, reels int, trigge } func formatWins(wins []SlotWin, ways int) string { - message := fmt.Sprintf("Winning lines/ways (%d-way evaluation):\n", ways) + message := fmt.Sprintf("Matches (%d-way evaluation):\n", ways) for _, win := range wins { - message += fmt.Sprintf(" %s x%d: %d ways x %.4gx bet / %d = %.4f credits\n", win.Symbol, win.Length, win.Ways, win.Multiplier, ways, win.Payout) + message += fmt.Sprintf(" %s x%d: %d ways\n", win.Symbol, win.Length, win.Ways) } return message[:len(message)-1] } diff --git a/internal/casino/money_regression_test.go b/internal/casino/money_regression_test.go index 3f1359e..2f56e0f 100644 --- a/internal/casino/money_regression_test.go +++ b/internal/casino/money_regression_test.go @@ -254,7 +254,7 @@ func TestSlotsStakeResolvesOnlyAfterFullRound(t *testing.T) { // would silently erase the failed round's evidence and drop the refund. func TestSlotsSecondSpinDoesNotResolveFailedPayoutStake(t *testing.T) { machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand()) - wallet := &failingPayoutWallet{testWallet{credits: 100}} + wallet := &recoveringSettlementWallet{testWallet: testWallet{credits: 100}, fail: true} machine.join("alice") machine.setBet("alice", 10, wallet) machine.spin = &SlotSpin{Payout: 20} // winning spin the wallet refuses to pay @@ -265,7 +265,9 @@ func TestSlotsSecondSpinDoesNotResolveFailedPayoutStake(t *testing.T) { t.Fatal("setup: a failed payout must leave the stake open") } - // A second, losing round must not resolve the first (still-open) stake. + // Storage recovers. A second, losing round must atomically resolve only + // its own stake, not the first (still-open) stake. + wallet.fail = false machine.setBet("alice", 10, wallet) machine.spin = &SlotSpin{} for i := 0; i < 20 && machine.phase != machineReady; i++ { diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go index 44893c1..0de79e3 100644 --- a/internal/casino/pai_gow.go +++ b/internal/casino/pai_gow.go @@ -76,6 +76,7 @@ type pgPlayer struct { payout int result string showdown *PaiGowShowdown + settlement []SettlementLine } type paiGowTable struct { *tableBase @@ -607,6 +608,11 @@ func (t *paiGowTable) prepareSettlementLocked() []Event { result := pgMainResultRules(p.split, t.dealerSplit, t.dealer, t.Config.PaiGow.Variant, t.tie, t.a2345High()) p.payout = pgMainPay(p.main.Total, result, t.Config.PaiGow.Variant) p.result = result + odds := "1:1" + if t.Config.PaiGow.Variant == PaiGowClassic { + odds = "19:20" + } + p.settlement = []SettlementLine{{Label: "Main", Odds: odds, Stake: p.main.Total, Returned: p.payout, Result: strings.Fields(result)[0]}} highRank := pgEvaluateRules(p.split.high, t.a2345High()) lowRank := pgEvaluateRules(p.split.low, t.a2345High()) dealerHighRank := pgEvaluateRules(t.dealerSplit.high, t.a2345High()) @@ -623,22 +629,32 @@ func (t *paiGowTable) prepareSettlementLocked() []Event { }, } if t.Config.PaiGow.Variant == PaiGowFaceUp { + paid, odds := 0, "Varies" rates, _ := t.Config.PaiGow.pushAceRates() if class := pgPushAceClassRules(t.dealer, p.cards, rates, t.a2345High()); class.klass != "none" { - p.payout += p.pushace.Total * (class.rate + 1) + paid = p.pushace.Total * (class.rate + 1) + odds = profitOdds(class.rate, 1) + p.payout += paid sd.PushAce = fmt.Sprintf("%s (%d:1)", class.desc, class.rate) sd.PushAceWin = true } else if p.pushace.Total > 0 { sd.PushAce = "dealer not ace-high" } + if p.pushace.Total > 0 { + p.settlement = append(p.settlement, SettlementLine{Label: "Push Ace", Odds: odds, Stake: p.pushace.Total, Returned: paid, Result: returnResult(p.pushace.Total, paid)}) + } } if p.fortune.Total > 0 { row, win := pgFortuneRules(p.cards, t.Config.PaiGow.Envy, t.a2345High()) sd.Fortune = row.Hand sd.FortuneWin = win + paid, odds := 0, "Varies" if win { - p.payout += p.fortune.Total * (row.Odds + 1) + paid = p.fortune.Total * (row.Odds + 1) + odds = profitOdds(row.Odds, 1) + p.payout += paid } + p.settlement = append(p.settlement, SettlementLine{Label: "Fortune", Odds: odds, Stake: p.fortune.Total, Returned: paid, Result: returnResult(p.fortune.Total, paid)}) if t.Config.PaiGow.Envy && p.fortune.Total >= 5 { envy, others := 0, 0 for _, other := range t.players { @@ -653,6 +669,7 @@ func (t *paiGowTable) prepareSettlementLocked() []Event { sd.ShowEnvy = true sd.Envy = envy p.payout += envy + p.settlement = append(p.settlement, SettlementLine{Label: "Envy bonus", Odds: "—", Returned: envy, Result: returnResult(0, envy)}) } } } @@ -663,7 +680,7 @@ func (t *paiGowTable) prepareSettlementLocked() []Event { return t.payLocked() } func (t *paiGowTable) payLocked() []Event { - var events []Event + var events, receipts []Event var newlyPaid []string pending := false for _, p := range t.players { @@ -688,8 +705,12 @@ func (t *paiGowTable) payLocked() []Event { continue } p.paid = true + receipt := t.private(p.name, settlementText(p.settlement)) + receipt.Settlement = append([]SettlementLine(nil), p.settlement...) + receipts = append(receipts, receipt) if p.showdown != nil { p.showdown.Paid = true + p.showdown.Pending = false } newlyPaid = append(newlyPaid, p.name) } @@ -697,7 +718,7 @@ func (t *paiGowTable) payLocked() []Event { for _, name := range newlyPaid { events = append(events, t.statusLocked(name, false)...) } - return events + return withSettlementReceipts(events, receipts) } t.phase = "waiting" kept := t.players[:0] @@ -714,7 +735,7 @@ func (t *paiGowTable) payLocked() []Event { for _, p := range t.players { events = append(events, t.statusLocked(p.name, false)...) } - return events + return withSettlementReceipts(events, receipts) } func (t *paiGowTable) tick() []Event { t.mu.Lock() diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index 163ed20..8949ff8 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -688,6 +688,7 @@ func (t *rouletteTable) settle() []Event { // departures/sit-outs need a balance attached to the settlement. event.ShowBalance = !failed && (p.LeaveAfter || p.SitOutAfter || !p.Connected) if !failed { + event.Settlement = []SettlementLine{{Label: p.lastSpot, Odds: rouletteSettlementOdds(wager, pocket), Stake: p.Wager.Total, Returned: payout, Result: result}} if result == "win" { event.Color = "casino_result_win" } else { diff --git a/internal/casino/settlement.go b/internal/casino/settlement.go new file mode 100644 index 0000000..46d3723 --- /dev/null +++ b/internal/casino/settlement.go @@ -0,0 +1,153 @@ +package casino + +import ( + "fmt" + "strings" +) + +// Attach receipts to the recipient's final board/menu where possible, so the +// renderer can place them between the reveal and the next-action prompt. +// Departing recipients still receive their standalone private receipt. +func withSettlementReceipts(events, receipts []Event) []Event { + for _, receipt := range receipts { + attached := false + for i := len(events) - 1; i >= 0; i-- { + if events[i].PrivateTo == receipt.PrivateTo && events[i].Snapshot != nil && !events[i].Public { + events[i].Settlement = receipt.Settlement + events[i].SettlementNote = receipt.SettlementNote + attached = true + break + } + } + if !attached { + events = append(events, receipt) + } + } + return events +} + +// profitOdds reduces an exact profit fraction without rounding currency. +func profitOdds(num, den int) string { + if den <= 0 { + return "Varies" + } + a, b := num, den + if a < 0 { + a = -a + } + for b != 0 { + a, b = b, a%b + } + if a == 0 { + return "0:1" + } + return fmt.Sprintf("%d:%d", num/a, den/a) +} + +func returnResult(stake, returned int) string { + switch { + case returned > stake: + return "win" + case returned < stake: + return "loss" + default: + return "push" + } +} + +func rouletteSettlementOdds(w rouletteWager, pocket string) string { + if s, ok := w.covers(pocket); ok { + return profitOdds(s.num-s.den, s.den) + } + odds := "" + for _, s := range w.shares { + next := profitOdds(s.num-s.den, s.den) + if odds != "" && odds != next { + return "Varies" + } + odds = next + } + if odds == "" { + return "Varies" + } + return odds +} + +func baccaratSettlementOdds(spot string, player, banker []card, rules BaccaratConfig) string { + rules = rules.Normalize() + switch spot { + case baccaratSpotPlayer: + return "1:1" + case baccaratSpotBanker: + if rules.Variant == BaccaratSuper6 && baccaratTotal(banker) > baccaratTotal(player) && baccaratTotal(banker) == 6 { + return "1:2" + } + return payoutRatio(rules.BankerPayout) + case baccaratSpotTie: + return payoutRatio(rules.TiePayout) + case "player_pair", "banker_pair": + return "11:1" + case "dragon_7": + return "40:1" + case "panda_8": + return "25:1" + case "super_6": + return "12:1" + default: + return "Varies" + } +} + +// For losing or active bets, inspect legal winning outcomes. Variable games +// (field, horn and variant pass lines) must not advertise one fixed rate. +func (t *crapsTable) settlementOdds(b *crapsBet) string { + odds := "" + for _, pair := range t.rollPairs() { + done, num, den := t.outcomeFor(b, pair[0], pair[1]) + if !done || num <= 0 { + continue + } + next := profitOdds(num, den) + if odds != "" && odds != next { + return "Varies" + } + odds = next + } + if odds == "" { + odds = "Varies" + } + if b.kind == "buy" || b.kind == "lay" { + odds += " fee" + } + return odds +} + +// Keep the base bet and its free-odds wager separate, including come-out +// refunds where the base loses but the odds push. +func (t *crapsTable) crapsSettlementLines(b *crapsBet, num, den int, refundOdds, active bool) []SettlementLine { + base := *b + base.odds = Wager{} + returned := crapsReturn(&base, num, den, num, false) + odds := t.settlementOdds(b) + if num > 0 && !active { + odds = profitOdds(num, den) + if b.kind == "buy" || b.kind == "lay" { + odds += " fee" + } + } + lines := []SettlementLine{{Label: crapsBetLabel(b), Odds: odds, Stake: b.stake.Total, Returned: returned, Result: returnResult(b.stake.Total, returned), Unresolved: active}} + if b.odds.Total > 0 { + x, y := crapsTrueOdds(b.number) + if strings.HasPrefix(b.kind, "dont") { + x, y = y, x + } + paid := 0 + if refundOdds { + paid = b.odds.Total + } else if num >= 0 { + paid = b.odds.Total + b.odds.Total/y*x + } + lines = append(lines, SettlementLine{Label: crapsBetLabel(b) + " odds", Odds: profitOdds(x, y), Stake: b.odds.Total, Returned: paid, Result: returnResult(b.odds.Total, paid), Unresolved: active}) + } + return lines +} diff --git a/internal/casino/settlement_test.go b/internal/casino/settlement_test.go new file mode 100644 index 0000000..9c110d6 --- /dev/null +++ b/internal/casino/settlement_test.go @@ -0,0 +1,252 @@ +package casino + +import ( + "strings" + "testing" +) + +type recoveringSettlementWallet struct { + testWallet + fail bool +} + +func (w *recoveringSettlementWallet) PayCredits(n int, ids ...string) error { + if w.fail { + return ErrWalletPersistence + } + return w.testWallet.PayCredits(n, ids...) +} + +func receiptLines(events []Event, name string) []SettlementLine { + var lines []SettlementLine + for _, e := range events { + if e.PrivateTo == name { + lines = append(lines, e.Settlement...) + } + } + return lines +} +func receiptTotal(lines []SettlementLine) (stake, returned int) { + for _, line := range lines { + if !line.Unresolved { + stake += line.Stake + returned += line.Returned + } + } + return +} + +func TestCasinoReceiptBlackjackSplitsInsuranceAndNatural(t *testing.T) { + tb := uxBlackjack() + p := tb.players.get("alice") + p.Wallet = &testWallet{} + p.hands = []*blackjackHand{ + {bet: 100, cards: []card{{rank: "A"}, {rank: "K"}}}, + {bet: 200, cards: []card{{rank: "10"}, {rank: "9"}}}, + {bet: 100, cards: []card{{rank: "10"}, {rank: "7"}}}, + } + p.insurance = 50 + lines := receiptLines(tb.settle(), "alice") + if len(lines) != 4 { + t.Fatalf("%+v", lines) + } + if lines[0].Odds != "3:2" || lines[0].Returned != 250 || lines[1].Returned != 400 || lines[2].Result != "push" || lines[3].Odds != "2:1" { + t.Fatalf("%+v", lines) + } + stake, paid := receiptTotal(lines) + if stake != 450 || paid != 750 { + t.Fatalf("stake=%d paid=%d", stake, paid) + } +} + +func TestCasinoReceiptRouletteAndBaccarat(t *testing.T) { + t.Run("roulette", func(t *testing.T) { + tb := newRouletteTable(1, TableConfig{ID: "r", Game: GameRoulette}, newTestRand()) + w := &testWallet{credits: 100} + tb.join("alice") + tb.bet("alice", "17", 10, w) + lines := receiptLines(tb.settle(), "alice") + if len(lines) != 1 || lines[0].Odds != "35:1" || lines[0].Returned != w.paid { + t.Fatalf("%+v paid=%d", lines, w.paid) + } + }) + t.Run("baccarat", func(t *testing.T) { + tb := newBaccaratTestTable(TableConfig{ID: "b", Game: GameBaccarat}) + w := &testWallet{credits: 100} + tb.join("alice") + events := tb.bet("alice", "banker", 10, w) + for i := 0; i < 30 && len(receiptLines(events, "alice")) == 0; i++ { + events = append(events, tb.tick()...) + } + lines := receiptLines(events, "alice") + if len(lines) != 1 || lines[0].Odds != "0.95:1" || lines[0].Returned != w.paid { + t.Fatalf("%+v paid=%d", lines, w.paid) + } + }) +} + +func TestCasinoReceiptCrapsActiveAndOddsRefund(t *testing.T) { + tb := crapsFixture() + w := &testWallet{credits: 1000} + tb.join("alice") + tb.bet("alice", "pass", 100, w) + tb.bet("alice", "field", 25, w) + lines := receiptLines(tb.resolveRoll(3, 3), "alice") + if len(lines) != 2 { + t.Fatalf("%+v", lines) + } + stake, paid := receiptTotal(lines) + if stake != 25 || paid != 0 { + t.Fatalf("%+v", lines) + } + active := 0 + for _, line := range lines { + if line.Unresolved { + active += line.Stake + } + } + if active != 100 { + t.Fatal(lines) + } + tb.bet("alice", "odds pass", 120, w) + lines = receiptLines(tb.resolveRoll(3, 3), "alice") + if len(lines) != 2 { + t.Fatalf("%+v", lines) + } + if lines[0].Odds != "1:1" || lines[1].Odds != "6:5" { + t.Fatal(lines) + } + stake, paid = receiptTotal(lines) + if stake != 220 || paid != 464 { + t.Fatalf("%+v", lines) + } + b := &crapsBet{kind: "come", number: 6, stake: Wager{Total: 10}, odds: Wager{Total: 60}} + lines = tb.crapsSettlementLines(b, -1, 1, true, false) + if lines[0].Result != "loss" || lines[1].Result != "push" || lines[1].Returned != 60 { + t.Fatal(lines) + } +} + +func TestCasinoReceiptCrapsPartialFailure(t *testing.T) { + tb := crapsFixture() + tb.join("alice") + w := &recoveringSettlementWallet{testWallet: testWallet{credits: 1000}} + tb.bet("alice", "pass", 100, w) + tb.bet("alice", "field", 25, w) + w.fail = true + events := tb.resolveRoll(3, 3) + lines := receiptLines(events, "alice") + if len(lines) != 1 || !lines[0].Unresolved { + t.Fatal(lines) + } + found := false + for _, e := range events { + found = found || e.SettlementNote != "" + } + if !found { + t.Fatal("failed wager warning lost") + } +} + +func TestCasinoReceiptSlotFeatureAndDurability(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "s", Game: GameSlots}, newTestRand()) + w := &recoveringSettlementWallet{testWallet: testWallet{credits: 100}} + m.join("alice") + m.beginSpinLocked(10, w, "", "") + m.spin = &SlotSpin{ScatterCount: 3, Payout: 20} + lines := receiptLines(m.finishSpin(), "alice") + if len(lines) != 1 || !lines[0].Unresolved || w.paid != 0 { + t.Fatal(lines, w.paid) + } + m.freeSpins = 0 + m.spin = &SlotSpin{Payout: 5} + lines = receiptLines(m.finishSpin(), "alice") + if len(lines) != 1 || lines[0].Unresolved || lines[0].Stake != 10 || lines[0].Returned != 25 || w.paid != 25 { + t.Fatal(lines, w.paid) + } + // A failed zero-return save must not announce a settled loss. + m.beginSpinLocked(10, w, "", "") + m.spin = &SlotSpin{} + w.fail = true + events := m.finishSpin() + if len(receiptLines(events, "alice")) != 0 || len(w.open) != 1 || !hasMessage(events, payoutFailedMessage()) { + t.Fatalf("events=%+v open=%v", events, w.open) + } +} + +func TestCasinoReceiptVideoPokerAndPaiGow(t *testing.T) { + t.Run("poker", func(t *testing.T) { + tb, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + pokerDeal(t, tb, w) + p := tb.players["alice"] + p.cards = pokerHand("Ah Kh Qh Jh 10h") + p.held = [5]bool{true, true, true, true, true} + stake := p.wager.Total + lines := receiptLines(tb.drawLocked("alice", p), "alice") + got, paid := receiptTotal(lines) + if len(lines) != p.hands || got != stake || paid != w.paid || paid == 0 || lines[0].Odds != "799:1" { + t.Fatalf("%+v stake=%d paid=%d", lines, stake, w.paid) + } + }) + t.Run("pai gow", func(t *testing.T) { + tb := pgTestTable(true) + w := &testWallet{credits: 1000} + tb.join("alice") + tb.bet("alice", "fortune", 5, w) + tb.bet("alice", "main", 100, w) + p := tb.player("alice") + p.split = pgHouse(p.cards) + p.ready = true + lines := receiptLines(tb.prepareSettlementLocked(), "alice") + stake, paid := receiptTotal(lines) + if len(lines) != 2 || stake != 105 || paid != w.paid || lines[0].Odds != "19:20" { + t.Fatalf("%+v paid=%d", lines, w.paid) + } + }) +} + +func TestCasinoReceiptPaiGowRetryAnnouncesExactlyOnce(t *testing.T) { + tb := pgTestTable(false) + w := &recoveringSettlementWallet{testWallet: testWallet{credits: 1000}} + tb.join("alice") + tb.bet("alice", "fortune", 5, w) + tb.bet("alice", "main", 100, w) + p := tb.player("alice") + p.split, p.ready = pgHouse(p.cards), true + w.fail = true + if lines := receiptLines(tb.prepareSettlementLocked(), "alice"); len(lines) != 0 { + t.Fatal(lines) + } + if !p.showdown.Pending { + t.Fatal("missing payment-pending status") + } + w.fail = false + lines := receiptLines(tb.payLocked(), "alice") + if len(lines) != 2 || p.showdown.Pending || !p.showdown.Paid { + t.Fatal(lines, p.showdown) + } + paid := w.paid + if len(receiptLines(tb.payLocked(), "alice")) != 0 || w.paid != paid { + t.Fatal("duplicate settlement on retry") + } +} + +func TestCasinoReceiptOddsAreProfitNotReturn(t *testing.T) { + for _, tc := range []struct { + num, den int + want string + }{{35, 1, "35:1"}, {3, 2, "3:2"}, {144, 120, "6:5"}, {0, 5, "0:1"}} { + if got := profitOdds(tc.num, tc.den); got != tc.want { + t.Fatalf("%s != %s", got, tc.want) + } + } + w, _ := rouletteWagerFor("orphelins", false, true) + if got := rouletteSettlementOdds(w, "0"); got != "Varies" { + t.Fatal(got) + } + for _, line := range crapsFixture().crapsSettlementLines(&crapsBet{kind: "buy", number: 4, stake: Wager{Total: 20}}, 2, 1, false, false) { + if !strings.Contains(line.Odds, "fee") || line.Returned != 59 { + t.Fatal(line) + } + } +} diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index 4251e4f..569128b 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -283,15 +283,26 @@ func (t *blackjackTable) insuranceMenuLocked(p *blackjackPlayer) Event { // the itemized line (empty for a lone anonymous hand); Result is the engine's // settlement word ("win", "loss", "bust", "push", "blackjack", // "surrender", "insurance"). -type settlementLine struct { - Label string - Result string - Stake int - Returned int - Tag string // optional color-category override (jackpot, for example) +type SettlementLine struct { + // Odds are profit odds, not total-return multipliers. Unresolved wagers + // retain their stake and are excluded from settlement totals. + Odds string + Unresolved bool + Label string + Result string + Stake int + Returned int + Tag string // optional color-category override (jackpot, for example) } -func (l settlementLine) net() int { return l.Returned - l.Stake } +type settlementLine = SettlementLine + +func (l SettlementLine) net() int { + if l.Unresolved { + return 0 + } + return l.Returned - l.Stake +} // resultTextLabel renders a settled wager as a colored outcome plus its net, // naming the bet on its own line. diff --git a/internal/casino/types.go b/internal/casino/types.go index 9c000b5..5f9de1d 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -282,10 +282,14 @@ type Event struct { // snapshot can never be assigned without implementing the interface, and // the event filter can test it with a single nil check instead of // enumerating every game's field. - Snapshot Snapshot - Menu *MenuView - ShowBalance bool // append the recipient's current available funds - Detail bool // action-by-action public output, only for participants/watchers + // Settlement is private to PrivateTo, never broadcast to spectators. + // Message/PrivateMessage remain a plain-text fallback for non-UI consumers. + Settlement []SettlementLine + SettlementNote string // warning displayed alongside a partial settlement + Snapshot Snapshot + Menu *MenuView + ShowBalance bool // append the recipient's current available funds + Detail bool // action-by-action public output, only for participants/watchers } // Snapshot is the marker interface for a game's structured event view. The diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go index cc4ccfa..a22d3c8 100644 --- a/internal/casino/video_poker.go +++ b/internal/casino/video_poker.go @@ -503,8 +503,23 @@ func (t *videoPokerTable) drawLocked(name string, p *pokerPlayer) []Event { } t.banks = banks p.active = false - p.result = fmt.Sprintf("Total return %d coins = %d credits", p.payout/p.denomination, p.payout) + p.result = "" events := t.statusLocked(name, false) + lines := make([]SettlementLine, 0, len(p.lines)) + for i, hand := range p.lines { + stake := p.wager.Total / p.hands + returned := hand.Coins * p.denomination + result := "loss" + if hand.Coins > 0 { + result = "win" + } + odds := "Varies" + if hand.Coins > 0 { + odds = profitOdds(returned-stake, stake) + } + lines = append(lines, SettlementLine{Label: fmt.Sprintf("Hand %d · %s", i+1, hand.Category), Odds: odds, Stake: stake, Returned: returned, Result: result}) + } + events[0].Settlement = lines if p.disconnected { delete(t.players, name) } |
