aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/core.go
blob: a0ac1c8d6f3a5854db7173388f2f485bb4441d51 (plain)
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
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.
func (c *tableCore[P]) refundAllLocked() []Event {
	var events []Event
	for _, p := range c.players.participants() {
		if !p.HasBet || 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 is recorded for recovery."))
			}
			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
}

// 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)
	if name == "" {
		return "Table"
	}
	return strings.ToUpper(name[:1]) + name[1:]
}