1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
|
package casino
import (
"fmt"
"strings"
"sync"
)
// tablePhase is the shared lifecycle phase of a multiplayer table game.
// Blackjack adds insurance and playing; baccarat uses only waiting/betting.
type tablePhase string
const (
phaseWaiting tablePhase = "waiting"
phaseBetting tablePhase = "betting"
phaseInsurance tablePhase = "insurance"
phasePlaying tablePhase = "playing"
)
// tableCore is the plumbing every multiplayer table game shares: the seat set,
// the phase/betting window, and the table lock. A game embeds a
// tableCore[itsPlayerType] and keeps only its own rules and round state.
//
// Methods suffixed Locked require the caller to hold c.mu; the others take it.
// A game's methods take the same promoted lock, so a game must never call a
// locking core method while already holding c.mu.
type tableCore[P seatedPlayer] struct {
*tableBase
mu sync.Mutex
players seatSet[P]
phase tablePhase
window bettingWindow
}
func newTableCore[P seatedPlayer](base *tableBase) tableCore[P] {
return tableCore[P]{tableBase: base, players: newSeatSet[P](), phase: phaseWaiting}
}
// base exposes the shared table identity to the non-generic Table handle.
func (c *tableCore[P]) base() *tableBase { return c.tableBase }
// idle reports whether no player is seated (or mid-round).
func (c *tableCore[P]) idle() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.players.count() == 0
}
func (c *tableCore[P]) hasParticipant(name string) bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.players.has(name)
}
// addSeatLocked seats a player and returns the shared public "sits down"
// event. The caller has already checked capacity.
func (c *tableCore[P]) addSeatLocked(name string, p P) Event {
c.players.add(name, p)
return c.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.")
}
// removeSeatLocked frees a player's seat. The caller owns the surrounding
// fixups (active-seat indices, refunds) and announcements.
func (c *tableCore[P]) removeSeatLocked(name string) { c.players.remove(name) }
// closeIfEmptyLocked appends the shared "table closes" event once the last
// seat has been removed.
func (c *tableCore[P]) closeIfEmptyLocked(events []Event) []Event {
if c.players.count() == 0 {
events = append(events, c.event(fmt.Sprintf("The %s table closes.", c.Config.Game), true))
}
return events
}
// openBettingLocked opens the timed betting window once a second player has a
// wager down, and announces it.
func (c *tableCore[P]) openBettingLocked(events []Event) []Event {
if c.players.count() > 1 && c.phase == phaseWaiting {
c.phase = phaseBetting
c.window.open(c.Config.BettingTicks)
events = append(events, c.event(fmt.Sprintf("%s betting is open for %d ticks.", gameTitle(c.Config.Game), c.Config.BettingTicks), true))
}
return events
}
// advanceBettingLocked moves the betting window one tick. pending leaves the
// window open; otherwise it announces the players who never bet and calls
// resolve (wagers are down) or reset (nobody bet).
func (c *tableCore[P]) advanceBettingLocked(resolve func() []Event, reset func()) []Event {
sitOuts, goRound, pending := c.window.advance(c.players.participants())
if pending {
return nil
}
var events []Event
for _, name := range sitOuts {
events = append(events, c.event(fmt.Sprintf("%s sits out this %s round.", name, c.Config.Game), true))
}
if goRound {
return append(events, resolve()...)
}
reset()
return events
}
// refundAllLocked refunds every committed wager through its wallet exactly
// once and returns the events. The caller resets its own round state
// afterwards, so a refunded round can never settle and pay again.
//
// It deliberately does NOT gate on p.HasBet: a stake whose payout could not
// be persisted stays pending after resetRound clears HasBet, and a shutdown
// or room eviction must still return it. RefundOutstanding is the player-wide
// ledger and a player can only participate in one casino game at a time, so
// refunding every seated wallet is safe and returns exactly that player's
// outstanding stakes.
func (c *tableCore[P]) refundAllLocked() []Event {
var events []Event
for _, p := range c.players.participants() {
if p.Wallet == nil {
continue
}
name := p.Name
chips, credits, err := p.Wallet.RefundOutstanding()
refund := chips + credits
if err != nil || refund <= 0 {
if err != nil {
events = append(events, c.private(name, "Shutdown refund could not be persisted; the stake stays pending for the next startup."))
}
continue
}
events = append(events, c.playerAction(name,
fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund)))
}
return events
}
// resolveStakeIDs consumes each listed pending stake by wallet handle. It is
// the per-handle counterpart to ResolveStakes: use it whenever another stake
// may still be pending for the same player (for example an earlier payout
// that failed to persist), so consuming one round's stakes cannot destroy an
// unrelated refundable stake.
func resolveStakeIDs(w Wallet, ids []string) {
if w == nil {
return
}
w.ResolveStakesByIDs(ids...)
}
// 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 PayCredits
// BEFORE announcing the win — that one save credits the payout and resolves
// the stakes it returns, so a crash can never pay the payout AND refund the
// same stake — and only then stand the hand down. On a persistence failure
// the stakes stay pending, so a later shutdown refund or startup recovery
// returns them exactly once. 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 {
return w.PayCredits(payout, stakeIDs...) != nil
}
// A zero payout is a loss: consume the stakes in one save so recovery can
// never refund a round that resolved.
if w != nil {
w.ResolveStakesByIDs(stakeIDs...)
}
return false
}
// gameTitle renders a configured game name as a message heading.
func gameTitle(game GameName) string {
name := string(game)
if name == "" {
return "Table"
}
return strings.ToUpper(name[:1]) + name[1:]
}
|