aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat.go45
-rw-r--r--internal/casino/baccarat_test.go8
-rw-r--r--internal/casino/blackjack.go95
-rw-r--r--internal/casino/blackjack_test.go8
-rw-r--r--internal/casino/core.go34
-rw-r--r--internal/casino/machine.go4
-rw-r--r--internal/casino/machine_test.go2
-rw-r--r--internal/casino/table.go9
-rw-r--r--internal/casino/types.go58
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