diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/baccarat.go | 45 | ||||
| -rw-r--r-- | internal/casino/baccarat_test.go | 8 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 95 | ||||
| -rw-r--r-- | internal/casino/blackjack_test.go | 8 | ||||
| -rw-r--r-- | internal/casino/core.go | 34 | ||||
| -rw-r--r-- | internal/casino/machine.go | 4 | ||||
| -rw-r--r-- | internal/casino/machine_test.go | 2 | ||||
| -rw-r--r-- | internal/casino/table.go | 9 | ||||
| -rw-r--r-- | internal/casino/types.go | 58 |
9 files changed, 122 insertions, 141 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index b762b6f..cf8a289 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -51,7 +51,7 @@ func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTabl } func (t *baccaratTable) menuEvent(name string) Event { - return t.betMenuEvent(name, MenuBaccaratBet, []MenuCommand{ + return t.betMenuEvent(name, []MenuCommand{ {Command: "bet <amount>", Desc: "Place a wager (min/max accepted)"}, {Command: "bet <amount> on player|banker|tie", Desc: "Wager on a specific outcome"}, {Command: "stop", Desc: "Leave the table"}, @@ -161,45 +161,30 @@ func (t *baccaratTable) deal() []Event { player, banker := playTableau(t.shoe) playerTotal, bankerTotal := baccaratTotal(player), baccaratTotal(banker) round := t.event("New baccarat hand!", true) - round.Baccarat = t.snapshot(player, banker) + round.Snapshot = t.snapshot(player, banker) events := []Event{round, t.event(baccaratOutcomeMessage(playerTotal, bankerTotal), true)} for _, p := range t.players.ordered() { if !p.HasBet { continue } result, payout, settleErr := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules) - // A payout-overflow error means the win could not be computed; treat - // it like an unpersisted payout so the win is never announced as paid - // (blackjack handles the same case identically). - payoutFailed := settleErr != nil - if !payoutFailed && payout > 0 && p.Wallet != nil { - if err := p.Wallet.PayStakeCover(payout, []string{p.stakeID}); err != nil { - // The payout could not be made durable; recovery will - // re-apply the owed amount from the open journal record. - // Do not announce the win as paid and leave the stakes - // open for recovery to settle. - payoutFailed = true - } - } + // The shared settlement path pays before announcing, leaves the + // stake open on any failure (recovery re-applies the owed payout), + // and resolves it only once durable. + payoutFailed := settleStakeOutcome(p.Wallet, payout, []string{p.stakeID}, settleErr) + publicResult, privateResult := baccaratResultMessages(p.Name, result, payout) if payoutFailed { - publicResult, _ := baccaratResultMessages(p.Name, result, payout) - settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage()) - if result == "win" { + privateResult = payoutFailedMessage() + } + settlement := t.playerAction(p.Name, publicResult, privateResult) + if result == "win" { + if p.lastSpot == baccaratSpotTie { + settlement.Color = "casino_jackpot_win" + } else { settlement.Color = "casino_big_win" } - events = append(events, settlement) - } else { - publicResult, privateResult := baccaratResultMessages(p.Name, result, payout) - settlement := t.playerAction(p.Name, publicResult, privateResult) - if result == "win" { - if p.lastSpot == baccaratSpotTie { - settlement.Color = "casino_jackpot_win" - } else { - settlement.Color = "casino_big_win" - } - } - events = append(events, settlement) } + events = append(events, settlement) if p.Wallet != nil && !payoutFailed { // The round resolved; the stakes are no longer outstanding. p.Wallet.ResolveStakes() diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go index 41f5acf..49e8fdd 100644 --- a/internal/casino/baccarat_test.go +++ b/internal/casino/baccarat_test.go @@ -150,8 +150,8 @@ func TestBaccaratSoloBetDealsImmediately(t *testing.T) { // The round event carries a snapshot of both hands and the bets. var snapshot *BaccaratSnapshot for _, event := range events { - if event.Baccarat != nil { - snapshot = event.Baccarat + if s, ok := event.Snapshot.(*BaccaratSnapshot); ok { + snapshot = s } } if snapshot == nil || len(snapshot.Player) != 2 || len(snapshot.Banker) != 2 || @@ -354,8 +354,8 @@ func TestBaccaratMenuAndActions(t *testing.T) { menu = event.Menu } } - if menu == nil || menu.Kind != MenuBaccaratBet { - t.Fatalf("join menu=%+v, want MenuBaccaratBet", menu) + if menu == nil || !menu.ShowBalance || menu.Heading != "New baccarat hand! Place your bet." { + t.Fatalf("join menu=%+v, want a baccarat betting menu", menu) } if len(menu.Commands) != 3 || menu.Commands[0].Command != "bet <amount>" || menu.Commands[2].Command != "stop" { t.Fatalf("baccarat menu commands=%+v", menu.Commands) diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index fe212f7..55ee702 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -102,7 +102,7 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa } func (t *blackjackTable) menuEvent(name string) Event { - return t.betMenuEvent(name, MenuBlackjackBet, []MenuCommand{ + return t.betMenuEvent(name, []MenuCommand{ {Command: "bet", Desc: "Place a wager (min/max accepted)"}, {Command: "bet <amount>", Desc: "Place a specific wager"}, {Command: "stop", Desc: "Leave the table"}, @@ -258,7 +258,7 @@ func (t *blackjackTable) action(name, action string) []Event { hand.bust = true } e := t.playerAction(name, fmt.Sprintf("%s hits and receives %s.", name, hand.cards[len(hand.cards)-1]), fmt.Sprintf("You hit and receive %s.", hand.cards[len(hand.cards)-1])) - e.Blackjack = t.snapshot(false) + e.Snapshot = t.snapshot(false) events := []Event{e} if total >= 21 { events = append(events, t.finishHand()...) @@ -286,7 +286,7 @@ func (t *blackjackTable) action(name, action string) []Event { hand.bust = true } e := t.playerAction(name, fmt.Sprintf("%s doubles down.", name), "You double down.") - e.Blackjack = t.snapshot(false) + e.Snapshot = t.snapshot(false) return append([]Event{e}, t.finishHand()...) case "split": return t.splitHand(name, p, hand) @@ -376,7 +376,7 @@ func (t *blackjackTable) splitHand(name string, p *blackjackPlayer, hand *blackj index := t.activeHand p.hands = append(p.hands[:index], append([]*blackjackHand{left, right}, p.hands[index+1:]...)...) e := t.playerAction(name, fmt.Sprintf("%s splits the hand.", name), "You split the hand.") - e.Blackjack = t.snapshot(false) + e.Snapshot = t.snapshot(false) events := []Event{e} if left.cards[0].rank == "A" { // Split aces receive one card each; they only stay live when a @@ -418,7 +418,7 @@ func (t *blackjackTable) startRound() []Event { } } roundEvent := t.event("", true) - roundEvent.Blackjack = t.snapshot(false) + roundEvent.Snapshot = t.snapshot(false) events := []Event{roundEvent} if t.dealer.cards[1].rank == "A" { t.phase = blackjackInsurance @@ -490,7 +490,7 @@ func (t *blackjackTable) prompt() []Event { actions := t.availableActions(p, h) e := t.private(name, fmt.Sprintf("Your turn (%s).\nActions: %s", strings.Join(ranks, ","), strings.Join(actions, ", "))) e.Color = "casino_menu" - e.Menu = &MenuView{Kind: MenuBlackjackTurn, Title: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Actions: actions, Commands: blackjackActionCommands(actions)} + e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Commands: blackjackActionCommands(actions)} return []Event{e} } @@ -585,7 +585,7 @@ func (t *blackjackTable) settle() []Event { } dealerTotal, _ := t.dealer.total() dealerEvent := t.event("", true) - dealerEvent.Blackjack = t.snapshot(true) + dealerEvent.Snapshot = t.snapshot(true) events := []Event{dealerEvent} dealerBlackjack := t.dealerBlackjack() for _, p := range t.players.ordered() { @@ -595,50 +595,20 @@ func (t *blackjackTable) settle() []Event { payoutFailed := false for _, hand := range p.hands { result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) - if settleErr != nil { - // A payout multiplier that overflows the credit cap is a - // configuration defect, not a payable win. Treat it like a - // failed settlement so the stake stays open for recovery to - // refund rather than paying a clamped/absurd amount. + // One shared settlement path for every game: pay before + // announcing, leave the stakes open on any failure, and resolve + // them only once the payout is durable. + if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) { payoutFailed = true - events = append(events, t.playerAction(p.Name, "Settlement unavailable.", payoutFailedMessage())) + publicResult, _ := blackjackResultMessages(p.Name, result, payout) + settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage()) + colorizeBlackjackSettlement(&settlement, result) + events = append(events, settlement) continue } - if payout > 0 && p.Wallet != nil { - if err := p.Wallet.PayStakeCover(payout, hand.stakes); err != nil { - // The payout could not be made durable. The journal - // record stays open listing the hand's stakes as - // covered: recovery re-applies the owed payout (which - // returns those stakes) and refunds must not pay them - // again. The win is not announced as paid. - payoutFailed = true - publicResult, _ := blackjackResultMessages(p.Name, result, payout) - settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage()) - if result == "blackjack" { - settlement.Color = "casino_jackpot_win" - } else if result == "win" { - settlement.Color = "casino_big_win" - } - events = append(events, settlement) - continue - } - // The hand was paid: its stakes are resolved immediately - // so a later sibling-hand payout failure cannot cause a - // refund of a stake this settlement already returned. - t.resolveHandStakes(p.Wallet, hand) - } else if payout == 0 && p.Wallet != nil { - // A lost hand resolves its stakes even when a sibling - // hand's payout is still in flight: the stake was - // consumed by the rules of the game. - t.resolveHandStakes(p.Wallet, hand) - } publicResult, privateResult := blackjackResultMessages(p.Name, result, payout) settlement := t.playerAction(p.Name, publicResult, privateResult) - if result == "blackjack" { - settlement.Color = "casino_jackpot_win" - } else if result == "win" { - settlement.Color = "casino_big_win" - } + colorizeBlackjackSettlement(&settlement, result) events = append(events, settlement) } if p.insurance > 0 { @@ -647,23 +617,12 @@ func (t *blackjackTable) settle() []Event { if dealerBlackjack { insurancePayout, insuranceErr = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) } - switch { - case insuranceErr != nil: - // Overflow is a config defect: leave the insurance stake open - // for recovery to refund instead of paying a clamped amount. + if settleStakeOutcome(p.Wallet, insurancePayout, []string{p.insuranceStakeID}, insuranceErr) { payoutFailed = true events = append(events, t.private(p.Name, payoutFailedMessage())) - case insurancePayout > 0: - if err := p.Wallet.PayStakeCover(insurancePayout, []string{p.insuranceStakeID}); err != nil { - payoutFailed = true - events = append(events, t.private(p.Name, payoutFailedMessage())) - } else { - p.Wallet.ResolveStake(p.insuranceStakeID) - events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) - } - default: - // Insurance lost: its stake was consumed by the rules. - p.Wallet.ResolveStake(p.insuranceStakeID) + } else if insurancePayout > 0 { + events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) + } else { events = append(events, t.private(p.Name, "Insurance pays 0 credits.")) } } @@ -725,13 +684,13 @@ func (t *blackjackTable) resetRound() { } } -// resolveHandStakes settles every stake wager a hand committed. Nil-safe. -func (t *blackjackTable) resolveHandStakes(wallet Wallet, hand *blackjackHand) { - if wallet == nil { - return - } - for _, stakeID := range hand.stakes { - wallet.ResolveStake(stakeID) +// colorizeBlackjackSettlement paints a winning settlement with the big-win +// (or jackpot, for a natural) color. +func colorizeBlackjackSettlement(e *Event, result string) { + if result == "blackjack" { + e.Color = "casino_jackpot_win" + } else if result == "win" { + e.Color = "casino_big_win" } } diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go index 2e2e985..e3ffcaf 100644 --- a/internal/casino/blackjack_test.go +++ b/internal/casino/blackjack_test.go @@ -130,8 +130,8 @@ func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing. if event.Message != "Your turn (K,6).\nActions: hit, stand, double" { t.Fatalf("prompt=%q", event.Message) } - if event.Menu == nil || event.Menu.Kind != MenuBlackjackTurn || event.Menu.Title != "Your turn (K,6)" || - len(event.Menu.Actions) != 3 || event.Menu.Actions[0] != "hit" || event.Menu.Actions[1] != "stand" || event.Menu.Actions[2] != "double" { + if event.Menu == nil || event.Menu.Heading != "Your turn (K,6)" || event.Menu.ShowBalance || + len(event.Menu.Commands) != 3 || event.Menu.Commands[0].Command != "hit" || event.Menu.Commands[1].Command != "stand" || event.Menu.Commands[2].Command != "double" { t.Fatalf("prompt menu=%+v", event.Menu) } } @@ -139,7 +139,7 @@ func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing. func TestBlackjackMenuEventCarriesStructuredMenu(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) event := table.menuEvent("alice") - if event.Menu == nil || event.Menu.Kind != MenuBlackjackBet { + if event.Menu == nil || !event.Menu.ShowBalance { t.Fatalf("menu event=%+v", event.Menu) } if len(event.Menu.Commands) != 3 || event.Menu.Commands[0].Command != "bet" || event.Menu.Commands[2].Command != "stop" { @@ -355,7 +355,7 @@ func hasMessage(events []Event, fragment string) bool { func hasSnapshot(events []Event) bool { for _, event := range events { - if event.Blackjack != nil { + if _, ok := event.Snapshot.(*BlackjackSnapshot); ok { return true } } diff --git a/internal/casino/core.go b/internal/casino/core.go index 85a5e28..a0ac1c8 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -127,6 +127,40 @@ func (c *tableCore[P]) refundAllLocked() []Event { return events } +// settleStakeOutcome applies one wager's outcome through the wallet and +// reports whether the settlement could NOT be made durable. It carries the +// money-critical ordering shared by every table game (documented in +// building_guide/concurrency.md): compute the payout, pay it via +// PayStakeCover BEFORE announcing the win, and only then resolve the stakes +// the settlement returned. On a persistence failure the stakes are left open +// so recovery re-applies the owed payout and a shutdown refund never returns +// them a second time; the caller must not announce a paid win and must not +// run the player-wide ResolveStakes. +// +// payoutErr is a payout-computation error (for example an overflowed +// multiplier): it is treated exactly like a persistence failure so a +// clamped or absurd amount is never paid. A nil wallet (test scaffolding) +// resolves the stakes in memory only. +func settleStakeOutcome(w Wallet, payout int, stakeIDs []string, payoutErr error) (failed bool) { + if payoutErr != nil { + return true + } + if w == nil { + return false + } + if payout > 0 { + if err := w.PayStakeCover(payout, stakeIDs); err != nil { + return true + } + } + for _, id := range stakeIDs { + if id != "" { + w.ResolveStake(id) + } + } + return false +} + // gameTitle renders a configured game name as a message heading. func gameTitle(game GameName) string { name := string(game) diff --git a/internal/casino/machine.go b/internal/casino/machine.go index f285518..3e1e0af 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -468,7 +468,7 @@ func (m *Machine) playerAction(publicMessage, privateMessage string) []Event { func (m *Machine) publicSlot(message string, stopped int) []Event { return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, Player: m.player.Name, - Message: message, Color: "casino_action", Public: true, Slot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}} + Message: message, Color: "casino_action", Public: true, Snapshot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}} } func stoppedScatters(grid [slotGridRows][slotGridReels]string, reels int, trigger string) int { @@ -503,7 +503,7 @@ func (m *Machine) privateMenu(prefix string) []Event { events := m.private(prefix) events[0].Color = "casino_menu" if m.phase == machineReady { - events[0].Menu = &MenuView{Kind: MenuSlotCommands, Commands: slotCommands} + events[0].Menu = &MenuView{Heading: prefix, ShowBalance: true, Commands: slotCommands} } return events } diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go index 73687b2..1e8cdad 100644 --- a/internal/casino/machine_test.go +++ b/internal/casino/machine_test.go @@ -45,7 +45,7 @@ func TestMachineMenuEventCarriesStructuredMenu(t *testing.T) { m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) m.player = &Participant{Name: "alice", Connected: true} events := m.privateMenu("You sit down at the slot machine.") - if len(events) != 1 || events[0].Menu == nil || events[0].Menu.Kind != MenuSlotCommands || len(events[0].Menu.Commands) == 0 { + if len(events) != 1 || events[0].Menu == nil || !events[0].Menu.ShowBalance || len(events[0].Menu.Commands) == 0 { t.Fatalf("slot menu event=%+v", events) } } diff --git a/internal/casino/table.go b/internal/casino/table.go index 4be60a0..e3104ae 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -50,11 +50,12 @@ func (b *tableBase) playerAction(name, publicMessage, privateMessage string) Eve } // betMenuEvent builds the shared "place your bet" menu heading for a table -// game; only the menu kind and command list differ per game. -func (b *tableBase) betMenuEvent(name string, kind MenuKind, commands []MenuCommand) Event { - e := b.private(name, fmt.Sprintf("New %s hand! Place your bet.", b.Config.Game)) +// game; only the command list differs per game. +func (b *tableBase) betMenuEvent(name string, commands []MenuCommand) Event { + heading := fmt.Sprintf("New %s hand! Place your bet.", b.Config.Game) + e := b.private(name, heading) e.Color = "casino_menu" - e.Menu = &MenuView{Kind: kind, Commands: commands} + e.Menu = &MenuView{Heading: heading, ShowBalance: true, Commands: commands} return e } diff --git a/internal/casino/types.go b/internal/casino/types.go index 3d31820..980769c 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -234,36 +234,24 @@ type Wallet interface { type EventType string +// Only the event types the games actually construct are defined. Payout and +// win styling travels on Event.Color, so there is no separate payout event +// type to keep in sync. const ( - EventJoined EventType = "joined" - EventBetting EventType = "betting" - EventBetPlaced EventType = "bet_placed" - EventRoundStarted EventType = "round_started" - EventResult EventType = "result" - EventPayout EventType = "payout" - EventTimedOut EventType = "timed_out" - EventLeft EventType = "left" - EventClosed EventType = "closed" -) - -type MenuKind string - -const ( - MenuSlotCommands MenuKind = "slot_commands" - MenuBlackjackBet MenuKind = "blackjack_bet" - MenuBlackjackTurn MenuKind = "blackjack_turn" - MenuBaccaratBet MenuKind = "baccarat_bet" + EventJoined EventType = "joined" + EventResult EventType = "result" + EventLeft EventType = "left" ) // MenuView carries a structured menu alongside its plain-text Message so the // presentation layer can enrich it (balances, command colors) without parsing -// message prefixes. +// message prefixes. It is deliberately game-agnostic: every game supplies a +// heading and its command list, and the renderer has no per-game switch that +// could silently drop a new game's commands. type MenuView struct { - Kind MenuKind - Body string // command list (MenuSlotCommands) - Title string // prompt heading (MenuBlackjackTurn) - Actions []string // available commands (MenuBlackjackTurn) - Commands []MenuCommand // unified command list for all casino menus + Heading string // prompt heading shown above the command list + ShowBalance bool // prepend the player's chips/credits balance + Commands []MenuCommand } // MenuCommand is one selectable command shown in a casino menu: the typed @@ -283,17 +271,27 @@ type Event struct { PrivateTo string Color string Public bool - Slot *SlotSnapshot - Blackjack *BlackjackSnapshot - Baccarat *BaccaratSnapshot - Menu *MenuView + // Snapshot is the optional per-game view attached to the event. A game + // implements the Snapshot interface for its view type; the presentation + // layer type-switches on it. Because the field is typed, a new game's + // 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 } +// Snapshot is the marker interface for a game's structured event view. The +// method is unexported so only types in this package can implement it. +type Snapshot interface{ snapshot() } + type SlotSnapshot struct { Grid [slotGridRows][slotGridReels]string StoppedReels int } +func (*SlotSnapshot) snapshot() {} + // CardView renders one card for any card-based table game. type CardView struct { Rank string @@ -315,6 +313,8 @@ type BlackjackSnapshot struct { Players []BlackjackPlayerView } +func (*BlackjackSnapshot) snapshot() {} + // BaccaratBetView lists one participant's wager on a baccarat outcome. type BaccaratBetView struct { Name string @@ -330,6 +330,8 @@ type BaccaratSnapshot struct { Bets []BaccaratBetView } +func (*BaccaratSnapshot) snapshot() {} + type Participant struct { Name string Connected bool |
