diff options
Diffstat (limited to 'internal')
| -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 | ||||
| -rw-r--r-- | internal/game/casino.go | 21 | ||||
| -rw-r--r-- | internal/game/pai_gow_test.go | 2 | ||||
| -rw-r--r-- | internal/game/render_casino_settlement.go | 185 | ||||
| -rw-r--r-- | internal/game/render_casino_settlement_test.go | 161 | ||||
| -rw-r--r-- | internal/game/render_pai_gow.go | 15 | ||||
| -rw-r--r-- | internal/game/render_video_poker.go | 2 | ||||
| -rw-r--r-- | internal/game/video_poker_render_test.go | 2 |
20 files changed, 907 insertions, 77 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) } 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) |
