aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-01 22:47:41 -0400
committerhistoria <[not public]>2026-10-01 22:47:41 -0400
commit8eea0e4e06a4aaa6a18edabd9897d3bea1ea2ca0 (patch)
tree63698c0618f382a553d4558a33e3d2087039c378 /internal/casino
parent6d9908b29c8187196d23b126b1165c7e0bd0fc76 (diff)
downloadthehouseoficarus-8eea0e4e06a4aaa6a18edabd9897d3bea1ea2ca0.tar.gz
feat: consistent wager outcome table across casino games
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat.go3
-rw-r--r--internal/casino/blackjack.go17
-rw-r--r--internal/casino/craps.go26
-rw-r--r--internal/casino/craps_ux.go4
-rw-r--r--internal/casino/machine.go49
-rw-r--r--internal/casino/money_regression_test.go6
-rw-r--r--internal/casino/pai_gow.go31
-rw-r--r--internal/casino/roulette.go1
-rw-r--r--internal/casino/settlement.go153
-rw-r--r--internal/casino/settlement_test.go252
-rw-r--r--internal/casino/table_ux.go25
-rw-r--r--internal/casino/types.go12
-rw-r--r--internal/casino/video_poker.go17
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)
}