aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/core.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 18:38:35 -0400
committerhistoria <[not public]>2026-09-18 18:38:35 -0400
commit1f118d79e319da4e765502e9ca5b661772de94f1 (patch)
treebca8a3fd1e538f1c302480511e6ba872c92e10b8 /internal/casino/core.go
parent5ad42a51df4dcc5ccc3aa6a6d902085ce2ee4239 (diff)
downloadthehouseoficarus-1f118d79e319da4e765502e9ca5b661772de94f1.tar.gz
refactor: casino table core; fix: stake/migration/validation gaps
Diffstat (limited to 'internal/casino/core.go')
-rw-r--r--internal/casino/core.go137
1 files changed, 137 insertions, 0 deletions
diff --git a/internal/casino/core.go b/internal/casino/core.go
new file mode 100644
index 0000000..85a5e28
--- /dev/null
+++ b/internal/casino/core.go
@@ -0,0 +1,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:]
+}