diff options
| author | historia <[not public]> | 2026-09-18 18:38:35 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 18:38:35 -0400 |
| commit | 1f118d79e319da4e765502e9ca5b661772de94f1 (patch) | |
| tree | bca8a3fd1e538f1c302480511e6ba872c92e10b8 /internal/casino/core.go | |
| parent | 5ad42a51df4dcc5ccc3aa6a6d902085ce2ee4239 (diff) | |
| download | thehouseoficarus-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.go | 137 |
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:] +} |
