aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/core.go
blob: 85a5e28c8b448b9ccb8e9fd209baedd23f2d143c (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
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
}

// 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:]
}