aboutsummaryrefslogtreecommitdiff
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
parent6d9908b29c8187196d23b126b1165c7e0bd0fc76 (diff)
downloadthehouseoficarus-8eea0e4e06a4aaa6a18edabd9897d3bea1ea2ca0.tar.gz
feat: consistent wager outcome table across casino games
-rw-r--r--data/help/casino.yaml16
-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
-rw-r--r--internal/game/casino.go21
-rw-r--r--internal/game/pai_gow_test.go2
-rw-r--r--internal/game/render_casino_settlement.go185
-rw-r--r--internal/game/render_casino_settlement_test.go161
-rw-r--r--internal/game/render_pai_gow.go15
-rw-r--r--internal/game/render_video_poker.go2
-rw-r--r--internal/game/video_poker_render_test.go2
21 files changed, 922 insertions, 78 deletions
diff --git a/data/help/casino.yaml b/data/help/casino.yaml
index 5f4e527..e361a47 100644
--- a/data/help/casino.yaml
+++ b/data/help/casino.yaml
@@ -18,10 +18,24 @@ description: |
Profit odds exclude your returned stake: a 10-credit win at 2:1
returns 30 total (20 profit + 10 stake). A push returns the stake;
- a loss returns nothing. Slots and video poker report total returns, not profit odds.
+ a loss returns nothing. Slots and video poker paytables use total-return
+ multipliers; the shared payout receipt uses profit odds instead.
Fractional table-game returns round down to whole credits, except
craps which requires exact payout units (commission rounds up).
+ Every game uses the same payout receipt: Wager, Staked, Odds, Result, Net.
+ All amounts are credits. Returned includes any returned stake; Net is
+ Returned minus the stake settled. WIN/LOSS/PUSH describes the game result;
+ a winning machine combination can still have zero or negative net profit.
+ Varies means there is no single fixed payout rate. Odds marked 'fee' are
+ before commission; the actual returned amount and net include that fee.
+
+ Active bets appear as UNRESOLVED with no net amount. They are excluded
+ from settled totals and listed in the Active subtotal. Craps free odds
+ have their own rows. Slot free-spin features remain unresolved until
+ their banked winnings are paid. Failed payments are reported separately;
+ a receipt never claims that an uncredited payout has been paid.
+
help roulette Number, color and combination bets.
help racetrack Voisins, Tiers, Orphelins, Zero and Neighbors.
help blackjack Beat the dealer without going over 21.
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)
}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index b15c965..8238b7c 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -546,7 +546,7 @@ func (g *Game) CasinoTick() {
func (g *Game) emitCasinoEvents(events []casino.Event) {
for _, event := range events {
- if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Snapshot == nil {
+ if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Snapshot == nil && len(event.Settlement) == 0 {
continue
}
category := event.Color
@@ -583,7 +583,7 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
if text == "" {
text = event.Message
}
- if text == "" && event.Menu == nil && event.Snapshot == nil {
+ if text == "" && event.Menu == nil && event.Snapshot == nil && len(event.Settlement) == 0 {
continue
}
g.charsMu.Lock()
@@ -595,10 +595,25 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, g.casinoTimeText(text))
privateMessage = g.renderCasinoMessage(sess, category, event.Menu, privateMessage)
rendered := g.renderCasinoSnapshot(sess, event.Snapshot)
+ if len(event.Settlement) > 0 {
+ receipt := renderCasinoSettlement(event.Settlement, sess.Player.OptionBool("unicode"), func(tag, text string) string { return g.colorize(sess, tag, text) })
+ if event.SettlementNote != "" {
+ receipt += "\n" + g.casinoProse(sess, "casino_warning", event.SettlementNote)
+ }
+ // Reveal first, then the receipt, then the next-action menu. A
+ // settlement's legacy prose is only a non-UI fallback, not a second payout.
+ rendered = composeTableThenReminder(rendered, receipt)
+ if event.Menu == nil {
+ privateMessage = ""
+ }
+ }
if reminder, ok := g.casinoBoardReminder(sess, category, event.Menu, event.Snapshot, g.casinoTimeText(text)); ok {
privateMessage = composeTableThenReminder(rendered, reminder)
} else if rendered != "" {
- privateMessage += "\n" + rendered
+ if privateMessage != "" {
+ privateMessage += "\n"
+ }
+ privateMessage += rendered
}
if event.ShowBalance {
privateMessage += "\n" + g.casinoBalance(sess)
diff --git a/internal/game/pai_gow_test.go b/internal/game/pai_gow_test.go
index b8a9c22..f0440b7 100644
--- a/internal/game/pai_gow_test.go
+++ b/internal/game/pai_gow_test.go
@@ -142,7 +142,7 @@ func TestPaiGowShowdownOrderNamesAndColors(t *testing.T) {
if !(high >= 0 && high < dealerHigh && dealerHigh < low && low < dealerLow) {
t.Fatalf("showdown order wrong:\n%s", clean)
}
- for _, want := range []string{"STRAIGHT ACE-HIGH", "TWO PAIR ACES AND KINGS", "PAIR OF ACES", "HIGH CARD KING", "DEALER WINS THE ROUND", "Net +25 credits."} {
+ for _, want := range []string{"STRAIGHT ACE-HIGH", "TWO PAIR ACES AND KINGS", "PAIR OF ACES", "HIGH CARD KING", "DEALER WINS THE ROUND"} {
if !strings.Contains(clean, want) {
t.Fatalf("showdown missing %q:\n%s", want, clean)
}
diff --git a/internal/game/render_casino_settlement.go b/internal/game/render_casino_settlement.go
new file mode 100644
index 0000000..bfe101c
--- /dev/null
+++ b/internal/game/render_casino_settlement.go
@@ -0,0 +1,185 @@
+package game
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+// renderCasinoSettlement is shared by every game. Paint is supplied by the
+// recipient's palette; widths are measured before adding ANSI sequences.
+// The receipt fits the MUD's minimum 80-column wrap width. Long labels wrap
+// rather than losing the hand number or wager identity through truncation.
+func renderCasinoSettlement(lines []casino.SettlementLine, unicode bool, paint func(string, string) string) string {
+ if len(lines) == 0 {
+ return ""
+ }
+ dash := "—"
+ h, v, tl, tr, bl, br, tj, bj, lj, rj, cross := "─", "│", "╭", "╮", "╰", "╯", "┬", "┴", "├", "┤", "┼"
+ if !unicode {
+ dash = "-"
+ h, v, tl, tr, bl, br, tj, bj, lj, rj, cross = "-", "|", "+", "+", "+", "+", "+", "+", "+", "+", "+"
+ }
+ type row struct {
+ cells [5]string
+ resultTag, netTag string
+ }
+ rows := make([]row, 0, len(lines))
+ widths := [5]int{5, 6, 4, 6, 3}
+ var staked, returned, active int64
+ settled := 0
+ netTag := func(net int64) string {
+ if net > 0 {
+ return "casino_result_win"
+ }
+ if net < 0 {
+ return "casino_result_loss"
+ }
+ return "casino_result_push"
+ }
+ for _, line := range lines {
+ label, odds := line.Label, line.Odds
+ if label == "" {
+ label = "Wager"
+ }
+ if odds == "" {
+ odds = "Varies"
+ }
+ if !unicode {
+ label = strings.NewReplacer("·", "-", "—", "-").Replace(label)
+ odds = strings.ReplaceAll(odds, "—", "-")
+ }
+ net := int64(line.Returned) - int64(line.Stake)
+ result, rt := "LOSS", "casino_result_loss"
+ switch {
+ case strings.HasPrefix(line.Result, "win"), line.Result == "blackjack":
+ result, rt = "WIN", "casino_result_win"
+ case strings.HasPrefix(line.Result, "push"):
+ result, rt = "PUSH", "casino_result_push"
+ case line.Result == "insurance":
+ rt = netTag(net)
+ if net > 0 {
+ result = "WIN"
+ } else if net == 0 {
+ result = "PUSH"
+ }
+ }
+ nt, amount := netTag(net), fmt.Sprintf("%+d", net)
+ if net == 0 {
+ amount = "0"
+ }
+ if line.Unresolved {
+ result, rt, nt, amount = "UNRESOLVED", "warning", "warning", dash
+ active += int64(line.Stake)
+ } else {
+ staked += int64(line.Stake)
+ returned += int64(line.Returned)
+ settled++
+ }
+ r := row{[5]string{label, strconv.Itoa(line.Stake), odds, result, amount}, rt, nt}
+ rows = append(rows, r)
+ for i, s := range r.cells {
+ widths[i] = max(widths[i], color.VisibleLen(s))
+ }
+ }
+ widths[0] = min(widths[0], 24)
+ widths[2] = min(widths[2], 10)
+ // Borders and cell padding occupy sixteen columns.
+ widths[0] = min(widths[0], 79-16-widths[1]-widths[2]-widths[3]-widths[4])
+ widths[0] = max(widths[0], 5)
+ inner := 4
+ for _, w := range widths {
+ inner += w + 2
+ }
+ // A readable footer without forcing tiny one-wager receipts into a wide box.
+ inner = max(inner, 50)
+ sum := 4
+ for _, w := range widths {
+ sum += w + 2
+ }
+ widths[0] += inner - sum
+ border := func(left, join, right string) string {
+ parts := make([]string, 5)
+ for i, w := range widths {
+ parts[i] = strings.Repeat(h, w+2)
+ }
+ return paint("dim", left+strings.Join(parts, join)+right)
+ }
+ pad := func(s string, w int, right bool) string {
+ spaces := strings.Repeat(" ", max(0, w-color.VisibleLen(s)))
+ if right {
+ return spaces + s
+ }
+ return s + spaces
+ }
+ formatRow := func(cells [5]string, rt, nt string) string {
+ parts := make([]string, 5)
+ for i, s := range cells {
+ tag := "casino_action"
+ if i == 3 {
+ tag = rt
+ }
+ if i == 4 {
+ tag = nt
+ }
+ parts[i] = " " + paint(tag, pad(s, widths[i], i == 1 || i == 2 || i == 4)) + " "
+ }
+ return paint("dim", v) + strings.Join(parts, paint("dim", v)) + paint("dim", v)
+ }
+ out := []string{border(tl, tj, tr), formatRow([5]string{"Wager", "Staked", "Odds", "Result", "Net"}, "casino_action", "casino_action"), border(lj, cross, rj)}
+ for _, r := range rows {
+ // Wrapping uses runes, matching the existing MUD visible-width contract.
+ remaining := r.cells
+ for {
+ var cells [5]string
+ more := false
+ for i, s := range remaining {
+ runes := []rune(strings.Join(strings.Fields(s), " "))
+ n := min(len(runes), widths[i])
+ cells[i] = string(runes[:n])
+ remaining[i] = string(runes[n:])
+ more = more || len(runes) > n
+ }
+ out = append(out, formatRow(cells, r.resultTag, r.netTag))
+ if !more {
+ break
+ }
+ }
+ }
+ out = append(out, border(lj, bj, rj))
+ footer := func(text, tag string) {
+ out = append(out, paint("dim", v)+" "+paint(tag, pad(text, inner-2, false))+" "+paint("dim", v))
+ }
+ summary := fmt.Sprintf("Staked: %d Returned: %d", staked, returned)
+ if active > 0 {
+ summary = fmt.Sprintf("Settled: %d Returned: %d Active: %d", staked, returned, active)
+ }
+ // Large multi-hand totals can exceed one footer line; never wrap the frame.
+ for color.VisibleLen(summary) > inner-2 {
+ cut := strings.LastIndex(summary[:inner-2], " ")
+ if cut < 0 {
+ cut = inner - 2
+ }
+ footer(summary[:cut], "casino_action")
+ summary = strings.TrimSpace(summary[cut:])
+ }
+ footer(summary, "casino_action")
+ net := returned - staked
+ title, amount := "NET EVEN", "0 credits"
+ if net > 0 {
+ title, amount = "NET PROFIT", fmt.Sprintf("+%d credits", net)
+ }
+ if net < 0 {
+ title, amount = "NET LOSS", fmt.Sprintf("%d credits", net)
+ }
+ tag := netTag(net)
+ if settled == 0 {
+ title, amount, tag = "NET PENDING", dash, "warning"
+ }
+ footer(title+strings.Repeat(" ", max(1, inner-2-len(title)-color.VisibleLen(amount)))+amount, tag)
+ out = append(out, paint("dim", bl+strings.Repeat(h, inner)+br))
+ return strings.Join(out, "\n")
+}
diff --git a/internal/game/render_casino_settlement_test.go b/internal/game/render_casino_settlement_test.go
new file mode 100644
index 0000000..f26a174
--- /dev/null
+++ b/internal/game/render_casino_settlement_test.go
@@ -0,0 +1,161 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func plainReceipt(_ string, text string) string { return text }
+
+func TestCasinoReceiptAccountingAndBorders(t *testing.T) {
+ lines := []casino.SettlementLine{
+ {Label: "Pass line", Stake: 100, Returned: 200, Odds: "1:1", Result: "win"},
+ {Label: "Field", Stake: 25, Odds: "Varies", Result: "loss"},
+ {Label: "Place 6", Stake: 60, Odds: "7:6", Unresolved: true},
+ {Label: "Push", Stake: 10, Returned: 10, Odds: "1:1", Result: "push"},
+ }
+ for _, unicode := range []bool{true, false} {
+ got := renderCasinoSettlement(lines, unicode, plainReceipt)
+ for _, want := range []string{"Wager", "Staked", "Odds", "WIN", "LOSS", "PUSH", "UNRESOLVED", "Settled: 135", "Returned: 210", "Active: 60", "NET PROFIT", "+75 credits"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("missing %q:\n%s", want, got)
+ }
+ }
+ if strings.Contains(got, "PAYOUT") || strings.Contains(got, "-60") {
+ t.Fatal(got)
+ }
+ width := color.VisibleLen(strings.Split(got, "\n")[0])
+ for _, line := range strings.Split(got, "\n") {
+ if color.VisibleLen(line) != width || width > 79 {
+ t.Fatalf("misaligned width=%d:\n%s", width, got)
+ }
+ if !unicode {
+ for _, r := range line {
+ if r > 127 {
+ t.Fatalf("non-ASCII %q", line)
+ }
+ }
+ }
+ }
+ }
+}
+
+func TestCasinoReceiptColorIsIndependentOfGameResult(t *testing.T) {
+ var tags []string
+ got := renderCasinoSettlement([]casino.SettlementLine{{Label: "Bonus hand", Stake: 10, Returned: 5, Odds: "Varies", Result: "win"}}, true, func(tag, text string) string {
+ tags = append(tags, tag+":"+strings.TrimSpace(text))
+ return color.Render("ansi", color.ColorSpec{Fg: 2, Bg: -1}, text)
+ })
+ for _, want := range []string{"casino_result_win:WIN", "casino_result_loss:-5"} {
+ found := false
+ for _, tag := range tags {
+ found = found || tag == want
+ }
+ if !found {
+ t.Fatalf("missing %q: %v", want, tags)
+ }
+ }
+ width := color.VisibleLen(strings.Split(got, "\n")[0])
+ for _, line := range strings.Split(got, "\n") {
+ if color.VisibleLen(line) != width {
+ t.Fatal(got)
+ }
+ }
+}
+
+func TestCasinoReceiptLargeTotalsAndLongLabels(t *testing.T) {
+ var lines []casino.SettlementLine
+ for i := 1; i <= 100; i++ {
+ lines = append(lines, casino.SettlementLine{Label: fmt.Sprintf("Hand %d - Four aces with a long kicker description", i), Stake: 1000000000, Returned: 2000000000, Odds: "19:20", Result: "win"})
+ }
+ lines = append(lines, casino.SettlementLine{Label: "Active", Stake: 1000000000, Odds: "1:1", Unresolved: true})
+ got := renderCasinoSettlement(lines, true, plainReceipt)
+ for _, want := range []string{"Hand 100", "100000000000", "200000000000", "+100000000000 credits"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("missing %s", want)
+ }
+ }
+ width := color.VisibleLen(strings.Split(got, "\n")[0])
+ for _, line := range strings.Split(got, "\n") {
+ if color.VisibleLen(line) != width || width > 79 {
+ t.Fatalf("width %d; bad line %q", width, line)
+ }
+ }
+}
+
+func TestCasinoReceiptRevealThenReceiptThenMenu(t *testing.T) {
+ p := player.New("alice")
+ p.Options = map[string]any{"color": "none", "unicode": false}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: p, Conn: conn}
+ g := &Game{Deps: Deps{World: world.New(t.TempDir())}, Combat: combat.NewTracker(), loggedInChars: map[string]*net.Session{"alice": sess}}
+ g.emitCasinoEvents([]casino.Event{{PrivateTo: "alice", Color: "casino_menu",
+ Snapshot: &casino.VideoPokerSnapshot{Config: (casino.VideoPokerConfig{}).Normalize(), Result: "REVEAL MARKER"},
+ Menu: &casino.MenuView{Heading: "NEXT ACTION MARKER"},
+ Settlement: []casino.SettlementLine{{Label: "Hand", Stake: 10, Returned: 20, Odds: "1:1", Result: "win"}},
+ }})
+ got := string(conn.data)
+ reveal, receipt, menu := strings.Index(got, "REVEAL MARKER"), strings.Index(got, "NET PROFIT"), strings.Index(got, "NEXT ACTION MARKER")
+ if reveal < 0 || receipt < reveal || menu < receipt {
+ t.Fatal(got)
+ }
+}
+
+func TestCasinoReceiptIsPrivateEvenOnPublicResult(t *testing.T) {
+ hub := net.NewHub()
+ g := &Game{Hub: hub, Casino: casino.NewManager(1), loggedInChars: map[string]*net.Session{}}
+ conns := map[string]*casinoTestConn{}
+ for _, name := range []string{"alice", "bob"} {
+ p := player.New(name)
+ p.Options = map[string]any{"color": "none"}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: p, Conn: conn}
+ hub.Add(sess)
+ hub.EnterRoom(sess, 1)
+ g.loggedInChars[name] = sess
+ conns[name] = conn
+ }
+ g.emitCasinoEvents([]casino.Event{{RoomID: 1, TableID: "test", Public: true, Message: "Alice wins.", PrivateTo: "alice", Settlement: []casino.SettlementLine{{Label: "SECRET WAGER", Stake: 10, Returned: 20, Result: "win"}}}})
+ if !strings.Contains(string(conns["alice"].data), "SECRET WAGER") || strings.Contains(string(conns["bob"].data), "SECRET WAGER") || !strings.Contains(string(conns["bob"].data), "Alice wins.") {
+ t.Fatalf("alice=%s bob=%s", conns["alice"].data, conns["bob"].data)
+ }
+}
+
+func TestCasinoReceiptUnresolvedIsNotPush(t *testing.T) {
+ got := renderCasinoSettlement([]casino.SettlementLine{{Label: "Pass line", Stake: 100, Unresolved: true}}, false, plainReceipt)
+ if !strings.Contains(got, "NET PENDING") || strings.Contains(got, "NET EVEN") || strings.Contains(got, "PUSH") {
+ t.Fatal(got)
+ }
+}
+
+func TestCasinoSettlementReplacesFallbackAndPreservesPartialFailure(t *testing.T) {
+ p := player.New("alice")
+ p.Options = map[string]any{"color": "none", "unicode": false}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: p, Conn: conn}
+ g := &Game{loggedInChars: map[string]*net.Session{"alice": sess}}
+ g.emitCasinoEvents([]casino.Event{{PrivateTo: "alice", PrivateMessage: "OLD PAYOUT", Settlement: []casino.SettlementLine{{Label: "Blackjack", Odds: "3:2", Stake: 100, Returned: 250, Result: "blackjack"}}, SettlementNote: "Another wager is pending recovery."}})
+ got := string(conn.data)
+ for _, want := range []string{"Blackjack", "3:2", "+150 credits", "pending recovery"} {
+ if !strings.Contains(got, want) {
+ t.Fatal(got)
+ }
+ }
+ if strings.Contains(got, "OLD PAYOUT") || strings.Contains(got, "\x1b[") || strings.Contains(got, "╭") {
+ t.Fatal(got)
+ }
+ conn.data = nil
+ // Structured-only events must survive the empty-message filters.
+ g.emitCasinoEvents([]casino.Event{{PrivateTo: "alice", Settlement: []casino.SettlementLine{{Label: "Spin", Stake: 10, Result: "loss"}}}})
+ if !strings.Contains(string(conn.data), "NET LOSS") {
+ t.Fatal(string(conn.data))
+ }
+}
diff --git a/internal/game/render_pai_gow.go b/internal/game/render_pai_gow.go
index b125a68..390a53f 100644
--- a/internal/game/render_pai_gow.go
+++ b/internal/game/render_pai_gow.go
@@ -176,21 +176,8 @@ func paiGowOutcome(sd *casino.PaiGowShowdown, mode string) []string {
lines = append(lines, "Push Ace High: "+sd.PushAce+".")
}
}
- if sd.ShowEnvy && sd.Envy > 0 {
- lines = append(lines, "Envy: "+color.Render(mode, paiGowWin, fmt.Sprintf("+%d credits", sd.Envy)))
- }
- switch {
- case sd.Pending:
+ if sd.Pending {
lines = append(lines, color.Render(mode, paiGowLoss, "PAYMENT PENDING — type settle to retry the same result."))
- case sd.Paid:
- net := sd.Payout - sd.Stake
- netSpec, netText := paiGowPush, "Net 0 credits."
- if net > 0 {
- netSpec, netText = paiGowWin, fmt.Sprintf("Net +%d credits.", net)
- } else if net < 0 {
- netSpec, netText = paiGowLoss, fmt.Sprintf("Net %d credits.", net)
- }
- lines = append(lines, color.Render(mode, netSpec, netText))
}
return lines
}
diff --git a/internal/game/render_video_poker.go b/internal/game/render_video_poker.go
index b8262c8..719b36a 100644
--- a/internal/game/render_video_poker.go
+++ b/internal/game/render_video_poker.go
@@ -168,7 +168,7 @@ func renderVideoPoker(s *casino.VideoPokerSnapshot, mode string, unicode bool, s
text := "No win"
fg := 15
if line.Coins > 0 {
- text = fmt.Sprintf("WIN: %s — %d coins", line.Category, line.Coins)
+ text = line.Category
fg = pokerYellow
}
if line.Multiplier > 1 {
diff --git a/internal/game/video_poker_render_test.go b/internal/game/video_poker_render_test.go
index 8195e21..88c8474 100644
--- a/internal/game/video_poker_render_test.go
+++ b/internal/game/video_poker_render_test.go
@@ -86,7 +86,7 @@ func TestPokerResultsBelowCardsAndHoldStyle(t *testing.T) {
line.Category = "Royal flush"
line.Coins = 4000
fg = pokerYellow
- text = "WIN: Royal flush — 4000 coins"
+ text = "Royal flush"
}
snap := &casino.VideoPokerSnapshot{Config: cfg, Hands: hands, Lines: []casino.PokerLine{line}, Multiplier: 1}
out := renderVideoPoker(snap, "xterm256", true, 7, "", nil)