diff options
| author | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
| commit | 153acb7f644d755c45533b7c5311e3ffa47e8973 (patch) | |
| tree | 7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/casino/machine.go | |
| parent | 87d08f0151e643951769e323995f1c5ed89ee200 (diff) | |
| download | thehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz | |
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/casino/machine.go')
| -rw-r--r-- | internal/casino/machine.go | 184 |
1 files changed, 140 insertions, 44 deletions
diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 844c5e9..cf0ccb9 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -30,11 +30,16 @@ type Machine struct { inFreeSpins bool spinBet int payoutRemainder float64 - profile SlotProfile - rng *rand.Rand - suspense bool - autoBet bool - autoCountdown int + // featureBank holds the winnings of a paid round whose free-spin feature + // is still running. They are paid as ONE settlement when the feature + // ends: see finishSpin for why no payout may be journaled while the + // triggering stake is outstanding. + featureBank float64 + profile SlotProfile + rng *rand.Rand + suspense bool + autoBet bool + autoCountdown int // stakeFailed marks the in-flight stake whose payout could not be // persisted: the stake record stays OPEN as recovery evidence, so the // seat must not resolve (forfeit) it when the player leaves. @@ -43,12 +48,7 @@ type Machine struct { func newMachine(roomID int, cfg MachineConfig, rng *rand.Rand) *Machine { cfg = cfg.Normalize() - profile := DefaultHuffAndPuffProfile() - if cfg.Payout > 0 { - profile = ScaleToRTP(profile, cfg.Payout) - } - profile = profile.Normalize() - return &Machine{RoomID: roomID, Config: cfg, phase: machineReady, profile: profile, rng: rng} + return &Machine{RoomID: roomID, Config: cfg, phase: machineReady, profile: machineSlotProfile(cfg), rng: rng} } func (m *Machine) join(name string) []Event { @@ -184,11 +184,11 @@ func (m *Machine) tick() []Event { m.reel++ message := fmt.Sprintf("Reel %d stops...", m.reel) events := m.publicSlot(message, m.reel) - if m.reel < 5 && stoppedScatters(m.spin.Grid, m.reel, m.profile.FreeSpinTrigger) >= 2 && !m.suspense { + if m.reel < slotGridReels && stoppedScatters(m.spin.Grid, m.reel, m.profile.FreeSpinTrigger) >= 2 && !m.suspense { m.suspense = true events = append(events, m.public("Two scatters! The remaining reels slow down...")...) } - if m.reel < 5 { + if m.reel < slotGridReels { return events } m.phase = machinePayout @@ -224,6 +224,18 @@ func (m *Machine) autoBetTick() []Event { } } +// finishSpin resolves the spin that just stopped: it pays that spin's +// winnings, advances or starts the free-spin feature, and resolves the +// triggering stake once the whole round (feature included) is over. +// +// A free-spin feature pays out as ONE settlement when it ends. While the +// feature runs its winnings are banked in memory and nothing is journaled: +// the triggering stake stays OUTSTANDING until the feature completes, and +// recovery reads ANY payout record for the player as proof that the round +// resolved (a PayCredits record carries no stake linkage, see the Wallet +// contract). Paying a free spin as it landed would therefore make a crash +// mid-feature settle the stake instead of refunding it, contradicting the +// refund-on-crash contract that a graceful shutdown honors. func (m *Machine) finishSpin() []Event { events := []Event{} result := m.spin.Payout + m.payoutRemainder @@ -238,9 +250,26 @@ func (m *Machine) finishSpin() []Event { } credits := int(math.Floor(result)) m.payoutRemainder = result - float64(credits) + + // A spin belongs to a feature when it is a free spin or when it just + // triggered one; the feature (and with it the round) resolves on the + // last free spin. + triggered := m.spin.ScatterCount >= 3 && m.freeSpins == 0 && !m.inFreeSpins + inFeature := m.inFreeSpins || triggered + featureEnds := m.inFreeSpins && m.freeSpins == 0 + payNow := credits + switch { + case inFeature && !featureEnds: + m.featureBank += float64(credits) + payNow = 0 + case featureEnds: + payNow = credits + int(m.featureBank) + m.featureBank = 0 + } + payFailed := false - if credits > 0 && m.player.Wallet != nil { - if err := m.player.Wallet.PayCredits(credits); err != nil { + if payNow > 0 && m.player.Wallet != nil { + if err := m.player.Wallet.PayCredits(payNow); err != nil { // The payout could not be made durable; recovery will re-apply // the owed amount from the open journal record. Do not announce // the win as paid, stop any autobet chain, and keep the stake @@ -254,10 +283,19 @@ func (m *Machine) finishSpin() []Event { events = append(events, failedEvents...) } } - if credits > 0 && !payFailed { - winEvents := m.private(fmt.Sprintf("\nYou win %d credits.", credits)) + if payNow > 0 && !payFailed { + message := fmt.Sprintf("\nYou win %d credits.", payNow) + switch { + case inFeature && !featureEnds: + // Banked, not spent: say so, or the player would read the win + // as already in their balance. + message = fmt.Sprintf("\nYou win %d credits, paid when the free spins end.", payNow) + case featureEnds: + message = fmt.Sprintf("\nThe free spins pay %d credits.", payNow) + } + winEvents := m.private(message) winEvents[0].Color = "casino_payout" - if credits > m.spinBet { + if payNow > m.spinBet { winEvents[0].Color = "casino_big_win" } events = append(events, winEvents...) @@ -271,7 +309,7 @@ func (m *Machine) finishSpin() []Event { // free spin are ignored, matching the CalculateRTP model (the feature is // a fixed number of automatic spins). Retriggering only on a paid spin // keeps the machine's actual return aligned with that model. - if m.spin.ScatterCount >= 3 && m.freeSpins == 0 && !m.inFreeSpins { + if triggered { m.freeSpins = m.profile.FreeSpinCount m.inFreeSpins = true events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...) @@ -335,6 +373,7 @@ func (m *Machine) resetSeat() { m.freeSpins = 0 m.inFreeSpins = false m.payoutRemainder = 0 + m.featureBank = 0 m.autoBet, m.autoCountdown = false, 0 m.stakeFailed = false } @@ -388,10 +427,10 @@ func (m *Machine) publicSlot(message string, stopped int) []Event { Message: message, Color: "casino_action", Public: true, Slot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}} } -func stoppedScatters(grid [3][5]string, reels int, trigger string) int { +func stoppedScatters(grid [slotGridRows][slotGridReels]string, reels int, trigger string) int { count := 0 - for row := 0; row < 3; row++ { - for reel := 0; reel < reels && reel < 5; reel++ { + for row := 0; row < slotGridRows; row++ { + for reel := 0; reel < reels && reel < slotGridReels; reel++ { if grid[row][reel] == trigger { count++ } @@ -429,16 +468,23 @@ func (m *Machine) menuEvents(prefix string) []Event { return m.privateMenu(prefix) } -// refundAll refunds any outstanding stake at graceful shutdown/eviction. -// The wallet resolves every outstanding stake exactly once; a completed -// spin has no outstanding stake and is not refunded again. A stake whose -// payout failed to persist (stakeFailed) is NOT returned here when an open -// payout record covers it — the journaled winnings prove the round -// resolved, so the stake was consumed by playing it and recovery re-applies -// the owed payout instead of double-paying the round. A bonus in progress -// keeps its triggering stake outstanding with no payout journaled, so a -// shutdown during the feature returns the stake (the remaining free-spin -// EV is forfeit, never the wager). Locked. +// refundAll refunds any outstanding stake at graceful shutdown/eviction and +// then DISCARDS the round (the machine closes with the process). The wallet +// resolves every outstanding stake exactly once; a completed spin has no +// outstanding stake and is not refunded again. A stake whose payout failed +// to persist (stakeFailed) is NOT returned here when an open payout record +// covers it — the journaled winnings prove the round resolved, so the stake +// was consumed by playing it and recovery re-applies the owed payout instead +// of double-paying the round. A bonus in progress keeps its triggering stake +// outstanding with no payout journaled, so a shutdown during the feature +// returns the stake (only the still-unplayed free spins' EV is forfeit); the +// winnings that have already landed are banked in featureBank and are paid +// through the wallet as a settlement, so a graceful shutdown preserves them +// exactly as the pre-banking code did. Locked. +// +// Discarding the round is what makes the call safe to repeat: a refunded +// round can never settle (and pay its spin) afterwards, so neither a second +// RefundAll nor a stray tick can pay the same round twice. func (m *Machine) refundAll() []Event { m.mu.Lock() defer m.mu.Unlock() @@ -446,22 +492,72 @@ func (m *Machine) refundAll() []Event { return nil } name, wallet := m.player.Name, m.player.Wallet + // Free-spin winnings that have already landed live only in featureBank + // until the feature ends (see finishSpin), so discarding the round must + // settle them or a graceful shutdown silently destroys them. Refund the + // outstanding triggering stake FIRST: the settlement that pays the bank + // below carries no stake linkage, so a journaled settlement with the + // stake still open would read at recovery as covering (and consuming) + // that stake, suppressing its refund. chips, credits, err := wallet.RefundOutstanding() refund := chips + credits - if err != nil || refund <= 0 { - if err != nil { - return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, - PrivateTo: name, Player: name, - PrivateMessage: "Shutdown refund could not be persisted; the stake is recorded for recovery.", - Color: "casino_action", Public: false}} + bank := int(m.featureBank) + bankFailed := false + if err == nil && bank > 0 { + // Only settle the bank once the refund is settled: on a failed refund + // the stake stays open for recovery, and a settlement payout would + // make recovery treat it as consumed instead of refunded. + if payErr := wallet.PayCredits(bank); payErr != nil { + bankFailed = true } - return nil } - return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, - PrivateTo: name, Player: name, - Message: fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund), - PrivateMessage: fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund), - Color: "casino_action", Public: true}} + // A refund that could not be made durable leaves the stakes open for + // recovery to complete; the round is still discarded, and marking the + // stake as failed keeps resetSeat from resolving (forfeiting) it. This is + // LOAD-BEARING, not dead code: on the RefundOutstanding error path the + // stakes are left open, and resetSeat's guard is the only thing that + // stops it from resolving them and erasing the recovery evidence. + m.stakeFailed = err != nil + m.resetSeat() + var events []Event + switch { + case err != nil: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + PrivateMessage: "Shutdown refund could not be persisted; the stake is recorded for recovery.", + Color: "casino_action", Public: false}) + case refund > 0: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + Message: fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund), + PrivateMessage: fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund), + Color: "casino_action", Public: true}) + } + // Announce the banked free-spin settlement, or its loss. resetSeat has + // already zeroed featureBank by this point, so a failed refund does NOT + // keep the bank in memory: the banked winnings are forfeited and only the + // trigger stake is recovered. Report that forfeiture explicitly (a + // stake-linked settlement that survives a failed shutdown refund is not + // implemented) instead of dropping it silently. + switch { + case bankFailed: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they are recorded for recovery.", bank), + Color: "casino_action", Public: false}) + case err != nil && bank > 0: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + PrivateMessage: fmt.Sprintf("Your %d credits of banked free-spin winnings were forfeited because the shutdown refund could not be persisted.", bank), + Color: "casino_action", Public: false}) + case err == nil && bank > 0: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + Message: fmt.Sprintf("%s's %d free-spin credits pay out as the casino closes.", name, bank), + PrivateMessage: fmt.Sprintf("The house settles your %d banked free-spin winnings for the shutdown.", bank), + Color: "casino_action", Public: true}) + } + return events } // idle reports whether no player is seated: only an idle machine may have |
