aboutsummaryrefslogtreecommitdiff
path: root/internal/game/casino.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 05:31:09 -0400
committerhistoria <[not public]>2026-09-18 05:31:09 -0400
commit153acb7f644d755c45533b7c5311e3ffa47e8973 (patch)
tree7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/game/casino.go
parent87d08f0151e643951769e323995f1c5ed89ee200 (diff)
downloadthehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/game/casino.go')
-rw-r--r--internal/game/casino.go47
1 files changed, 36 insertions, 11 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 133eb34..bc702e1 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -124,13 +124,15 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool, stakeIDs []stri
}
w.p.Credits = creditsAfter
// The character file is the only durable record, so it must also
- // carry the resolution evidence for this player's open stakes: a
- // crash before the games' own ResolveStakes call would otherwise
- // leave the wager records open with checkpoint balances that no
- // longer match, and recovery would refund chip stakes the paid
- // settlement had already consumed. The single save below is the
- // commit point for the payout AND the resolution.
- resolved := w.recordStakeEvidence()
+ // carry the resolution evidence for the stakes this settlement
+ // resolves: a crash before the games' own ResolveStakes call would
+ // otherwise leave the wager records open with checkpoint balances
+ // that no longer match, and recovery would refund chip stakes the
+ // paid settlement had already consumed. Only the settled stakes are
+ // recorded (see recordStakeEvidence), so a sibling hand's stake stays
+ // refundable. The single save below is the commit point for the
+ // payout AND the resolution.
+ resolved := w.recordStakeEvidence(stakeIDs)
if serr := w.g.AccountStore.SaveCharacter(w.p); serr != nil {
w.p.Credits = creditsBefore
if resolved > 0 {
@@ -218,15 +220,25 @@ func (w casinoWallet) ResolveStake(stakeID string) {
w.recordResolutionEvidence(unresolved)
}
-// recordStakeEvidence adds every currently open stake of the player to the
+// recordStakeEvidence adds the stakes the current settlement resolves to the
// file-side resolution evidence list and returns how many IDs were newly
// recorded. Used when the economy journal is unavailable: the open wager
// record IDs in the character file prove the round resolved even though no
// settled marker (or payout record) could ever be journaled. The caller
// must persist the character file for the evidence to count.
-func (w casinoWallet) recordStakeEvidence() int {
+//
+// stakeIDs are the stakes the payout settles (a table hand lists its own
+// hand's stakes). Only those are recorded: a sibling hand's stake is settled
+// by its own payout, so marking it resolved here would suppress the refund a
+// crash before that payout must still produce. An empty list (a slots payout
+// links no stake) settles the round that consumed the stake, so every open
+// stake of the player is evidence.
+func (w casinoWallet) recordStakeEvidence(stakeIDs []string) int {
added := 0
for _, rec := range w.g.Economy.openStakes(economyKey(w.p)) {
+ if len(stakeIDs) > 0 && !containsString(stakeIDs, rec.ID) {
+ continue
+ }
if !resolvedStakeRecorded(w.p, rec.ID) {
w.p.ResolvedStakes = append(w.p.ResolvedStakes, rec.ID)
added++
@@ -315,11 +327,15 @@ func (g *Game) leaveCasinoGames(p *player.Player) bool {
if table != nil {
g.emitCasinoEvents(g.Casino.Leave(table.RoomID, table.Config.ID, p.Name))
} else if machine, ok := g.Casino.MachineFor(p.Name); ok {
+ // Stop autobet first, then take the machine's own leave path: a
+ // machine between spins has no committed stake, so one quit must
+ // free it (and must not warn about a forfeit that cannot happen).
+ // A machine mid-spin refuses the leave, the player stays seated,
+ // and the caller warns about the stake that is really committed.
if events, stopped := g.Casino.StopMachineAutoBet(machine.RoomID, machine.Config.ID, p.Name); stopped {
g.emitCasinoEvents(events)
- } else {
- g.emitCasinoEvents(g.Casino.LeaveMachine(machine.RoomID, machine.Config.ID, p.Name))
}
+ g.emitCasinoEvents(g.Casino.LeaveMachine(machine.RoomID, machine.Config.ID, p.Name))
}
_, stillPlaying := g.Casino.Participation(p.Name)
return !stillPlaying
@@ -337,6 +353,11 @@ func (g *Game) inCasinoGame(name string) bool {
return playing
}
+// casinoTable returns the room's table for the named game. A room addresses
+// at most one table (or machine) per game name — "play blackjack" carries no
+// table id — so a second table for the same game would be unreachable
+// content; ValidateRoomConfigs rejects that configuration instead of silently
+// dropping it.
func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) {
room, err := g.World.LoadRoom(roomID)
if err != nil {
@@ -357,6 +378,10 @@ func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) {
return nil, false
}
+// casinoMachine returns the room's machine for the named game or variant.
+// Like casinoTable, only the first match is reachable, which is why
+// ValidateRoomConfigs rejects a room with two entries addressing the same
+// game name or variant.
func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool) {
room, err := g.World.LoadRoom(roomID)
if err != nil {