aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 21:36:36 -0400
committerhistoria <[not public]>2026-09-18 21:36:36 -0400
commita8a67482bbff8297a96adff65d2fd7029ed998cc (patch)
treee84f8ba636c8f2edb4d2ff8b45d7cff7298fa7cc
parentff04d26ce90d6b411a04a2fb59f7c24ceefb161f (diff)
downloadthehouseoficarus-a8a67482bbff8297a96adff65d2fd7029ed998cc.tar.gz
refactor: replace economy journal with single-file pending-wager wallet
-rw-r--r--.gitignore2
-rw-r--r--building_guide/concurrency.md139
-rw-r--r--internal/casino/baccarat.go6
-rw-r--r--internal/casino/blackjack.go14
-rw-r--r--internal/casino/core.go13
-rw-r--r--internal/casino/correct_casino_refund_bank_test.go2
-rw-r--r--internal/casino/credits.go11
-rw-r--r--internal/casino/machine.go118
-rw-r--r--internal/casino/manager.go8
-rw-r--r--internal/casino/types.go49
-rw-r--r--internal/game/account_txn.go13
-rw-r--r--internal/game/casino.go323
-rw-r--r--internal/game/casino_forfeit_regression_test.go2
-rw-r--r--internal/game/casino_refund_regression_test.go141
-rw-r--r--internal/game/cmd_bank.go7
-rw-r--r--internal/game/economy.go1466
-rw-r--r--internal/game/economy_journal_test_helpers_test.go14
-rw-r--r--internal/game/economy_open_payout_list_test.go120
-rw-r--r--internal/game/economy_reanchor_test.go31
-rw-r--r--internal/game/economy_recovery_identity_test.go128
-rw-r--r--internal/game/economy_recovery_regression_test.go546
-rw-r--r--internal/game/economy_rollback_regression_test.go89
-rw-r--r--internal/game/economy_seq_test.go24
-rw-r--r--internal/game/game.go24
-rw-r--r--internal/game/hardening_economy_durability_test.go272
-rw-r--r--internal/game/hardening_fixes_batch2_test.go40
-rw-r--r--internal/game/journal_damage_regression_test.go123
-rw-r--r--internal/game/journal_failure_regression_test.go243
-rw-r--r--internal/game/money.go182
-rw-r--r--internal/game/payout_evidence_regression_test.go76
-rw-r--r--internal/game/quit_casino_test.go3
-rw-r--r--internal/game/readiness_regression_test.go216
-rw-r--r--internal/game/wallet_test.go (renamed from internal/game/economy_shutdown_test.go)143
-rw-r--r--internal/player/player.go31
34 files changed, 575 insertions, 4044 deletions
diff --git a/.gitignore b/.gitignore
index 358ebe1..f9ab5c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,8 +2,6 @@
!/data/players/accounts/.gitkeep
/data/players/characters/*
!/data/players/characters/.gitkeep
-# Durable money journal: runtime state, never commit.
-/data/economy/
# Admin undo history (password hashes are redacted, but never commit it).
/data/.admin_history.json
/data/.admin_history.json.bak
diff --git a/building_guide/concurrency.md b/building_guide/concurrency.md
index 081952f..f0b8b06 100644
--- a/building_guide/concurrency.md
+++ b/building_guide/concurrency.md
@@ -1,7 +1,7 @@
# Concurrency & durability
This guide documents the runtime architecture decisions that keep live game
-state safe: the single-owner loop, the durable money journal, the account
+state safe: the single-owner loop, single-file durable money, the account
lifecycle transactions, and the graceful-shutdown sequence.
## Single-owner game state
@@ -114,94 +114,55 @@ operations for live renumbering, and stop the game for external bulk edits.
Some definition editors still need the in-game `reload` command; serialization
does not add missing cache invalidations or migrate existing spawned entities.
-## Durable money (casino wallet)
-
-Every wager and payout is journaled before the balance mutation
-(`internal/game/economy.go`, `data/economy/journal.jsonl`):
-
-1. A durable journal record with balance checkpoints (before/after credits
- and chips) is appended and fsynced.
-2. The in-memory balance mutates.
-3. The character save makes the balance durable.
-
-Two record kinds exist:
-
-- **wager** — a committed stake. The record stays OPEN while the stake is
- outstanding (round in flight) and is resolved when the round settles:
- won, lost, pushed, forfeited (disconnect), or refunded (shutdown). Games
- resolve stakes through `casino.Wallet.ResolveStakes` after settlement
- (or per hand via `ResolveStake`); `casino.Wallet.RefundOutstanding`
- returns every outstanding stake (chips as chips, credits durably) at
- shutdown/eviction, exactly once. A stake already covered by an open
- settlement payout is resolved WITHOUT a refund: a table settlement
- includes its hand's stake return and a slots payout proves the round
- resolved, so refunding would double-pay the round — recovery re-applies
- the owed payout instead, and the covered stakes receive their money
- through it.
-- **payout** — money movement (winnings, pushes, refunds). The record
- closes when the balance mutation is durable.
-
-A failed save reverts the in-memory mutation and voids the record — the
-game must not proceed on a stake that could not be made durable, and the
-player is told the house could not process the transaction (distinct from
-an insufficient-funds message). A failed payout keeps its record open (the
-reported win is never announced as paid) and startup recovery re-pays the
-owed settlement.
-
-If the JOURNAL ITSELF cannot be appended (persistent write failure, e.g. a
-deleted or unwritable `data/economy`), appends are retried and then the
-wallet degrades safely:
-
-- A **wager** is never charged without a journal record — the bet is
- rejected with the persistence message (unchanged).
-- A **payout** falls back to paying through the character file alone (the
- save is the commit point), so the settlement is real, announced, and
- durable even with no journal record. `ResolveStakes` then records the
- unresolved stake IDs in the character file (`player.Player
- .ResolvedStakes`) as durable resolution evidence, and recovery settles
- those stakes without refunding them (no double pay, and a settlement's
- chip stake is not returned in kind on top of credits that already
- returned it). If the character save fails too, no evidence exists and
- the open wager record drives the ordinary refund-on-lost-state recovery.
- The same save records the attempted payout record's ID in the character
- file's `open_payouts` evidence list. A failed append is not proof that no
- record was written — a failed rollback (the truncate, or the sync that
- would make the truncation durable) can leave the record bytes behind, and
- a retry can leave bytes from an earlier attempt even when the final error
- is definite. Recording the ID means a surviving open payout is settled by
- recovery from the file evidence instead of being re-applied (a
- double-pay); when no record survived, the stale ID is pruned at the next
- startup.
-- A journal that cannot be fully READ (oversized or unreadable line) or
- that contains a malformed record with later records present aborts
- recovery and compaction entirely: records are preserved verbatim for
- operator inspection instead of compensating over a partial fold or
- truncating the file. A torn FINAL line (crash mid-append) is still
- tolerated and skipped.
-
-At startup (`game.New`), open records are compensated in two phases:
-
-1. Open **payout** records (append order, evolving balance): file at the
- pre-payout balance → re-apply (the player is owed it); at the post-payout
- balance → already durable, settle; anything else stays open and is
- surfaced for operator inspection — a liability is never silently
- dropped. Payouts written by current code are **Tracked**: the record ID
- is stored in the character file's `open_payouts` evidence list, and
- recovery proves durability from that evidence rather than from the
- balance checkpoints (which remain only as a legacy fallback).
-2. Open **wager** records (newest first, so each refund restores the
- balance the previous record's checkpoints refer to): file at the
- pre-debit state (credits AND chips) → void (nothing was charged); at
- the post-debit state → the stake is **refunded** (the round state was
- lost in the crash) — unless a later payout record proves the round
- resolved (the win/push/refund already returned the stake inside that
- payout), in which case only the lost resolution marker is re-applied.
- Settlement payouts may carry an explicit `covers` list naming the stake
- IDs they return (a table hand's payout): recovery then resolves
- exactly those stakes. A payout without a list (legacy records, slots
- winnings) resolves every open stake of its player — a journaled
- settlement means the round resolved, and its re-application delivers
- the money. Ambiguous records stay open for operator inspection.
+## Durable money (single-file wallet)
+
+Money lives in the character file and **nowhere else**. Every casino money
+mutation is one atomic `AccountStore.SaveCharacter` (`persistence.WriteFile`:
+temp file → fsync → rename → directory fsync) that carries both the balance
+and the `Player.PendingWagers` ledger. There is no second durable store, so
+there is no cross-file crash window to reconcile.
+
+- **Wager** — debits chips then credits and appends a `PendingWager` (its
+ wallet handle plus the exact chips/credits debited) in the SAME save. The
+ entry stays until the round resolves.
+- **Settlement** — `PayStakeCover` credits the payout and removes the stakes
+ it returns in the same save (a table hand includes its stake return in the
+ payout). `PayCredits` credits winnings that carry no stake linkage (a slot
+ spin: the stake was consumed by the round) and the game then resolves the
+ stake with `ResolveStake`/`ResolveStakes`.
+- **Loss/forfeit** — `ResolveStake`/`ResolveStakes` consume the pending
+ stakes in one save; the round resolved and the stake is gone.
+- **Refund** — `RefundOutstanding` returns every pending stake (chips in
+ kind, credits as credits) and clears the ledger in one save. Graceful
+ shutdown (`Game.runShutdown` → `Casino.RefundAll`) and eviction use it.
+
+A failed save reverts the in-memory balance and ledger together, so a player
+is never charged for, credited with, or refunded a stake that is not durable.
+On `persistence.ErrDurabilityUncertain` a wager keeps its in-memory debit and
+pending entry, because the visible file already carries both.
+
+At startup (`game.New` → `recoverPendingWagers`, `internal/game/money.go`)
+any stake still listed in a character's `PendingWagers` is refunded: a listed
+stake means the process died before its round resolved. That is the entire
+recovery story — no ordering, checkpoints, evidence, or operator queue.
+
+### Two-phase journal recovery is out of scope
+
+This game deliberately does **not** implement a durable money journal or
+two-phase crash recovery. That design was built and then removed: it required
+a second file (`data/economy/journal.jsonl`) plus balance checkpoints, record
+ordering (`Seq`), payout `Covers` linkage, `open_payouts` evidence, and a
+startup recovery pass with per-record ambiguity handling. Its entire purpose
+was to make an ungraceful stop mid-round produce zero credit discrepancies,
+at a cost of ~1,400 lines of engine code and ~2,000 lines of tests.
+
+With the crash-consistency bar deliberately lowered to "a rare ungraceful
+stop may lose or refund one in-flight stake, and must never double-pay", the
+single atomic character save is sufficient: a listed pending stake is
+refunded, and a settlement whose save fails simply leaves the stake pending
+and the win unannounced (never paid on top of the refund). Do not reintroduce
+a second money store, per-record ordering, or an operator-inspection queue
+without an explicit decision to restore bank-grade guarantees.
Currency arithmetic is checked (`internal/casino/credits.go`): explicit
`MaxCredits` cap, bounded `DefaultMaxBet` default (the explicit unlimited
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index cf8a289..2b2b060 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -168,9 +168,9 @@ func (t *baccaratTable) deal() []Event {
continue
}
result, payout, settleErr := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules)
- // The shared settlement path pays before announcing, leaves the
- // stake open on any failure (recovery re-applies the owed payout),
- // and resolves it only once durable.
+ // The shared settlement path pays before announcing and leaves the
+ // stake pending on any failure, so a refund can never be paid on top
+ // of a payout that was not durable.
payoutFailed := settleStakeOutcome(p.Wallet, payout, []string{p.stakeID}, settleErr)
publicResult, privateResult := baccaratResultMessages(p.Name, result, payout)
if payoutFailed {
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index 55ee702..3b08ba5 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -596,8 +596,8 @@ func (t *blackjackTable) settle() []Event {
for _, hand := range p.hands {
result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules)
// One shared settlement path for every game: pay before
- // announcing, leave the stakes open on any failure, and resolve
- // them only once the payout is durable.
+ // announcing, and leave the stakes pending on any failure so a
+ // refund can never be paid on top of a payout that was not durable.
if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) {
payoutFailed = true
publicResult, _ := blackjackResultMessages(p.Name, result, payout)
@@ -627,8 +627,8 @@ func (t *blackjackTable) settle() []Event {
}
}
if p.Wallet != nil && !payoutFailed {
- // The round resolved; the stakes are no longer outstanding and
- // startup recovery must not refund them.
+ // The round resolved; the stakes are no longer pending and must
+ // never be refunded.
p.Wallet.ResolveStakes()
}
}
@@ -781,8 +781,8 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event {
}
if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil {
if t.phase == blackjackPlaying || t.phase == blackjackInsurance {
- // A disconnected player forfeits the round in flight: resolve the
- // stakes now so startup recovery does not refund them later.
+ // A disconnected player forfeits the round in flight: consume the
+ // stakes now so a later refund cannot return them.
p.Wallet.ResolveStakes()
} else {
// No card has been dealt for this stake yet, so refund it: nothing
@@ -812,7 +812,7 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event {
}
// refundAll refunds every committed wager at this table (graceful
-// shutdown/eviction): the wallet returns every outstanding stake — opening
+// shutdown/eviction): the wallet returns every pending stake — opening
// bet, doubles, splits, and insurance — exactly once. It then RESETS the
// round, so a refunded round can never settle (and pay) afterwards: calling
// it twice, or letting the table keep running, cannot pay the same round
diff --git a/internal/casino/core.go b/internal/casino/core.go
index a0ac1c8..4177094 100644
--- a/internal/casino/core.go
+++ b/internal/casino/core.go
@@ -116,7 +116,7 @@ func (c *tableCore[P]) refundAllLocked() []Event {
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."))
+ events = append(events, c.private(name, "Shutdown refund could not be persisted; the stake stays pending for the next startup."))
}
continue
}
@@ -131,11 +131,12 @@ func (c *tableCore[P]) refundAllLocked() []Event {
// reports whether the settlement could NOT be made durable. It carries the
// money-critical ordering shared by every table game (documented in
// building_guide/concurrency.md): compute the payout, pay it via
-// PayStakeCover BEFORE announcing the win, and only then resolve the stakes
-// the settlement returned. On a persistence failure the stakes are left open
-// so recovery re-applies the owed payout and a shutdown refund never returns
-// them a second time; the caller must not announce a paid win and must not
-// run the player-wide ResolveStakes.
+// PayStakeCover BEFORE announcing the win (that one save both credits the
+// payout and removes the stakes it returns), and only then stand the hand
+// down. On a persistence failure the stakes stay pending, so a later shutdown
+// refund or startup recovery returns them exactly once — the payout was never
+// durable, so it can never be paid on top of the refund. The caller must not
+// announce a paid win and must not run the player-wide ResolveStakes.
//
// payoutErr is a payout-computation error (for example an overflowed
// multiplier): it is treated exactly like a persistence failure so a
diff --git a/internal/casino/correct_casino_refund_bank_test.go b/internal/casino/correct_casino_refund_bank_test.go
index 4262997..497b5c2 100644
--- a/internal/casino/correct_casino_refund_bank_test.go
+++ b/internal/casino/correct_casino_refund_bank_test.go
@@ -95,7 +95,7 @@ func TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery(t *testing.T) {
if wallet.paid != 0 {
t.Fatalf("a failed refund paid %d; want 0", wallet.paid)
}
- if !hasMessage(events, "recorded for recovery") {
+ if !hasMessage(events, "stake stays pending") {
t.Fatalf("the failed refund was not reported: %+v", events)
}
}
diff --git a/internal/casino/credits.go b/internal/casino/credits.go
index 19364da..8ef8868 100644
--- a/internal/casino/credits.go
+++ b/internal/casino/credits.go
@@ -73,13 +73,10 @@ func walletErrorMessage(err error, context ...string) string {
return "You don't have enough chips and credits for that bet."
}
-// payoutFailedMessage explains a settlement the wallet could not persist:
-// the journal record for the payout stays open, so startup recovery will
-// re-apply the owed amount. Games must NOT announce such a payout as paid.
// payoutFailedMessage explains a settlement the wallet could not apply right
-// now. This covers both a persistence failure and a credit-cap block (the
-// journal record stays open either way), so it must not assert a persistence
-// failure as the cause. Recovery re-applies the owed amount.
+// now: a persistence failure or a credit-cap block. Games must NOT announce
+// such a payout as paid. The stake stays pending, so it is returned by a
+// shutdown refund or at the next startup rather than paid as winnings.
func payoutFailedMessage() string {
- return "The house could not process your payout right now. Your settlement is recorded and will be restored when you return."
+ return "The house could not process your payout right now. Your stake is safe and will be returned if the settlement cannot be completed."
}
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 3e1e0af..5c73b6f 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -32,13 +32,13 @@ type Machine struct {
payoutRemainder float64
// stakeID is the wallet handle of the wager committed to the current
// round (the triggering wager for a free-spin feature). Resolving by
- // handle — not the player-wide ResolveStakes — keeps an older
- // failed-payout stake open as recovery evidence when a new round starts.
+ // handle — not the player-wide ResolveStakes — consumes only this
+ // round's stake, leaving an older failed-payout stake pending.
stakeID string
// 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.
+ // ends: see finishSpin for why the triggering stake stays pending until
+ // then.
featureBank float64
profile SlotProfile
rng *rand.Rand
@@ -46,8 +46,9 @@ type Machine struct {
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.
+ // persisted: the stake stays pending, so the seat must not consume
+ // (forfeit) it when the player leaves — a refund or startup recovery
+ // returns it.
stakeFailed bool
}
@@ -238,13 +239,11 @@ func (m *Machine) autoBetTick() []Event {
// 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.
+// feature runs its winnings are banked in memory and the triggering stake
+// stays PENDING (nothing is resolved): paying a free spin as it landed would
+// consume the stake early, so a crash mid-feature would eat it instead of
+// refunding it. Banking the winnings keeps the refund-on-lost-round behavior
+// intact until the feature completes.
func (m *Machine) finishSpin() []Event {
events := []Event{}
result := m.spin.Payout + m.payoutRemainder
@@ -279,11 +278,10 @@ func (m *Machine) finishSpin() []Event {
payFailed := false
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
- // OPEN (stakeFailed): resolving it here would erase the refund
- // evidence for the owed settlement.
+ // The payout could not be made durable. Do not announce the win as
+ // paid, stop any autobet chain, and keep the stake PENDING
+ // (stakeFailed): the settlement was never durable, so the stake is
+ // refunded rather than paid.
payFailed = true
m.stakeFailed = true
m.autoBet, m.autoCountdown = false, 0
@@ -324,10 +322,10 @@ func (m *Machine) finishSpin() []Event {
events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...)
}
if m.freeSpins > 0 {
- // A feature is in progress: the triggering stake stays OUTSTANDING
- // until the bonus completes, so a graceful shutdown (or crash) while
- // free spins remain refunds the stake through RefundOutstanding /
- // startup recovery instead of silently eating the feature.
+ // A feature is in progress: the triggering stake stays PENDING until
+ // the bonus completes, so a graceful shutdown (or crash) while free
+ // spins remain refunds the stake through RefundOutstanding / startup
+ // recovery instead of silently eating the feature.
m.freeSpins--
m.phase = machineSpinning
m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile)
@@ -337,9 +335,9 @@ func (m *Machine) finishSpin() []Event {
m.inFreeSpins = false
if !payFailed && m.player.Wallet != nil {
// The paid round fully resolved (the feature either never
- // started or just completed; free spins wager nothing): resolve
- // ONLY this round's stake, so an older failed-payout stake stays
- // open as recovery evidence.
+ // started or just completed; free spins wager nothing): consume
+ // ONLY this round's stake, leaving an older failed-payout stake
+ // pending.
m.player.Wallet.ResolveStake(m.stakeID)
m.stakeID = ""
m.stakeFailed = false
@@ -367,10 +365,9 @@ func (m *Machine) leave(name string) []Event {
// resetSeat frees the machine, discarding any in-flight spin and autobet
// state. A wager already taken for a spin in progress is forfeited: its
-// stake is resolved so startup recovery does not refund it. The exception
-// is a stake whose payout could not be persisted (stakeFailed): the open
-// stake record is the only evidence of the owed settlement, so it is left
-// unresolved for recovery (or the shutdown refund) to complete.
+// stake is consumed so it is not refunded. The exception is a stake whose
+// payout could not be persisted (stakeFailed): it is left pending for a
+// shutdown refund or startup recovery.
func (m *Machine) resetSeat() {
if m.player != nil && m.player.Wallet != nil && !m.stakeFailed {
m.player.Wallet.ResolveStake(m.stakeID)
@@ -394,11 +391,10 @@ func (m *Machine) resetSeat() {
// in-flight wager, so the seat is immediately free for someone else.
//
// Already-landed free-spin winnings (featureBank) are NOT part of the
-// in-flight wager: they are settled after resetSeat resolves the triggering
-// stake, so the unlinked settlement cannot be read at recovery as covering
-// that stake. If the round's payout could not be persisted (stakeFailed), the
-// stake is left open for recovery and the bank is reported as forfeited
-// rather than silently destroyed.
+// in-flight wager: they are settled after resetSeat consumes the triggering
+// stake. If the round's payout could not be persisted (stakeFailed), the
+// stake is left pending and the bank is reported as forfeited rather than
+// silently destroyed.
func (m *Machine) markDisconnected(name string) []Event {
m.mu.Lock()
defer m.mu.Unlock()
@@ -423,7 +419,7 @@ func (m *Machine) markDisconnected(name string) []Event {
case wallet.PayCredits(bank) != nil:
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),
+ PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they were forfeited.", bank),
Color: "casino_action", Public: false})
default:
events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
@@ -508,19 +504,16 @@ func (m *Machine) privateMenu(prefix string) []Event {
return events
}
-// 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.
+// refundAll refunds any pending stake at graceful shutdown/eviction and then
+// DISCARDS the round (the machine closes with the process). RefundOutstanding
+// returns every pending stake exactly once; a completed spin has none and is
+// not refunded again. A stake whose payout could not be persisted
+// (stakeFailed) is left pending by RefundOutstanding and returned here, since
+// the settlement never became durable. A bonus in progress keeps its
+// triggering stake pending, so a shutdown during the feature returns the
+// stake (only the still-unplayed free spins' EV is forfeit); winnings that
+// have already landed are banked in featureBank and paid through the wallet,
+// so a graceful shutdown preserves them. 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
@@ -535,28 +528,27 @@ func (m *Machine) refundAll() []Event {
// 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.
+ // pending triggering stake FIRST, then pay the bank: the two are separate
+ // (the stake is returned because the feature never completed, the bank is
+ // winnings already won).
chips, credits, err := wallet.RefundOutstanding()
refund := chips + credits
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.
+ // Only settle the bank once the refund is durable: on a failed refund
+ // the stake stays pending, and paying the bank first would leave the
+ // two obligations tangled.
if payErr := wallet.PayCredits(bank); payErr != nil {
bankFailed = 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.
+ // A refund that could not be made durable leaves the stakes pending; the
+ // round is still discarded, and marking the stake as failed keeps
+ // resetSeat from consuming (forfeiting) it. This is LOAD-BEARING, not
+ // dead code: on the RefundOutstanding error path the stakes are left
+ // pending, and resetSeat's guard is the only thing that stops it from
+ // consuming them.
m.stakeFailed = err != nil
m.resetSeat()
var events []Event
@@ -564,7 +556,7 @@ func (m *Machine) refundAll() []Event {
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.",
+ PrivateMessage: "Shutdown refund could not be persisted; the stake stays pending for the next startup.",
Color: "casino_action", Public: false})
case refund > 0:
events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
@@ -583,7 +575,7 @@ func (m *Machine) refundAll() []Event {
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),
+ PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they were forfeited.", bank),
Color: "casino_action", Public: false})
case err != nil && bank > 0:
events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index d9b2c3e..f880463 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -329,8 +329,8 @@ func (m *Manager) MachineBusy(name string) bool {
// RefundAll resolves every outstanding wager by refunding committed stakes
// to their players and then closing each game's round. Graceful shutdown and
// room eviction use this: rounds cannot settle, so no player may lose a
-// stake to the shutdown itself. Refunds go through the wallet (durable,
-// journaled) and the refund events are returned for delivery.
+// stake to the shutdown itself. Refunds go through the wallet (durable
+// character saves) and the refund events are returned for delivery.
//
// It is deliberately NOT a way to clear live tables: every game discards
// the round it refunded (seats keep their last bet only for "rebet", but
@@ -414,8 +414,8 @@ func (m *Manager) RekeyRooms(idMap map[int]int) {
func (m *Manager) EvictRoom(roomID int) []Event {
prefix := fmt.Sprintf("%d:", roomID)
// Snapshot the games under the manager lock, then refund OUTSIDE it:
- // refundAll appends to the money journal and saves character files, and
- // holding m.mu across that I/O blocks every casino command and Tick.
+ // refundAll saves character files, and holding m.mu across that I/O
+ // blocks every casino command and Tick.
type evictable interface{ refundAll() []Event }
var evicted []evictable
m.mu.Lock()
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 980769c..a5e1415 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -191,44 +191,39 @@ type Wager struct {
Total int
Chips int
Credits int
- // ID is the wallet's opaque handle for the journaled stake record:
- // table settlements pay through PayStakeCover with the hand's stake
- // IDs so the journal can prove which wager a settlement returns.
+ // ID is the wallet's opaque handle for the committed stake. Table
+ // settlements pass the hand's stake IDs to PayStakeCover so the wallet
+ // removes exactly the stakes the payout returns.
ID string
}
type Wallet interface {
- // Wager debits the stake durably. ErrInsufficientFunds is a normal
- // rejected bet; ErrWalletPersistence means the debit could not be made
- // durable and the game must not proceed as if the bet succeeded.
+ // Wager debits the stake and records it as pending. ErrInsufficientFunds
+ // is a normal rejected bet; ErrWalletPersistence means the debit could
+ // not be made durable and the game must not proceed as if it succeeded.
Wager(amount int) (Wager, error)
- // PayCredits pays a settlement durably, returning an error when the
- // credit could not be persisted (the game must not report a win that
- // is not durable). The journaled record carries no stake linkage:
- // recovery treats it as resolving the round (a slots payout is
- // winnings only; the stake was consumed by playing the round).
+ // PayCredits pays winnings durably, returning an error when the credit
+ // could not be persisted (the game must not report a win that is not
+ // durable). The payout carries no stake linkage: the stake was consumed
+ // by playing the round, so the game resolves it separately.
PayCredits(amount int) error
// PayStakeCover pays a settlement whose amount INCLUDES the return of
// the listed stake wager IDs (a table hand pays back its stake inside
- // the payout). When the payout cannot be made durable, the open
- // journal record still proves those stakes were returned inside it, so
- // a shutdown refund must not return them a second time and recovery
- // re-applies the owed payout instead.
+ // the payout). The credit and the removal of those stakes are one
+ // durable step, so a failed settlement leaves the stakes pending —
+ // refunded at the next startup, never paid on top of the refund.
PayStakeCover(amount int, stakeIDs []string) error
- // ResolveStakes marks every outstanding stake for this player as
- // settled: the round resolved (won, lost, pushed, or forfeited), so
- // startup recovery must no longer refund it. A stake whose wager
- // record remains open at restart is refunded by recovery.
+ // ResolveStakes consumes every pending stake for this player: the round
+ // resolved as a loss, a forfeit, or an unlinked payout, so the stakes
+ // must not be refunded.
ResolveStakes()
- // ResolveStake settles ONE outstanding stake by wallet handle (a
- // single hand paid or lost while a sibling hand's payout is still in
- // flight): its return is proven, so it must never be refunded.
+ // ResolveStake consumes ONE pending stake by wallet handle (a single
+ // hand paid or lost while a sibling hand's round is still in flight).
ResolveStake(stakeID string)
- // RefundOutstanding returns every outstanding stake to the player
- // (chips as chips, credits through the durable payout path) and
- // resolves the stakes. It reports the amounts returned. An error
- // means the refund could not be made durable: the stakes remain open
- // and startup recovery completes the refund.
+ // RefundOutstanding returns every pending stake to the player (chips as
+ // chips, credits as credits) and clears the ledger. An error means the
+ // refund could not be made durable: nothing was returned and the stakes
+ // remain pending for a later attempt or startup recovery.
RefundOutstanding() (chips, credits int, err error)
}
diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go
index 13f7695..6e1e2d1 100644
--- a/internal/game/account_txn.go
+++ b/internal/game/account_txn.go
@@ -48,10 +48,10 @@ func (g *Game) adoptCharacterIdentity(p *player.Player, accountName string) {
p.Account = accountName
}
if err := g.AccountStore.SaveCharacter(p); err != nil {
- // Identity backfill is best-effort, but a NON-DURABLE ID must never
- // become an economy key: a later startup's backfill would assign a
- // different ID and strand every journal record written under this
- // one. Fall back to the durable character name instead.
+ // Identity backfill is best-effort, but a NON-DURABLE ID must not
+ // survive in memory: a later startup's backfill would assign a
+ // different one, and any reference cached under this ID would point
+ // at nothing. Fall back to the durable character name instead.
if assignedID != "" {
p.ID = ""
}
@@ -201,8 +201,9 @@ func (g *Game) renameCharacterTxn(accountName, oldName, newName string) error {
}
p.Name = newName
if p.ID == "" {
- // Assign the stable identity at rename time so journal records
- // written after this point resolve the renamed character by ID.
+ // Assign the stable identity at rename time so cached references
+ // (and the owner/membership checks) resolve the renamed character
+ // by ID rather than the new filename.
p.ID = newCharacterID()
}
data, err := g.AccountStore.MarshalCharacter(p)
diff --git a/internal/game/casino.go b/internal/game/casino.go
index f111ed0..f6592ac 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -21,24 +21,18 @@ type casinoWallet struct {
p *player.Player
}
-// Wager debits the stake durably. Order of operations: durable journal
-// record FIRST (with balance checkpoints), then the in-memory mutation,
-// then the character save, then the record is registered as an OUTSTANDING
-// STAKE. It is NOT settled here: it stays open until the round resolves
-// (casino.Wallet.ResolveStakes) or is refunded (RefundOutstanding). An open
-// record at startup recovery means the round state was lost in a crash and
-// the stake is refunded — the documented refund-on-crash policy.
-//
-// A failed save reverts the in-memory mutation and voids the record: no
-// charge is kept in memory, and the error distinguishes persistence failure
-// from insufficient funds.
+// Wager debits the stake and records it in PendingWagers in the SAME atomic
+// character save. The entry stays until the round resolves: a settlement
+// removes the stakes it returns (PayStakeCover) or consumes them
+// (ResolveStake/ResolveStakes), and startup refunds anything still listed
+// (see recoverPendingWagers). A failed save reverts the in-memory debit and
+// ledger entry, so a player is never charged for a stake that is not durable.
func (w casinoWallet) Wager(amount int) (casino.Wager, error) {
if amount <= 0 {
return casino.Wager{}, casino.ErrInsufficientFunds
}
creditsBefore := w.p.Credits
- chipsBefore := countChips(w.p)
- chips := chipsBefore
+ chips := countChips(w.p)
if chips > amount {
chips = amount
}
@@ -46,26 +40,21 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) {
if creditsBefore < credits {
return casino.Wager{}, casino.ErrInsufficientFunds
}
- rec, err := w.g.Economy.start("wager", w.p, chips, credits,
- creditsBefore, creditsBefore-credits, chipsBefore, chipsBefore-chips)
- if err != nil {
- return casino.Wager{}, casino.ErrWalletPersistence
- }
if chips > 0 {
w.p.RemoveItem("chips", chips)
}
w.p.Credits -= credits
+ pending := player.PendingWager{ID: newPendingWagerID(), Chips: chips, Credits: credits}
+ w.p.PendingWagers = append(w.p.PendingWagers, pending)
if err := w.g.AccountStore.SaveCharacter(w.p); err != nil {
if errors.Is(err, persistence.ErrDurabilityUncertain) {
- // The character file was replaced but the directory sync failed:
- // the debit may be durable. Keep the in-memory debit and the
- // journal record OPEN instead of voiding it, so recovery compares
- // checkpoints against whatever file survived and either voids or
- // refunds consistently. Proceeding is safe: the record stays open
- // (refundable) until the round resolves.
- fmt.Fprintf(os.Stderr, "casino wager: %s save durability uncertain: %v — record kept open\n", w.p.Name, err)
- w.g.Economy.registerStake(rec)
- return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: rec.ID}, nil
+ // The character file was replaced but its directory entry may not
+ // be durable. The debit and the pending entry are in the visible
+ // file, so keep them: a surviving save refunds the stake at
+ // startup, and a lost one means the player still has the money.
+ // Either way the stake is never charged twice.
+ fmt.Fprintf(os.Stderr, "casino wager: %s save durability uncertain: %v — stake kept pending\n", w.p.Name, err)
+ return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, nil
}
// Revert the in-memory mutation: the play must not proceed on a
// stake that could not be made durable.
@@ -76,270 +65,120 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) {
fmt.Fprintf(os.Stderr, "casino wager revert: no inventory room for %d chips\n", chips)
}
}
- w.p.Credits += credits
- w.g.Economy.void(rec)
+ w.p.Credits = creditsBefore
+ removePendingWager(w.p, pending.ID)
return casino.Wager{}, casino.ErrWalletPersistence
}
- // The record stays open as an outstanding stake until the round
- // resolves; recovery refunds open stakes after a crash.
- w.g.Economy.registerStake(rec)
- return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: rec.ID}, nil
+ return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, nil
}
-// PayCredits applies a payout durably. The journal record is written first;
-// if the save fails, the record stays open and startup recovery re-pays it
-// (the player is owed the settlement).
+// PayCredits applies winnings that do not return a stake (a slot spin's
+// payout: the stake was consumed by playing the round and is resolved
+// separately). The credit and the save are the commit point; a failed save
+// reverts the credit and reports persistence failure so the caller does not
+// announce a win that is not durable.
func (w casinoWallet) PayCredits(amount int) error {
- return w.payCreditsFlagged(amount, false, nil)
+ return w.payCredits(amount, nil)
}
-// PayStakeCover applies a settlement that INCLUDES the return of the listed
-// stake wager IDs (a table hand pays its stake back inside the payout). The
-// record is journaled with that linkage, so a shutdown refund never returns
-// those stakes a second time; recovery re-applies the owed payout instead.
+// PayStakeCover applies a settlement whose amount INCLUDES the return of the
+// listed stake wager IDs (a table hand pays its stake back inside the
+// payout). The credit and the removal of those stakes happen in ONE atomic
+// save, so a crash can never pay the payout AND refund the returned stake.
func (w casinoWallet) PayStakeCover(amount int, stakeIDs []string) error {
- return w.payCreditsFlagged(amount, false, stakeIDs)
+ return w.payCredits(amount, stakeIDs)
}
-// payCreditsFlagged journals and applies a credit payout; a settlement
-// payout (refund=false) proves the round resolved during recovery, while a
-// refund payout (refund=true) marks a stake RETURNED at shutdown/eviction
-// (its chip part returns in chips, not through this credit payout).
-// stakeIDs, when non-empty, records WHICH open stakes this settlement
-// returns (table hands); empty means the payout resolves the round without
-// a linked stake return (slots winnings).
-func (w casinoWallet) payCreditsFlagged(amount int, refund bool, stakeIDs []string) error {
- if amount <= 0 {
+// payCredits credits the payout and, when stakeIDs is non-empty, removes
+// those stakes from the pending ledger in the same atomic save. On any
+// failure the credit and the ledger are reverted together.
+func (w casinoWallet) payCredits(amount int, stakeIDs []string) error {
+ if amount <= 0 && len(stakeIDs) == 0 {
return nil
}
creditsBefore := w.p.Credits
- chipsBefore := countChips(w.p)
- creditsAfter, capErr := addCreditsChecked(creditsBefore, amount)
- // The journal record is written FIRST, even when the economy cap
- // blocks the payout: a won settlement must never be silently dropped.
- // With the cap blocking, the balance is unchanged and the open record
- // keeps the owed amount durable; startup recovery re-applies it once
- // the balance has room below the cap.
- rec, err := w.g.Economy.startKind("payout", refund, stakeIDs, w.p, 0, amount,
- creditsBefore, creditsAfter, chipsBefore, chipsBefore)
- if err != nil {
- // The journal is unavailable (persistent append failure). Last
- // resort: pay through the character file only. The save below is
- // the commit point — the money is durable even without a journal
- // record, so the settlement may be announced as paid. The stake
- // resolution evidence (recordStakeEvidence) recorded in that same
- // file keeps recovery from treating the round as unresolved;
- // without a payout record, an open wager can no longer be
- // distinguished from a lost one, so the balance itself is the only
- // record of what happened.
- if capErr != nil {
- return capErr
- }
- w.p.Credits = creditsAfter
- // The append failed, but the record may still be IN the journal: a
- // failed rollback (a truncate, or the sync that would make the
- // truncation durable) leaves the bytes behind, and appendRetry can
- // leave bytes from an earlier attempt even when its final error is
- // definite. Record the attempted record's ID as open-payout evidence
- // in the SAME save that makes this payout durable, so recovery
- // recognizes a surviving record as already paid instead of
- // re-applying it (a double-pay). When no record survived, the stale
- // ID is pruned at the next startup.
- attemptID := ""
- if rec != nil {
- attemptID = rec.ID
- }
- if attemptID != "" {
- w.p.OpenPayouts = append(w.p.OpenPayouts, attemptID)
- }
- // The character file is the only durable record, so it must also
- // 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 attemptID != "" {
- removeOpenPayout(w.p, attemptID)
- }
- if resolved > 0 {
- w.p.ResolvedStakes = w.p.ResolvedStakes[:len(w.p.ResolvedStakes)-resolved]
- }
- return casino.ErrWalletPersistence
- }
- kind := "settlement"
- if refund {
- kind = "refund"
- }
- if errors.Is(err, errJournalAppendUncertain) {
- fmt.Fprintf(os.Stderr, "economy: %s payout of %d credits for %s could not be journaled and the append could not be rolled back; recorded as open-payout evidence so recovery will not re-apply it\n", kind, amount, w.p.Name)
- } else {
- fmt.Fprintf(os.Stderr, "economy: %s payout of %d credits for %s could not be journaled; the character file is the only durable record\n", kind, amount, w.p.Name)
- }
- return nil
- }
+ sum, capErr := addCreditsChecked(creditsBefore, amount)
if capErr != nil {
return capErr
}
- w.p.Credits = creditsAfter
- // Record the payout in the character's open-payout evidence list: the
- // save below is the commit point, and the file itself then proves the
- // amount is included in the balance. Recovery reads this list instead
- // of guessing from balance checkpoints, which unrelated credit changes
- // (a shop sale, a bank deposit) can replicate by coincidence.
- w.p.OpenPayouts = append(w.p.OpenPayouts, rec.ID)
+ var removed []player.PendingWager
+ if len(stakeIDs) > 0 {
+ removed = takePendingWagers(w.p, stakeIDs)
+ }
+ w.p.Credits = sum
if err := w.g.AccountStore.SaveCharacter(w.p); err != nil {
w.p.Credits = creditsBefore
- removeOpenPayout(w.p, rec.ID)
- // Keep the record open: recovery re-pays on next startup, since a
- // reported win must be durable.
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
return casino.ErrWalletPersistence
}
- // The evidence entry may only be dropped once the settled marker is
- // durable; while the marker is still open, the file's membership is
- // what proves durability to recovery.
- if w.g.Economy.settleDurable(rec) {
- removeOpenPayout(w.p, rec.ID)
- }
return nil
}
-// ResolveStakes marks every outstanding stake for the player settled: the
-// round resolved (won, lost, pushed, or forfeited), so startup recovery
-// must no longer refund it. When the journal cannot persist the settled
-// markers, the record IDs are recorded in the character file as durable
-// resolution evidence (player.Player.ResolvedStakes) so recovery still
-// honors the resolution instead of refunding an already-resolved round.
+// ResolveStakes consumes every stake still pending for the player: the round
+// resolved as a loss, a forfeit, or a slots outcome whose payout carried no
+// stake linkage. The pending ledger and the save are the commit point.
func (w casinoWallet) ResolveStakes() {
- unresolved := w.g.Economy.ResolveStakes(economyKey(w.p))
- w.recordResolutionEvidence(unresolved)
-}
-
-// recordResolutionEvidence persists file-side resolution evidence for stake
-// records whose settled markers could not be journaled. The character file
-// then proves the round resolved even though the journal could not record
-// its marker, so recovery still honors the resolution instead of refunding
-// an already-resolved round.
-func (w casinoWallet) recordResolutionEvidence(unresolved []*economyRecord) {
- if len(unresolved) == 0 {
- return
- }
- changed := false
- for _, rec := range unresolved {
- if !resolvedStakeRecorded(w.p, rec.ID) {
- w.p.ResolvedStakes = append(w.p.ResolvedStakes, rec.ID)
- changed = true
- }
- }
- if !changed {
+ if len(w.p.PendingWagers) == 0 {
return
}
+ w.p.PendingWagers = nil
if err := w.g.AccountStore.SaveCharacter(w.p); err != nil {
- fmt.Fprintf(os.Stderr, "casino resolve stakes: evidence save for %s: %v\n", w.p.Name, err)
+ fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v\n", w.p.Name, err)
}
}
-// ResolveStake settles ONE outstanding stake by wallet handle: the hand it
-// belongs to was paid (its return is inside the settlement) or lost, so it
-// must never be refunded. Unresolvable markers fall back to the same file-
-// side evidence the bulk ResolveStakes uses.
+// ResolveStake consumes ONE pending stake by wallet handle: its hand lost or
+// was paid by a cover, so it must never be refunded. A settlement that
+// already removed it (PayStakeCover) makes this a no-op, so no redundant
+// save is paid.
func (w casinoWallet) ResolveStake(stakeID string) {
- if stakeID == "" {
+ if stakeID == "" || !removePendingWager(w.p, stakeID) {
return
}
- unresolved := w.g.Economy.ResolveStakeByID(economyKey(w.p), stakeID)
- w.recordResolutionEvidence(unresolved)
-}
-
-// 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.
-//
-// 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++
- }
+ if err := w.g.AccountStore.SaveCharacter(w.p); err != nil {
+ fmt.Fprintf(os.Stderr, "casino resolve stake: save for %s: %v\n", w.p.Name, err)
}
- return added
}
-// RefundOutstanding returns every outstanding stake to the player: chips
-// come back as chips, credits through the durable payout path. The stakes
-// are resolved only once the return is durable; on failure the chip return
-// is rolled back out of memory and the open records drive recovery instead.
-//
-// Stakes already accounted for by an open settlement payout are NOT
-// refunded: a journaled table settlement includes its hand's stake return
-// and recovery re-applies that owed payout, so refunding the stake here
-// would pay the round twice (the returned credits then arrive on top of
-// the stake). The stakes are resolved; their money arrives with the
-// re-applied settlement.
+// RefundOutstanding returns every pending stake to the player — chips in
+// kind, credits as credits — and clears the ledger in one save. It is used
+// by graceful shutdown/eviction and by a pre-deal disconnect. On a failed
+// save the in-memory return and ledger are reverted so a later attempt (or
+// startup recovery) retries cleanly.
func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) {
- for _, rec := range w.g.Economy.openStakes(economyKey(w.p)) {
- if resolvedStakeRecorded(w.p, rec.ID) {
- // Already resolved through file-side evidence: recovery
- // settles it, never refunds it.
- continue
- }
- if w.g.Economy.stakeCovered(rec) {
- // The covering settlement is journaled and owed: resolving
- // the stake defers its return to the payout re-application.
- continue
- }
- chips += rec.Chips
- credits += rec.Credit
+ before := append([]player.PendingWager(nil), w.p.PendingWagers...)
+ for _, pending := range before {
+ chips += pending.Chips
+ credits += pending.Credits
}
- if chips <= 0 && credits <= 0 {
- w.ResolveStakes()
+ if chips == 0 && credits == 0 {
return 0, 0, nil
}
+ creditsBefore := w.p.Credits
+ w.p.PendingWagers = nil
if chips > 0 {
if !w.g.giveChips(w.p, chips) {
- // No inventory room for the chip return: nothing was refunded.
- // The stakes stay open so recovery (or a later attempt once the
- // player has inventory space) can complete the refund; callers
- // report the stake as recorded rather than refunded, never as
- // silently dropped.
+ w.p.PendingWagers = before
return 0, 0, casino.ErrWalletPersistence
}
}
- if credits > 0 {
- if err := w.payCreditsFlagged(credits, true, nil); err != nil {
- if chips > 0 {
- // Roll the chip return back out of memory: the durable
- // refund will re-apply both amounts from the open records.
- w.p.RemoveItem("chips", chips)
- }
- return chips, credits, err
+ sum, capErr := addCreditsChecked(creditsBefore, credits)
+ if capErr != nil {
+ if chips > 0 {
+ w.p.RemoveItem("chips", chips)
}
- } else if chips > 0 {
- // A chips-only refund needs its own durable save.
- if err := w.g.AccountStore.SaveCharacter(w.p); err != nil {
+ w.p.PendingWagers = before
+ return 0, 0, casino.ErrWalletPersistence
+ }
+ w.p.Credits = sum
+ if err := w.g.AccountStore.SaveCharacter(w.p); err != nil {
+ w.p.Credits = creditsBefore
+ if chips > 0 {
w.p.RemoveItem("chips", chips)
- return chips, 0, casino.ErrWalletPersistence
}
+ w.p.PendingWagers = before
+ return 0, 0, casino.ErrWalletPersistence
}
- w.ResolveStakes()
return chips, credits, nil
}
diff --git a/internal/game/casino_forfeit_regression_test.go b/internal/game/casino_forfeit_regression_test.go
index d29187d..c064712 100644
--- a/internal/game/casino_forfeit_regression_test.go
+++ b/internal/game/casino_forfeit_regression_test.go
@@ -54,7 +54,7 @@ func TestDisconnectForfeitsBlackjackStake(t *testing.T) {
p.Credits = 100
h.g.Casino.Join(1, casino.TableConfig{ID: "bj", Game: casino.GameBlackjack, MaxPlayers: 2}, "alice")
h.g.Casino.Bet(1, "bj", "alice", "", 10, casinoWallet{g: h.g, p: p})
- committed = len(h.g.Economy.openStakes("alice")) == 1
+ committed = len(p.PendingWagers) == 1
})
h.barrier()
}
diff --git a/internal/game/casino_refund_regression_test.go b/internal/game/casino_refund_regression_test.go
index 90864e2..6842c32 100644
--- a/internal/game/casino_refund_regression_test.go
+++ b/internal/game/casino_refund_regression_test.go
@@ -1,10 +1,9 @@
package game
-// Settlement/refund regressions for the durable-money audit: a settlement
-// whose save failed is journaled as owed money, so a shutdown refund must
-// NOT also return the round's stake — recovery re-applies the owed payout,
-// which already contains the stake's return (table hands) or the round's
-// winnings (slots, where the stake was consumed by playing the round).
+// Settlement/refund regressions for single-file money: a settlement credits
+// the payout and clears the stakes it returns in ONE character save, so a
+// failed settlement leaves the stake pending (and refundable) rather than
+// paying it twice; an outstanding stake with no settlement is refunded.
import (
"path/filepath"
@@ -23,12 +22,11 @@ func failingSaveDuring(g *Game, dir string, fn func()) {
fn()
}
-// TestFailedTableSettlementDoesNotDoubleReturnStake: a won hand whose payout
-// save fails leaves the settlement journaled and owed. A graceful-shutdown
-// refund must not ALSO return the stake; recovery re-applies the payout
-// (which returns the stake inside it) exactly once. 100 - 10 + 20 = 110,
-// never 120.
-func TestFailedTableSettlementDoesNotDoubleReturnStake(t *testing.T) {
+// TestFailedSettlementLeavesStakeRefundable: a settlement whose save fails
+// changes nothing — the credit is reverted and the stake stays pending. A
+// restart refunds the stake; the win was never durable, and it is never paid
+// on top of a refund.
+func TestFailedSettlementLeavesStakeRefundable(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
@@ -43,72 +41,61 @@ func TestFailedTableSettlementDoesNotDoubleReturnStake(t *testing.T) {
failingSaveDuring(h.g, h.dir, func() {
if err := w.PayStakeCover(20, []string{wager.ID}); err == nil {
t.Error("expected the settlement save to fail")
- return
- }
- // The shutdown refund path must not return the covered stake.
- if chips, credits, err := w.RefundOutstanding(); err != nil || chips != 0 || credits != 0 {
- t.Errorf("shutdown refund returned a covered stake: chips=%d credits=%d err=%v", chips, credits, err)
- return
}
})
- h.g.recoverEconomy()
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- if loaded.Credits != 110 {
- t.Errorf("balance after recovery = %d, want 110 (stake returned exactly once, inside the re-applied payout)", loaded.Credits)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Errorf("records left open after recovery: %+v", open)
+ if p.Credits != 90 || len(p.PendingWagers) != 1 {
+ t.Errorf("after failed settlement: credits=%d pending=%+v; want 90 and one stake",
+ p.Credits, p.PendingWagers)
}
})
+
+ // A restart refunds the still-pending stake; nothing double-pays.
+ restarted := harnessFromDir(t, h.dir)
+ loaded, err := restarted.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 100 {
+ t.Fatalf("balance after restart = %d, want 100 (failed payout not applied; stake refunded)", loaded.Credits)
+ }
+ if len(loaded.PendingWagers) != 0 {
+ t.Fatalf("pending after recovery = %+v, want none", loaded.PendingWagers)
+ }
}
-// TestFailedSlotsPayoutDoesNotDoubleReturnStake: a slot spin's payout is
-// winnings only — the stake is consumed by playing the round. When the
-// payout save fails, the journaled winnings prove the round resolved: the
-// shutdown refund must not return the stake on top of the owed payout.
-func TestFailedSlotsPayoutDoesNotDoubleReturnStake(t *testing.T) {
+// TestSuccessfulSettlementClearsPendingDurably: the payout and the removal of
+// the stake it returns are one save, so the durable file never holds both.
+func TestSuccessfulSettlementClearsPendingDurably(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
h.g.RunOnOwner(func() {
p.Credits = 100
w := casinoWallet{g: h.g, p: p}
- if _, err := w.Wager(10); err != nil {
- t.Error(err)
- return
- }
- failingSaveDuring(h.g, h.dir, func() {
- // Machine payouts carry no stake linkage (nil covers): the
- // journaled settlement still resolves the round.
- if err := w.PayCredits(20); err == nil {
- t.Error("expected the payout save to fail")
- return
- }
- if chips, credits, err := w.RefundOutstanding(); err != nil || chips != 0 || credits != 0 {
- t.Errorf("shutdown refund paid a resolved round's stake: chips=%d credits=%d err=%v", chips, credits, err)
- return
- }
- })
- h.g.recoverEconomy()
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ wager, err := w.Wager(10)
if err != nil {
t.Error(err)
return
}
- if loaded.Credits != 110 {
- t.Errorf("balance after recovery = %d, want 110 (stake consumed by the round, winnings paid once)", loaded.Credits)
+ if err := w.PayStakeCover(20, []string{wager.ID}); err != nil {
+ t.Error(err)
}
})
+ if p.Credits != 110 || len(p.PendingWagers) != 0 {
+ t.Fatalf("after settlement: credits=%d pending=%+v; want 110 and none", p.Credits, p.PendingWagers)
+ }
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 110 || len(loaded.PendingWagers) != 0 {
+ t.Fatalf("durable after settlement: credits=%d pending=%+v; want 110 and none",
+ loaded.Credits, loaded.PendingWagers)
+ }
}
-// TestUncoveredStakeStillRefunds: without a journaled settlement, the
-// shutdown refund path keeps returning outstanding stakes (the documented
-// refund-on-shutdown policy) — a regression guard that the coverage skip
-// never over-triggers.
+// TestUncoveredStakeStillRefunds: without a settlement, the refund path
+// returns an outstanding stake (the refund-on-shutdown policy).
func TestUncoveredStakeStillRefunds(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
@@ -125,12 +112,15 @@ func TestUncoveredStakeStillRefunds(t *testing.T) {
t.Errorf("uncovered stake refund: chips=%d credits=%d err=%v", chips, credits, err)
return
}
+ if p.Credits != 100 || len(p.PendingWagers) != 0 {
+ t.Errorf("after refund: credits=%d pending=%+v; want 100 and none", p.Credits, p.PendingWagers)
+ }
})
}
-// TestCasinoManagerRefundPathUsesCoverage: the same rule through the live
-// manager path (baccarat settle → RefundAll), exercised with a probe wallet
-// so the settlement save fails mid-round.
+// TestCasinoManagerFailedSettlementRefundIsExact drives a full baccarat round
+// through the manager with a wallet whose settlement save fails: the stake
+// stays pending and is refunded exactly once (never paid on top of a payout).
func TestCasinoManagerFailedSettlementRefundIsExact(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
@@ -138,27 +128,18 @@ func TestCasinoManagerFailedSettlementRefundIsExact(t *testing.T) {
h.g.RunOnOwner(func() {
p.Credits = 1000
h.g.Casino.Join(1, casino.TableConfig{ID: "b", Game: casino.GameBaccarat}, "alice")
- var totalPaid int
w := casinoWallet{g: h.g, p: p}
- wallet := &fallbackProbeWallet{harness: h, inner: w, onPay: func(credits int, failedCall bool) {
- totalPaid += credits
- }}
- // One bet: a lone player's bet deals and settles immediately. The
- // wallet fails the first settlement's journal-free save via a
- // broken SAVE (not journal) so the record stays open and owed.
- h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet)
+ // A lone player's bet deals and settles immediately; break the save
+ // for that settlement so the stake stays pending.
+ failingSaveDuring(h.g, h.dir, func() {
+ h.g.Casino.Bet(1, "b", "alice", "player", 10, w)
+ })
h.g.emitCasinoEvents(h.g.Casino.RefundAll())
- h.g.recoverEconomy()
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- // The stake was committed durably (990); RefundAll must not return
- // it while the settlement payout is owed; recovery re-applies the
- // payout: 1000 - 10 + totalPaid = final.
- if want := 1000 - 10 + totalPaid; loaded.Credits != want {
- t.Errorf("balance after refund+recovery = %d, want %d (wager 10, paid %d)", loaded.Credits, want, totalPaid)
- }
})
+ if p.Credits != 1000 {
+ t.Fatalf("balance after failed settlement and refund = %d, want 1000", p.Credits)
+ }
+ if len(p.PendingWagers) != 0 {
+ t.Fatalf("pending after refund = %+v, want none", p.PendingWagers)
+ }
}
diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go
index 1ec0b7c..deddf0b 100644
--- a/internal/game/cmd_bank.go
+++ b/internal/game/cmd_bank.go
@@ -417,10 +417,9 @@ func (g *Game) doBankWithdrawCredits(sess *net.Session, parts []string) {
// persistBankCredits makes a credit move between the on-hand wallet and the
// bank durable, reverting it in memory when the save fails. A best-effort
-// save here would leave the in-memory balance ahead of the durable file, and
-// a wager taken from that phantom balance journals checkpoints the file can
-// never match — recovery then leaves the stake permanently ambiguous instead
-// of refunding or settling it. Reports whether the move may be announced.
+// save here would leave the in-memory balance ahead of the durable file, so
+// a wager taken from that phantom balance would be charged against money the
+// player does not durably have. Reports whether the move may be announced.
func (g *Game) persistBankCredits(p *player.Player, creditsBefore, bankedBefore int) bool {
if err := g.AccountStore.SaveCharacter(p); err != nil {
log.Printf("bank credit transfer %s: %v", p.Name, err)
diff --git a/internal/game/economy.go b/internal/game/economy.go
deleted file mode 100644
index 28e2880..0000000
--- a/internal/game/economy.go
+++ /dev/null
@@ -1,1466 +0,0 @@
-package game
-
-// The economy journal: durable, idempotent money movement. Every casino
-// wager and payout writes a journal record BEFORE the balance mutation,
-// then the character save makes the balance durable.
-//
-// Two kinds of records exist:
-//
-// - "wager": a committed stake. The record stays OPEN while the stake is
-// outstanding (the round is in flight); the game resolves it with a
-// settled marker when the round resolves (won, lost, pushed, or
-// forfeited). At startup, open wager records are refunded — the round
-// state was lost in the crash, so the stake returns to the player.
-// - "payout": money movement (winnings, pushes, refunds). The record
-// closes when the balance mutation is durable; an open payout at
-// startup is owed money and is re-applied. Durability is proven by
-// the character file itself, not by balance checkpoints: the same
-// save that includes the payout in the balance records the record's
-// ID in the character's open_payouts evidence list, and the entry is
-// dropped once the settled marker is durable. Checkpoints alone are
-// ambiguous — an unrelated credit change (shop sale, bank deposit)
-// can coincidentally land on the record's CreditsAfter balance, and
-// settling on that coincidence silently erases an owed payout.
-//
-// A crash at any point therefore leaves either:
-// - an open record with the character file at its pre-transaction state
-// (crash before save) — recovery settles it as void (debit) or applies
-// it (payout, which the player is owed); or
-// - an open wager with the file at its post-transaction state and no
-// later payout evidence (the round state was lost in the crash) — the
-// stake is refunded; or
-// - an open payout (crash after the journal write, before the save) —
-// the owed amount is re-applied.
-//
-// Records whose checkpoints match neither balance state after compensating
-// earlier records are ambiguous: they are kept open and surfaced for
-// operator inspection rather than silently settled or voided.
-//
-// The reducer folds journal lines by record ID (later lines win), so
-// settlement markers are idempotent appends. Recovery runs once at startup
-// before any session can join; compaction rewrites the journal keeping only
-// open records (outstanding stakes and unresolved payouts).
-
-import (
- "bufio"
- "bytes"
- "crypto/rand"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "thehouseoficarus/internal/casino"
- "thehouseoficarus/internal/player"
-)
-
-type economyRecord struct {
- ID string `json:"id"`
- Player string `json:"player"`
- // PlayerID is the character's stable UUID (player.Player.ID), assigned
- // at creation and backfilled at first startup. Records resolve their
- // subject by ID at recovery so a character RENAME can never strand an
- // owed payout or outstanding stake; Player (the name) is kept for
- // operator inspection and for legacy records written before IDs.
- PlayerID string `json:"player_id,omitempty"`
- Kind string `json:"kind"` // "wager" or "payout"
- Chips int `json:"chips"`
- Credit int `json:"credits"`
- Status string `json:"status"` // "open", "settled", "void"
- TS time.Time `json:"ts"`
- // Seq is the record's monotonic creation order within the journal. It
- // lets coverage checks distinguish a settlement that predates a stake
- // (and therefore cannot return it) from one written after it.
- Seq int64 `json:"seq,omitempty"`
- // Refund marks a payout record as a SHUTDOWN/EVICTION REFUND (the
- // stake being returned), not a game settlement. Recovery must not
- // treat the chip part of a refund as paid in credits: a refund's chip
- // stake returns in kind, so a crash mid-refund still owes the chips.
- // Settlement payouts (a win, push, or surrender return) instead prove
- // the round resolved, closing the stake without any chip return.
- Refund bool `json:"refund,omitempty"`
-
- // Tracked marks a payout written under the character-file evidence
- // scheme: the record's ID is recorded in the character's open_payouts
- // list by the same save that includes its amount in the balance, and
- // recovery uses that membership — not balance checkpoints — to decide
- // whether the payout became durable.
- Tracked bool `json:"tracked,omitempty"`
-
- // Balance checkpoints used to decide recovery compensation.
- CreditsBefore int `json:"credits_before"`
- CreditsAfter int `json:"credits_after"`
- ChipsBefore int `json:"chips_before"`
- ChipsAfter int `json:"chips_after"`
-
- // Covers lists the stake wager IDs a settlement payout RETURNS (a
- // table hand pays its stake back inside the payout). Empty means no
- // explicit linkage: recovery treats such a settlement as resolving
- // every open stake of its player (the historical behavior, and the
- // right reading for a slots payout whose stake was consumed by
- // playing the round). A REFUND payout never covers: its credit part
- // is the stake return being re-applied, not a new settlement.
- Covers []string `json:"covers,omitempty"`
-}
-
-type economyJournal struct {
- path string
- // dirSynced records whether the directory entry of path has been made
- // durable. A file's own fsync does not persist the entry that names it,
- // so the first append must sync the directory (as persistence.WriteFile
- // does) or a crash can lose the whole journal — including the record of
- // a debit that is already durable in the character file.
- dirSynced bool
- // tailChecked records whether the journal tail has been verified to end on
- // a newline before the first append of this process. A torn final line
- // (short write) must be truncated before a new record is appended, or the
- // two merge into one malformed mid-file line that permanently aborts
- // recovery.
- tailChecked bool
- // stakes indexes outstanding wager records per player identity key
- // (economyKey: the character's stable ID when known, else its name).
- // All journal mutations happen on the game owner (or during startup
- // recovery before any session connects), so no lock is needed.
- stakes map[string][]*economyRecord
- // openPayouts indexes payout records that are still open (journaled but
- // not provably durable). It drives evidence pruning: a character-file
- // ID absent from this set is settled or void and must not be trusted as
- // durability evidence.
- openPayouts map[string]bool
- // openPayoutRecs indexes open payout records per player key so a
- // refund can check which stakes an owed settlement already returns
- // (stakeCovered). Same ownership discipline as stakes: all mutations
- // happen on the game owner or during startup recovery.
- openPayoutRecs map[string][]*economyRecord
- // seq is the monotonic record counter (see economyRecord.Seq). It is
- // advanced by startKind and raised to the journal maximum on every fold.
- seq int64
-}
-
-func newEconomyJournal(dataDir string) *economyJournal {
- dir := filepath.Join(dataDir, "economy")
- _ = os.MkdirAll(dir, 0700)
- return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool), openPayoutRecs: make(map[string][]*economyRecord)}
-}
-
-// syncDir fsyncs a directory so an entry created or replaced in it survives a
-// crash (the same durability step persistence.WriteFile performs).
-func syncDir(dir string) error {
- d, err := os.Open(dir)
- if err != nil {
- return err
- }
- defer d.Close()
- return d.Sync()
-}
-
-func newEconomyRecordID() string {
- var b [8]byte
- if _, err := rand.Read(b[:]); err != nil {
- panic(fmt.Sprintf("economy record id: %v", err))
- }
- return hex.EncodeToString(b[:])
-}
-
-// errJournalAppendUncertain reports that a journal append failed AND the
-// record's bytes could not be proven removed: the rollback truncate failed,
-// or the sync that would make that truncation durable failed, or the
-// pre-write size was unknown. The record MAY therefore still be present in
-// the journal. Callers must not treat the transaction as definitely absent:
-// the wallet's journal-unavailable payout fallback records the attempted
-// record's ID as open-payout evidence so a surviving record is settled by
-// recovery instead of being re-applied (a double-pay).
-var errJournalAppendUncertain = errors.New("economy journal append may have left the record in the journal")
-
-// append writes one durable line. Errors are returned to the caller, which
-// must treat the transaction as failed. An error wrapping
-// errJournalAppendUncertain additionally means the record may have survived
-// in the journal.
-func (j *economyJournal) append(rec economyRecord) error {
- rec.TS = time.Now()
- data, err := json.Marshal(rec)
- if err != nil {
- return err
- }
- if !j.tailChecked {
- if err := j.repairTornTail(); err != nil {
- return fmt.Errorf("repair economy journal tail: %w", err)
- }
- j.tailChecked = true
- }
- f, err := os.OpenFile(j.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
- if err != nil {
- return fmt.Errorf("open economy journal: %w", err)
- }
- defer f.Close()
- // Record the pre-write size so a failed write/sync rolls the record back.
- // "append failed" must mean "no record bytes remain": the wallet's
- // journal-unavailable fallback commits the payout through the character
- // file only, and a later recovery must not see the same payout record as
- // still owed (that double-pays). It also keeps a short write from merging
- // into the next append as a malformed mid-file line.
- startSize := int64(-1)
- if fi, statErr := f.Stat(); statErr == nil {
- startSize = fi.Size()
- }
- rollback := func(cause error) error {
- if startSize < 0 {
- // The pre-write size is unknown, so the record cannot be truncated
- // away with confidence: it may remain in the journal.
- return fmt.Errorf("%w: %v (pre-write size unknown)", errJournalAppendUncertain, cause)
- }
- if terr := f.Truncate(startSize); terr != nil {
- fmt.Fprintf(os.Stderr, "economy journal: rollback truncate after %v: %v\n", cause, terr)
- return fmt.Errorf("%w: %v (rollback truncate: %v)", errJournalAppendUncertain, cause, terr)
- }
- if serr := f.Sync(); serr != nil {
- // The truncation is not durable: a crash can restore the record
- // bytes, so the append outcome is still unknown.
- fmt.Fprintf(os.Stderr, "economy journal: rollback sync after %v: %v\n", cause, serr)
- return fmt.Errorf("%w: %v (rollback sync: %v)", errJournalAppendUncertain, cause, serr)
- }
- return cause
- }
- if _, err := f.Write(append(data, '\n')); err != nil {
- return rollback(fmt.Errorf("append economy journal: %w", err))
- }
- if err := f.Sync(); err != nil {
- return rollback(fmt.Errorf("sync economy journal: %w", err))
- }
- if !j.dirSynced {
- if err := syncDir(filepath.Dir(j.path)); err != nil {
- // The record bytes are in the file but the directory entry is not
- // guaranteed durable. Roll the record back so the caller's failure
- // is truthful, and leave dirSynced false so a later append retries
- // the directory sync.
- return rollback(fmt.Errorf("sync economy journal dir: %w", err))
- }
- j.dirSynced = true
- }
- return nil
-}
-
-// repairTornTail guarantees the journal ends on a newline before a new record
-// is appended. A short write (ENOSPC, a flaky filesystem) can leave a partial
-// JSON suffix; appending a full record after it merges the two into one
-// malformed mid-file line, which loadFolded treats as fatal and which then
-// permanently disables recovery and compaction. Truncating the torn suffix is
-// safe: a partial record was never a committed transaction. This runs at most
-// once per process (append is otherwise hot).
-func (j *economyJournal) repairTornTail() error {
- f, err := os.OpenFile(j.path, os.O_RDWR, 0600)
- if err != nil {
- if os.IsNotExist(err) {
- return nil
- }
- return err
- }
- defer f.Close()
-
- fi, err := f.Stat()
- if err != nil {
- return err
- }
- size := fi.Size()
- if size == 0 {
- return nil
- }
-
- last := make([]byte, 1)
- if _, err := f.ReadAt(last, size-1); err != nil {
- return err
- }
- if last[0] == '\n' {
- return nil
- }
-
- // Scan backwards in bounded chunks for the last newline and truncate the
- // torn suffix after it. If none exists the whole file is one torn line.
- const chunk = 64 * 1024
- buf := make([]byte, chunk)
- for end := size; end > 0; {
- start := end - chunk
- if start < 0 {
- start = 0
- }
- n := int(end - start)
- if _, err := f.ReadAt(buf[:n], start); err != nil {
- return err
- }
- if idx := bytes.LastIndexByte(buf[:n], '\n'); idx >= 0 {
- return f.Truncate(start + int64(idx) + 1)
- }
- end = start
- }
- return f.Truncate(0)
-}
-
-// appendRetry appends one durable line, retrying a bounded number of
-// times: a transient write failure must not permanently lose a marker
-// that money correctness depends on.
-func (j *economyJournal) appendRetry(rec economyRecord, attempts int) error {
- var err error
- uncertain := false
- for i := 0; i < max(attempts, 1); i++ {
- if err = j.append(rec); err == nil {
- return nil
- }
- if errors.Is(err, errJournalAppendUncertain) {
- uncertain = true
- }
- }
- // An earlier attempt whose rollback could not be proven may have left the
- // record bytes behind even when the final attempt's error is definite, so
- // the aggregate must still report that the record MAY be present.
- if uncertain && !errors.Is(err, errJournalAppendUncertain) {
- err = fmt.Errorf("%w: %v", errJournalAppendUncertain, err)
- }
- return err
-}
-
-// start opens a durable record for the in-flight transaction.
-func (j *economyJournal) start(kind string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) {
- return j.startKind(kind, false, nil, p, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter)
-}
-
-// startRefund opens a refund payout record (stake returned at shutdown or
-// eviction; see economyRecord.Refund).
-func (j *economyJournal) startRefund(p *player.Player, credits, creditsBefore, creditsAfter int) (*economyRecord, error) {
- return j.startKind("payout", true, nil, p, 0, credits, creditsBefore, creditsAfter, 0, 0)
-}
-
-func (j *economyJournal) startKind(kind string, refund bool, covers []string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) {
- rec := &economyRecord{
- ID: newEconomyRecordID(),
- Player: p.Name,
- PlayerID: p.ID,
- Kind: kind,
- Refund: refund,
- Covers: covers,
- Tracked: kind == "payout",
- Chips: chips,
- Credit: credits,
- Status: "open",
- CreditsBefore: creditsBefore,
- CreditsAfter: creditsAfter,
- ChipsBefore: chipsBefore,
- ChipsAfter: chipsAfter,
- }
- // Retry a bounded number of times: a transient append failure must not
- // drop the record that makes the wager or owed payout durable.
- j.seq++
- rec.Seq = j.seq
- if err := j.appendRetry(*rec, 5); err != nil {
- // Return the attempted record with the error. It is NOT durable, but
- // its ID is what a surviving (uncertain) append would carry, so the
- // wallet's journal-unavailable payout fallback can record it as
- // open-payout evidence and stop recovery re-applying the record.
- return rec, err
- }
- if rec.Tracked {
- j.openPayouts[rec.ID] = true
- j.openPayoutRecs[rec.key()] = append(j.openPayoutRecs[rec.key()], rec)
- }
- return rec, nil
-}
-
-// closePayoutTracking drops rec from the open-payout indexes (its status
-// changed to settled or void).
-func (j *economyJournal) closePayoutTracking(rec *economyRecord) {
- delete(j.openPayouts, rec.ID)
- kept := j.openPayoutRecs[rec.key()][:0]
- for _, open := range j.openPayoutRecs[rec.key()] {
- if open != rec {
- kept = append(kept, open)
- }
- }
- if len(kept) == 0 {
- delete(j.openPayoutRecs, rec.key())
- } else {
- j.openPayoutRecs[rec.key()] = kept
- }
-}
-
-// settle marks a record settled (the balance mutation is durable). The
-// marker is retried and the record is left OPEN (with its tracking index
-// intact) if no append succeeds: a non-durable settle must never drop the
-// evidence a later recovery needs, or the liability is paid twice.
-func (j *economyJournal) settle(rec *economyRecord) {
- rec.Status = "settled"
- if err := j.appendRetry(*rec, 3); err != nil {
- rec.Status = "open"
- return
- }
- j.closePayoutTracking(rec)
-}
-
-// settleDurable marks a record settled and persists the marker with retries,
-// reporting whether the marker became durable. A false return leaves the
-// record open in the journal: callers keeping file-side evidence for this
-// record must NOT drop that evidence until this returns true.
-func (j *economyJournal) settleDurable(rec *economyRecord) bool {
- rec.Status = "settled"
- if err := j.appendRetry(*rec, 3); err != nil {
- rec.Status = "open"
- return false
- }
- j.closePayoutTracking(rec)
- return true
-}
-
-// void marks a record void (the mutation was reverted in memory and never
-// became durable; recovery must not compensate). Like settle, the marker is
-// retried and the record is left open on failure so its index/evidence is
-// preserved for a later attempt.
-func (j *economyJournal) void(rec *economyRecord) {
- rec.Status = "void"
- if err := j.appendRetry(*rec, 3); err != nil {
- rec.Status = "open"
- return
- }
- j.closePayoutTracking(rec)
-}
-
-// registerStake tracks an outstanding wager record for its player. Wager
-// records stay open (refundable) until the round resolves.
-func (j *economyJournal) registerStake(rec *economyRecord) {
- j.stakes[rec.key()] = append(j.stakes[rec.key()], rec)
-}
-
-// openStakes returns the outstanding stake records for a player identity
-// key (see economyKey).
-func (j *economyJournal) openStakes(player string) []*economyRecord {
- return j.stakes[player]
-}
-
-// ResolveStakes closes every outstanding stake for a player with a settled
-// marker per record: the round resolved, so recovery must not refund them.
-// A marker whose append cannot be made durable leaves that stake OPEN
-// (never dropped from the index): recovery then settles it from later
-// payout evidence, or — if the round was a loss with no settlement
-// payout — refunds it under the documented refund-on-crash policy. A
-// stake is only forgotten once its marker is durable.
-//
-// It returns the records whose settled marker could NOT be made durable.
-// Callers holding the player should record those IDs as resolved-stake
-// evidence (player.Player.ResolvedStakes) so recovery still honors the
-// resolution even though the journal could not persist its marker.
-func (j *economyJournal) ResolveStakes(player string) []*economyRecord {
- var unresolved []*economyRecord
- remaining := j.stakes[player][:0]
- for _, rec := range j.stakes[player] {
- rec.Status = "settled"
- if err := j.appendRetry(*rec, 3); err != nil {
- // The marker is not durable: the stake stays open so recovery
- // (or a later attempt) still resolves it.
- rec.Status = "open"
- remaining = append(remaining, rec)
- unresolved = append(unresolved, rec)
- }
- }
- if len(remaining) == 0 {
- delete(j.stakes, player)
- } else {
- j.stakes[player] = remaining
- }
- return unresolved
-}
-
-// ResolveStakeByID closes ONE outstanding stake (identified by its wallet
-// handle) with a settled marker: the hand it belongs to was paid or lost,
-// so it must never be refunded even while sibling stakes of the same round
-// remain open. On success the stake leaves the index; a marker whose append
-// cannot be made durable leaves the stake OPEN (the covering settlement's
-// journal record, or file-side resolution evidence, still protects it from
-// being refunded twice).
-func (j *economyJournal) ResolveStakeByID(player, id string) []*economyRecord {
- recs := j.stakes[player]
- remaining := make([]*economyRecord, 0, len(recs))
- var unresolved []*economyRecord
- for _, rec := range recs {
- if rec.ID != id {
- remaining = append(remaining, rec)
- continue
- }
- rec.Status = "settled"
- if err := j.appendRetry(*rec, 3); err != nil {
- rec.Status = "open"
- remaining = append(remaining, rec)
- unresolved = append(unresolved, rec)
- }
- }
- if len(remaining) == 0 {
- delete(j.stakes, player)
- } else {
- j.stakes[player] = remaining
- }
- return unresolved
-}
-
-// stakeCovered reports whether an OPEN payout record already accounts for
-// stake's return. A table settlement lists the stake IDs it covers; a
-// legacy (or slots) settlement without an explicit list is treated as
-// resolving every open stake of its player — a journaled settlement means
-// the round resolved, and its recovery re-application delivers the money.
-// A REFUND payout never covers: its credit part is the stake return being
-// re-applied, not a settlement.
-func (j *economyJournal) stakeCovered(stake *economyRecord) bool {
- for _, payout := range j.openPayoutRecs[stake.key()] {
- if payout.Refund {
- continue
- }
- // A payout written BEFORE the stake cannot be its settlement: without
- // this ordering check an old open payout (for example a failed slots
- // win) would mask a later, unrelated stake and RefundOutstanding
- // would resolve it without returning the money.
- if payout.Seq <= stake.Seq {
- continue
- }
- if len(payout.Covers) == 0 || containsString(payout.Covers, stake.ID) {
- return true
- }
- }
- return false
-}
-
-func containsString(list []string, s string) bool {
- for _, item := range list {
- if item == s {
- return true
- }
- }
- return false
-}
-
-// economyKey is the identity a player's journal records are indexed by:
-// the stable character ID when known, else the (renameable) name.
-func economyKey(p *player.Player) string {
- if p.ID != "" {
- return p.ID
- }
- return p.Name
-}
-
-// economyRecordKey mirrors economyKey for a raw record.
-func (rec *economyRecord) key() string {
- if rec.PlayerID != "" {
- return rec.PlayerID
- }
- return rec.Player
-}
-
-// resolvedStakeRecorded reports whether the character's durable evidence
-// list claims rec's stake was resolved by the session (its settled marker
-// could not be journaled; see player.Player.ResolvedStakes).
-func resolvedStakeRecorded(p *player.Player, id string) bool {
- for _, existing := range p.ResolvedStakes {
- if existing == id {
- return true
- }
- }
- return false
-}
-
-// pruneResolvedStakes drops resolved-stake evidence IDs that no longer
-// correspond to an open wager record (settled or void). It reports whether
-// anything changed, so callers only pay for a save when the file would
-// actually differ.
-func pruneResolvedStakes(openStakeIDs map[string]bool, p *player.Player) bool {
- if len(p.ResolvedStakes) == 0 {
- return false
- }
- kept := p.ResolvedStakes[:0]
- changed := false
- for _, id := range p.ResolvedStakes {
- if openStakeIDs[id] {
- kept = append(kept, id)
- } else {
- changed = true
- }
- }
- if changed {
- p.ResolvedStakes = kept
- }
- return changed
-}
-
-// payoutIdentifies reports whether two journal records belong to the same
-// character identity. ID-carrying records match only on the stable ID: a
-// different character that reused the display name must never supply (or
-// consume) another character's resolution evidence. Legacy records without
-// IDs keep the historical name-based match among themselves.
-func payoutIdentifies(a, b economyRecord) bool {
- if a.PlayerID != "" && b.PlayerID != "" {
- return a.PlayerID == b.PlayerID
- }
- if a.PlayerID != "" || b.PlayerID != "" {
- // Mixed legacy/ID records: refuse to match on name alone so an ID
- // can never be resolved through a name that another character may
- // have reused.
- return false
- }
- return strings.EqualFold(a.Player, b.Player)
-}
-
-// reloadStakes rebuilds the outstanding-stake index from the journal file:
-// open wager records remain tracked (and refundable/resolvable) after
-// recovery compaction. A read failure leaves the previous indexes intact
-// (never a wiped index over a live journal); the caller logs loudly.
-func (j *economyJournal) reloadStakes() error {
- folded, err := j.loadFolded()
- if err != nil {
- return err
- }
- j.stakes = make(map[string][]*economyRecord)
- j.openPayouts = make(map[string]bool)
- j.openPayoutRecs = make(map[string][]*economyRecord)
- for _, fr := range folded {
- if fr.rec.Kind == "wager" && fr.rec.Status == "open" {
- rec := fr.rec
- j.registerStake(&rec)
- }
- if fr.rec.Kind == "payout" && fr.rec.Status == "open" {
- rec := fr.rec
- j.openPayouts[fr.rec.ID] = true
- j.openPayoutRecs[rec.key()] = append(j.openPayoutRecs[rec.key()], &rec)
- }
- }
- return nil
-}
-
-// loadOpen folds the journal and returns the still-open records.
-func (j *economyJournal) loadOpen() ([]*economyRecord, error) {
- var open []*economyRecord
- folded, err := j.loadFolded()
- if err != nil {
- return nil, err
- }
- for _, fr := range folded {
- if fr.rec.Status == "open" {
- rec := fr.rec
- open = append(open, &rec)
- }
- }
- return open, nil
-}
-
-// foldedRecord is the latest state of one record ID. Ordering decisions
-// (which record came first) are made from economyRecord.Seq — the record's
-// creation order — never from the record's line position, which a later
-// rewrite of the same record moves.
-type foldedRecord struct {
- rec economyRecord
-}
-
-// loadFolded folds the journal (latest line per record ID wins) and
-// returns the surviving records in journal append order. A read failure
-// (oversized or unreadable region) is an ERROR, never a silent truncation:
-// callers must not compact or compensate over a partially-read journal,
-// because compaction would permanently erase every record beyond the
-// unreadable point — including owed payouts and outstanding stakes.
-func (j *economyJournal) loadFolded() ([]foldedRecord, error) {
- f, err := os.Open(j.path)
- if err != nil {
- if os.IsNotExist(err) {
- return nil, nil // no journal yet: nothing to fold
- }
- // Any other open failure (permissions, symlink loops, I/O errors)
- // is NOT "an empty healthy journal": callers must not compensate
- // or compact over a journal whose contents are unknown.
- return nil, fmt.Errorf("open economy journal: %w", err)
- }
- defer f.Close()
- latest := map[string]*foldedRecord{}
- var order []*foldedRecord
- corruptPending := false
- corruptSeq := 0
- scanner := bufio.NewScanner(f)
- scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
- for seq := 0; scanner.Scan(); seq++ {
- line := scanner.Bytes()
- if len(line) == 0 {
- continue
- }
- var rec economyRecord
- if err := json.Unmarshal(line, &rec); err != nil {
- // A malformed line is only tolerable as a torn FINAL line (a
- // crash mid-append; appends are small and single-line, so a
- // torn write can also merge with a later append onto one
- // mid-file line). A malformed record followed by ANY other
- // record means the fold cannot be trusted: surface the
- // failure instead of skipping the line — compensating or
- // compacting over a damaged journal could erase owed money.
- if !corruptPending {
- corruptPending = true
- corruptSeq = seq
- }
- continue
- }
- if corruptPending {
- return nil, fmt.Errorf("economy journal line %d is malformed; later records exist and may be owed money (journal preserved for inspection)", corruptSeq)
- }
- if rec.Seq > j.seq {
- j.seq = rec.Seq
- }
- if existing, ok := latest[rec.ID]; ok {
- existing.rec = rec
- } else {
- fr := &foldedRecord{rec: rec}
- latest[rec.ID] = fr
- order = append(order, fr)
- }
- }
- if err := scanner.Err(); err != nil {
- // A scanner error means the fold is INCOMPLETE (a line exceeded the
- // buffer or the file became unreadable mid-scan). Returning a
- // partial fold would let compaction erase valid records; surface
- // the failure instead.
- return nil, fmt.Errorf("scan economy journal: %w", err)
- }
- folded := make([]foldedRecord, 0, len(order))
- for _, fr := range order {
- folded = append(folded, *fr)
- }
- return folded, nil
-}
-
-// compact rewrites the journal keeping only still-open records. A fold
-// failure aborts the rewrite: compacting over a journal that could not be
-// fully read would silently erase every record beyond the unreadable point.
-func (j *economyJournal) compact() {
- open, err := j.loadOpen()
- if err != nil {
- fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err)
- return
- }
- tmp := j.path + ".compact"
- f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
- if err != nil {
- fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err)
- return
- }
- defer os.Remove(tmp)
- w := bufio.NewWriter(f)
- for _, rec := range open {
- rec.TS = time.Now()
- data, err := json.Marshal(rec)
- if err != nil {
- continue
- }
- if _, err := w.Write(data); err != nil {
- f.Close()
- fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err)
- return
- }
- w.WriteByte('\n')
- }
- if err := w.Flush(); err != nil {
- f.Close()
- return
- }
- if err := f.Sync(); err != nil {
- f.Close()
- return
- }
- f.Close()
- // Best-effort atomic-ish replacement; a torn compaction leaves the
- // original file intact. The rename is made durable like the file's own
- // sync, so a crash cannot lose the compacted journal entirely.
- if err := os.Rename(tmp, j.path); err != nil {
- os.Remove(tmp)
- return
- }
- if err := syncDir(filepath.Dir(j.path)); err != nil {
- fmt.Fprintf(os.Stderr, "economy compaction: sync journal dir: %v\n", err)
- }
-}
-
-// giveChips re-credits chips to a player's inventory (used by refund and
-// recovery paths). It stacks onto an existing chips slot when one exists.
-// It reports false when the chips do NOT fit (no existing stack and no free
-// slot): the caller must then treat the chips as undelivered — they are
-// never silently dropped; the money record stays open instead.
-func (g *Game) giveChips(p *player.Player, qty int) bool {
- for _, slot := range p.Inventory {
- if slot != nil && slot.ItemID == "chips" {
- slot.Quantity += qty
- return true
- }
- }
- slot := p.FirstFreeSlot()
- if slot < 0 {
- return false
- }
- p.SetInvSlot(slot, &player.InventorySlot{ItemID: "chips", Quantity: qty})
- return true
-}
-
-// payCreditsDurable applies a checked credit increase and persists it.
-// A failed save reverts the in-memory mutation: recovery must keep
-// comparing later records against the DURABLE balance.
-func (g *Game) payCreditsDurable(p *player.Player, amount int) error {
- if amount < 0 {
- return fmt.Errorf("negative payout %d", amount)
- }
- sum, err := addCreditsChecked(p.Credits, amount)
- if err != nil {
- return err
- }
- before := p.Credits
- p.Credits = sum
- if err := g.AccountStore.SaveCharacter(p); err != nil {
- p.Credits = before
- return err
- }
- return nil
-}
-
-// applyOpenPayout re-applies an open TRACKED payout record during recovery:
-// checked credit increase plus a character save that records the record ID
-// in the file's open_payouts evidence list. A failed save reverts the
-// in-memory mutation (credits and evidence) so the record stays open and a
-// later recovery attempt retries cleanly.
-func (g *Game) applyOpenPayout(p *player.Player, rec *economyRecord) error {
- if rec.Credit < 0 {
- return fmt.Errorf("negative payout %d", rec.Credit)
- }
- sum, err := addCreditsChecked(p.Credits, rec.Credit)
- if err != nil {
- return err
- }
- before := p.Credits
- p.Credits = sum
- p.OpenPayouts = append(p.OpenPayouts, rec.ID)
- if err := g.AccountStore.SaveCharacter(p); err != nil {
- p.Credits = before
- removeOpenPayout(p, rec.ID)
- return err
- }
- return nil
-}
-
-// grantCredits adds credits from ordinary gameplay (shop sales, credit
-// pickups, activity payouts). The economy cap is enforced by clamping: the
-// cap is far above any reachable balance, so hitting it means a data bug —
-// clamp (never wrap) and log loudly for the operator.
-func (g *Game) grantCredits(p *player.Player, amount int) {
- if amount <= 0 {
- return
- }
- sum, err := addCreditsChecked(p.Credits, amount)
- if err != nil {
- fmt.Fprintf(os.Stderr, "credit grant for %s clamped at the economy cap: %v\n", p.Name, err)
- p.Credits = casino.MaxCredits
- return
- }
- p.Credits = sum
-}
-
-// addCreditsChecked is the game-side checked addition against the shared
-// economy cap (casino.MaxCredits, mirrored by the casino wallet: a second,
-// independently drifting cap would let the two sides of the money path
-// disagree about what a balance may hold).
-func addCreditsChecked(a, b int) (int, error) {
- if b > 0 && a > casino.MaxCredits-b {
- return casino.MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, casino.MaxCredits)
- }
- if b < 0 {
- return 0, fmt.Errorf("negative credit add %d", b)
- }
- return a + b, nil
-}
-
-// openPayoutRecorded reports whether the character's durable evidence list
-// claims rec's amount is included in the balance.
-func openPayoutRecorded(p *player.Player, id string) bool {
- for _, existing := range p.OpenPayouts {
- if existing == id {
- return true
- }
- }
- return false
-}
-
-// removeOpenPayout drops id from the in-memory evidence list; the on-disk
-// copy is corrected by the next character save.
-func removeOpenPayout(p *player.Player, id string) {
- kept := p.OpenPayouts[:0]
- for _, existing := range p.OpenPayouts {
- if existing != id {
- kept = append(kept, existing)
- }
- }
- p.OpenPayouts = kept
-}
-
-// pruneOpenPayouts drops evidence IDs that no longer correspond to an open
-// payout record (settled or void). It reports whether anything changed, so
-// callers only pay for a save when the file would actually differ.
-func pruneOpenPayouts(j *economyJournal, p *player.Player) bool {
- if j == nil || len(p.OpenPayouts) == 0 {
- return false
- }
- if j.openPayouts == nil {
- j.openPayouts = make(map[string]bool)
- }
- kept := p.OpenPayouts[:0]
- changed := false
- for _, id := range p.OpenPayouts {
- if j.openPayouts[id] {
- kept = append(kept, id)
- } else {
- changed = true
- }
- }
- if changed {
- p.OpenPayouts = kept
- }
- return changed
-}
-
-// reanchorOwedPayout rewrites an open payout record's checkpoints to the
-// current durable balance so a later recovery reads it as still owed (the
-// balance sits at the record's before-checkpoint). It is used when a recovery
-// run could not pay every sibling in an identical-signature group: the paid
-// siblings moved the durable balance, so leaving the unpaid remainder at its
-// original checkpoints would let a later run mistake the balance for its own
-// durability and settle the liability without paying it. The post-checkpoint
-// respects the economy cap: when the credit cannot fit, before == after, so
-// the record keeps reading as owed at this balance instead of claiming an
-// impossible post-state.
-func reanchorOwedPayout(rec *economyRecord, credits, credit int) {
- rec.CreditsBefore = credits
- rec.CreditsAfter = credits
- if after, err := addCreditsChecked(credits, credit); err == nil {
- rec.CreditsAfter = after
- }
-}
-
-// recoverEconomy compensates open records from the last crash. It is called
-// once from New, before any session connects.
-//
-// Recovery runs in two phases so checkpoints stay meaningful:
-//
-// 1. Open PAYOUT records are processed in append order against the evolving
-// durable balance: file at the pre-payout balance → the payout is
-// re-applied (the player is owed it); at the post-payout balance → it is
-// already durable and settles — UNLESS an earlier recovery run (or the
-// original settlement) already settled a same-checkpoint sibling, in
-// which case the balance is explained by THAT record and this open
-// record is still owed and re-applied; anything else stays open and is
-// surfaced for operator inspection (a liability is never silently
-// dropped).
-// 2. Open WAGER records are processed NEWEST FIRST (each refund restores
-// the balance an earlier record's checkpoints refer to). A record at
-// its pre-debit state (credits AND chips) never became durable → void;
-// at its post-debit state → the stake was charged but the round state
-// was lost in the crash → the stake is refunded, unless a payout record
-// written later proves the round resolved (a win/push/refund already
-// returned the stake inside that payout) → settled with no refund.
-// Anything else stays open for operator inspection.
-func (g *Game) recoverEconomy() {
- if g.Economy == nil {
- return
- }
- folded, err := g.Economy.loadFolded()
- if err != nil {
- // The journal could not be fully read. Compensation over a partial
- // fold could double-pay or erase liabilities, and compaction would
- // truncate the file. Preserve everything for operator inspection.
- fmt.Fprintf(os.Stderr, "economy recovery aborted: %v (journal and character files preserved for inspection)\n", err)
- return
- }
- if g.Economy.openPayouts == nil {
- g.Economy.openPayouts = make(map[string]bool)
- } else {
- for id := range g.Economy.openPayouts {
- delete(g.Economy.openPayouts, id)
- }
- }
- for i := range folded {
- rec := &folded[i].rec
- if rec.Kind == "payout" && rec.Status == "open" {
- g.Economy.openPayouts[rec.ID] = true
- }
- }
- // Player records resolve by the STABLE character ID (a rename must
- // never strand an owed payout); the name lookup remains for legacy
- // records written before IDs existed. Every character file is loaded
- // up front so records resolve by stable ID without filename guessing.
- playersByID := make(map[string]*player.Player)
- playersByName := make(map[string]*player.Player)
- if names, err := g.AccountStore.ListCharacterNames(); err == nil {
- for _, name := range names {
- if p, err := g.AccountStore.LoadCharacter(name); err == nil {
- if p.ID != "" {
- playersByID[p.ID] = p
- }
- playersByName[strings.ToLower(p.Name)] = p
- }
- }
- }
- // finalizeLoaded prunes stale open-payout evidence and resolved-stake
- // evidence the first time a character file is touched: IDs the journal
- // shows as settled or void cannot prove anything and are dropped (with
- // a durable save) so the lists stay small and meaningful.
- openStakeIDs := make(map[string]bool)
- for i := range folded {
- rec := &folded[i].rec
- if rec.Kind == "wager" && rec.Status == "open" {
- openStakeIDs[rec.ID] = true
- }
- }
- finalizeLoaded := func(p *player.Player) *player.Player {
- prunedPayouts := pruneOpenPayouts(g.Economy, p)
- prunedStakes := pruneResolvedStakes(openStakeIDs, p)
- if prunedPayouts || prunedStakes {
- if err := g.AccountStore.SaveCharacter(p); err != nil {
- fmt.Fprintf(os.Stderr, "economy recovery: evidence prune save %s: %v\n", p.Name, err)
- }
- }
- return p
- }
- loadPlayer := func(rec *economyRecord) *player.Player {
- if rec.PlayerID != "" {
- // A record carrying a stable ID resolves to THAT identity only.
- // Never fall back to the display name: a different character
- // that later reused the name would collect the original
- // character's payout and the original liability would be
- // settled as paid. See TestRecoveryDoesNotPayReusedCharacterName.
- if p, ok := playersByID[rec.PlayerID]; ok {
- return finalizeLoaded(p)
- }
- // A character file added since the index was built: load every
- // file once and match by stable ID.
- if names, err := g.AccountStore.ListCharacterNames(); err == nil {
- for _, name := range names {
- if p, err := g.AccountStore.LoadCharacter(name); err == nil && p.ID == rec.PlayerID {
- playersByID[p.ID] = p
- playersByName[strings.ToLower(p.Name)] = p
- return finalizeLoaded(p)
- }
- }
- }
- // Identity not found (character deleted or unreadable): leave
- // the record open so a later attempt (or operator inspection)
- // can resolve it; do not silently drop or reassign money.
- fmt.Fprintf(os.Stderr, "economy recovery: load %s (id %q): character not found\n", rec.Player, rec.PlayerID)
- return nil
- }
- // Legacy record written before stable IDs existed: the (renameable)
- // name is the only identity available.
- if rec.Player != "" {
- if p, ok := playersByName[strings.ToLower(rec.Player)]; ok {
- return finalizeLoaded(p)
- }
- }
- fmt.Fprintf(os.Stderr, "economy recovery: load %s: character not found\n", rec.Player)
- return nil
- }
- // stakeEvidence reports whether any non-void payout record was
- // journaled for this stake after seq — evidence the round resolved and
- // its settlement was recorded. A settlement that lists explicit stake
- // IDs (a table hand) only proves THIS stake when it covers it; a
- // legacy (or slots) payout without a list proves the round resolved
- // (the historical, and slots-correct, reading: the stake was consumed
- // by playing the round).
- //
- // settlement separately reports whether a SETTLEMENT payout (not a
- // refund) was journaled later: a settlement — even a partial one like
- // a surrendered hand or the first hand of a multi-hand win — proves
- // the round resolved, so the stake must NOT be refunded and its chip
- // part must NOT return in kind on top of the settlement (the chips
- // were lost at settlement, by the rules of the game). A REFUND payout
- // instead marks a refund in progress: its credit part is re-applied by
- // phase 1 and the chip part (if the debit was durable) still comes
- // back in chips.
- stakeEvidence := func(rec *economyRecord) (evidence bool, settlement bool) {
- for _, fr := range folded {
- // Ordering is by the record's CREATION Seq, not by the line of
- // its latest write: a payout created before this stake is not
- // its settlement even when a later rewrite of that payout
- // (a settle marker, or a recovery checkpoint re-anchor) lands
- // after the stake's line. Line order would let an old payout
- // mask a later stake and silently erase its refund (see
- // stakeCovered, which orders the same way).
- if fr.rec.Kind != "payout" || fr.rec.Status == "void" || fr.rec.Seq <= rec.Seq ||
- !payoutIdentifies(fr.rec, *rec) {
- continue
- }
- if len(fr.rec.Covers) > 0 && !containsString(fr.rec.Covers, rec.ID) {
- continue
- }
- evidence = true
- if !fr.rec.Refund {
- settlement = true
- }
- }
- return evidence, settlement
- }
- compensated := 0
- // skipCompact is set when a recovery-time append that a later run depends
- // on could not be made durable; compaction must then be skipped so the
- // evidence it would erase is preserved.
- skipCompact := false
-
- // Phase 1 — payouts, in append order. Records with identical
- // checkpoints are decided as one group: k sibling records all journaled
- // against balance `before` were all written against the same in-memory
- // balance (a failed save reverts it), so either none became durable
- // (the file is still at `before`: all k are owed) or at most one did
- // (the file is at `after` and k == 1). Re-applying identical siblings
- // one at a time would let the second record misread the first
- // re-application as its own durability and silently drop the owed
- // payout — the group is settled together instead.
- type payoutSig struct {
- // player is the record's stable identity key (PlayerID when known,
- // else the name): grouping by display name alone would merge two
- // different characters that reused the same name.
- player string
- before, after int
- credit int
- }
- siblings := make(map[payoutSig][]*economyRecord)
- for i := range folded {
- rec := &folded[i].rec
- if rec.Kind == "payout" && rec.Status == "open" {
- sig := payoutSig{rec.key(), rec.CreditsBefore, rec.CreditsAfter, rec.Credit}
- siblings[sig] = append(siblings[sig], rec)
- }
- }
- settled := make(map[string]bool)
- // settledSiblings counts SETTLED payout records (from earlier recovery
- // runs; visible in the folded journal) with the same signature as rec.
- // Each one already explains one durable credit unit of the current
- // balance: a record moved the balance from `before` to `after` and was
- // settled, so any other open record with the SAME checkpoints is still
- // owed — it must never be settled as "already durable" because the
- // balance happens to sit at `after`.
- settledSiblings := func(sig payoutSig, selfID string) int {
- n := 0
- for j := range folded {
- other := &folded[j].rec
- if other.ID == selfID || other.Kind != "payout" || other.Status != "settled" {
- continue
- }
- if other.key() == sig.player && other.CreditsBefore == sig.before &&
- other.CreditsAfter == sig.after && other.Credit == sig.credit {
- n++
- }
- }
- return n
- }
- // reapplyGroup re-applies every identical open sibling: they were all
- // journaled against the same in-memory balance and are all owed. Only
- // the siblings whose re-application actually became durable are
- // settled; a save failure (e.g. the credit cap) leaves the unpaid
- // remainder open for operator inspection — a liability is never erased.
- reapplyGroup := func(group []*economyRecord, p *player.Player, credit int) {
- // A mixed tracked/untracked group can contain a record already settled
- // earlier in this pass; paying it again would create credits. Filter
- // to the still-unsettled members.
- active := make([]*economyRecord, 0, len(group))
- for _, rec := range group {
- if !settled[rec.ID] {
- active = append(active, rec)
- }
- }
- group = active
- if len(group) == 0 {
- return
- }
- paid := 0
- capBlocked := false
- for m := 0; m < len(group); m++ {
- if _, err := addCreditsChecked(p.Credits, credit); err != nil {
- capBlocked = true
- break
- }
- if err := g.payCreditsDurable(p, credit); err != nil {
- fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err)
- break
- }
- paid++
- compensated++
- }
- for idx, other := range group {
- if idx < paid {
- g.Economy.settle(other)
- settled[other.ID] = true
- continue
- }
- if capBlocked || paid > 0 {
- // Either the credit cap blocked re-application, or an
- // earlier sibling WAS paid before this record's save failed
- // transiently. In both cases the durable balance already
- // moved away from this record's original before-checkpoint,
- // so re-anchor the checkpoints to the current durable balance
- // — without this, the balance a PAID sibling moved reads as
- // this record's own durability and the liability is silently
- // settled instead of paid (and compaction then erases the
- // settled sibling evidence, so a later run sees len(group)==1
- // and settles the remainder as "already durable").
- reanchorOwedPayout(other, p.Credits, other.Credit)
- settled[other.ID] = true // handled for the rest of this run
- if err := g.Economy.appendRetry(*other, 3); err != nil {
- // The re-anchor is the evidence the next run needs to avoid
- // silently settling this liability. If it is not durable,
- // skip compaction so the settled sibling evidence survives.
- fmt.Fprintf(os.Stderr, "economy recovery: re-anchor append %s failed: %v — skipping compaction this run\n", other.ID, err)
- skipCompact = true
- }
- continue
- }
- // A TRANSIENT save failure blocked the FIRST re-application
- // (paid == 0) and the balance still sits at the original
- // before-checkpoint: keep the record open with its ORIGINAL
- // checkpoints so a later recovery reads it as owed and retries
- // cleanly. Re-anchoring here would make the record unreadable
- // against the unchanged durable balance (before-checkpoint
- // mismatch) and permanently ambiguous.
- settled[other.ID] = true // handled for the rest of this run
- }
- }
- for i := range folded {
- rec := &folded[i].rec
- if rec.Kind != "payout" || rec.Status != "open" || settled[rec.ID] {
- continue
- }
- p := loadPlayer(rec)
- if p == nil {
- continue
- }
- if rec.Tracked {
- // Tracked records carry durable evidence in the character file
- // itself: the same save that includes the amount in the balance
- // records the record ID in open_payouts. Membership decides.
- // Balance checkpoints are NEVER consulted for tracked records:
- // unrelated credit changes (shop sales, bank deposits, other
- // games) can coincidentally land on CreditsAfter, and settling
- // on that coincidence silently erases an owed payout.
- if openPayoutRecorded(p, rec.ID) {
- // The save landed; the amount is already in the balance.
- g.Economy.settle(rec)
- settled[rec.ID] = true
- continue
- }
- if err := g.applyOpenPayout(p, rec); err != nil {
- // Cap-blocked or transiently failed: the record stays open
- // (no evidence entry was added) and a later recovery
- // retries. A liability is never silently dropped.
- fmt.Fprintf(os.Stderr, "economy recovery: tracked payout %s for %s not re-applied (%v) — kept open for operator inspection\n",
- rec.ID, rec.Player, err)
- } else {
- if g.Economy.settleDurable(rec) {
- removeOpenPayout(p, rec.ID)
- }
- compensated++
- }
- settled[rec.ID] = true
- continue
- }
- sig := payoutSig{rec.key(), rec.CreditsBefore, rec.CreditsAfter, rec.Credit}
- group := siblings[sig]
- switch {
- case p.Credits == rec.CreditsBefore:
- // The payout (and every identical sibling) was lost in the
- // crash: the player is owed them all.
- reapplyGroup(group, p, rec.Credit)
- case p.Credits == rec.CreditsAfter && len(group) == 1 && settledSiblings(sig, rec.ID) == 0:
- // The only record with these checkpoints sits at its
- // post-payout balance: it is already durable, settle it.
- g.Economy.settle(rec)
- settled[rec.ID] = true
- case p.Credits == rec.CreditsAfter && settledSiblings(sig, rec.ID) > 0:
- // A SETTLED sibling with identical checkpoints explains the
- // balance: an earlier recovery run already re-applied (and
- // settled) it, or its original settlement moved the balance
- // here. This open record is still OWED — settling it as
- // "already durable" because the balance reads `after` would
- // silently drop the liability (see the
- // partially-paid-identical-siblings regression).
- reapplyGroup(group, p, rec.Credit)
- default:
- fmt.Fprintf(os.Stderr, "economy recovery: ambiguous payout %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n",
- rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter)
- }
- }
-
- // Phase 2 — outstanding wagers, newest first.
- var openWagers []int
- for i := range folded {
- if folded[i].rec.Kind == "wager" && folded[i].rec.Status == "open" {
- openWagers = append(openWagers, i)
- }
- }
- for k := len(openWagers) - 1; k >= 0; k-- {
- rec := &folded[openWagers[k]].rec
- p := loadPlayer(rec)
- if p == nil {
- continue
- }
- // File-side resolution evidence: the session resolved this stake
- // but its settled marker could not be journaled (e.g. a broken
- // economy path), so the character file carries the resolution ID.
- // The round resolved — the stake must not be refunded, and its chip
- // part must not return in kind on top of a settlement that already
- // returned it as credits (a refund, by contrast, already gave the
- // chips back in the file).
- if resolvedStakeRecorded(p, rec.ID) {
- g.Economy.settle(rec)
- compensated++
- continue
- }
- resolvedByPayout, resolvedBySettlement := stakeEvidence(rec)
- // Checkpoint comparisons use the DURABLE state, captured before any
- // in-memory chip restoration below.
- creditsNow, chipsNow := p.Credits, countChips(p)
- // Chips are recovered independently of the credit checkpoints. Any
- // state where the chip debit is durable but the chips have not come
- // back (post-debit chip balance) still owes the chips — unless a
- // SETTLEMENT payout was journaled later: a settlement proves the
- // round resolved, so the chips were lost at settlement by the rules
- // of the game, and returning them in kind on top would double-pay
- // the round (e.g. a surrendered hand paying half the stake must not
- // ALSO return the full chip stake). A REFUND payout instead marks a
- // refund in progress: its credit part was re-applied by phase 1 and
- // the chip part still returns in chips.
- chipsOwed := rec.Chips > 0 && chipsNow == rec.ChipsAfter &&
- !resolvedBySettlement
- if chipsOwed && !g.giveChips(p, rec.Chips) {
- // No inventory room for the chip return: keep the record open
- // for operator inspection rather than silently dropping the
- // chips; a later startup (or operator action) retries.
- fmt.Fprintf(os.Stderr, "economy recovery: wager %s for %s: no inventory room for %d chips — kept open for operator inspection\n",
- rec.ID, rec.Player, rec.Chips)
- continue
- }
- saveChipReturn := func() error {
- if !chipsOwed {
- return nil
- }
- if err := g.AccountStore.SaveCharacter(p); err != nil {
- p.RemoveItem("chips", rec.Chips)
- fmt.Fprintf(os.Stderr, "economy recovery: chip return save %s: %v\n", p.Name, err)
- return err
- }
- return nil
- }
- switch {
- case creditsNow == rec.CreditsBefore && chipsNow == rec.ChipsBefore:
- // The debit never became durable: void it.
- g.Economy.void(rec)
- case creditsNow == rec.CreditsAfter:
- if resolvedByPayout {
- // A later payout record proves the round resolved (the win,
- // push, or refund already returned the credit stake inside
- // that payout). Only the resolution marker was lost; the
- // chip stake may still be outstanding.
- if saveChipReturn() == nil {
- g.Economy.settle(rec)
- }
- continue
- }
- // The debit is durable but the round was lost in the crash:
- // refund the stake (approved refund-on-recovery policy). The
- // chips (if owed) are already restored in memory; one durable
- // save covers both the chip return and the credit refund. The
- // credit refund is checked: a record can never push the
- // balance past the economy cap.
- sum, capErr := addCreditsChecked(creditsNow, rec.Credit)
- if capErr != nil {
- if chipsOwed {
- p.RemoveItem("chips", rec.Chips)
- }
- fmt.Fprintf(os.Stderr, "economy recovery: refund %s for %s blocked (%v) — kept open for operator inspection\n",
- rec.ID, rec.Player, capErr)
- continue
- }
- p.Credits = sum
- if err := g.AccountStore.SaveCharacter(p); err != nil {
- // Revert the in-memory refund so later records for this
- // player still compare against the durable balance; the
- // record stays open and a later startup can retry.
- p.Credits = creditsNow
- if chipsOwed {
- p.RemoveItem("chips", rec.Chips)
- }
- fmt.Fprintf(os.Stderr, "economy recovery: refund save %s: %v\n", p.Name, err)
- continue
- }
- g.Economy.settle(rec)
- compensated++
- default:
- // Persist an owed chip return even when the credit side stays
- // ambiguous: the record remains open either way, but the chips
- // must not be silently lost. A failed save reverts them.
- if saveChipReturn() != nil {
- continue
- }
- if resolvedByPayout {
- g.Economy.settle(rec)
- continue
- }
- // The chips were returned in kind and persisted; clear the
- // record's chip obligation durably so a later session refund
- // (RefundOutstanding) cannot return the same chips again. The
- // record stays open for the credit side.
- if chipsOwed {
- rec.Chips = 0
- rec.ChipsBefore = chipsNow
- rec.ChipsAfter = chipsNow
- if err := g.Economy.appendRetry(*rec, 3); err != nil {
- fmt.Fprintf(os.Stderr, "economy recovery: chip-obligation clear %s failed: %v\n", rec.ID, err)
- skipCompact = true
- }
- }
- fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n",
- rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter)
- }
- }
- if skipCompact {
- fmt.Fprintln(os.Stderr, "economy recovery: compaction skipped to preserve payout evidence")
- } else {
- g.Economy.compact()
- }
- // Final evidence prune: the phases above settled or voided records, so
- // evidence IDs that no longer correspond to an open record are stale
- // (a settled stake's resolution evidence, an applied payout's entry).
- // Drop them now so the character files stay small and meaningful; a
- // failed save only defers the prune to the next startup (finalizeLoaded).
- stillOpen := make(map[string]bool)
- for i := range folded {
- rec := &folded[i].rec
- if rec.Kind == "wager" && rec.Status == "open" {
- stillOpen[rec.ID] = true
- }
- }
- pruneSeen := make(map[*player.Player]bool)
- prunePlayer := func(p *player.Player) {
- if p == nil || pruneSeen[p] {
- return
- }
- pruneSeen[p] = true
- if pruneOpenPayouts(g.Economy, p) || pruneResolvedStakes(stillOpen, p) {
- if err := g.AccountStore.SaveCharacter(p); err != nil {
- fmt.Fprintf(os.Stderr, "economy recovery: final evidence prune save %s: %v\n", p.Name, err)
- }
- }
- }
- for _, p := range playersByID {
- prunePlayer(p)
- }
- for _, p := range playersByName {
- prunePlayer(p)
- }
- // Rebuild the outstanding-stake index from whatever open wager records
- // remain (normally none; ambiguous ones stay refundable/resolvable). A
- // fold failure leaves the previous index intact and was already logged.
- if err := g.Economy.reloadStakes(); err != nil {
- fmt.Fprintf(os.Stderr, "economy recovery: stake index rebuild failed: %v\n", err)
- }
-}
diff --git a/internal/game/economy_journal_test_helpers_test.go b/internal/game/economy_journal_test_helpers_test.go
deleted file mode 100644
index e65c622..0000000
--- a/internal/game/economy_journal_test_helpers_test.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package game
-
-import "testing"
-
-// mustLoadOpen is the test-side loadOpen: a journal fold failure is a test
-// failure, not a nil slice to accidentally treat as "nothing open".
-func (j *economyJournal) mustLoadOpen(t *testing.T) []*economyRecord {
- t.Helper()
- open, err := j.loadOpen()
- if err != nil {
- t.Fatalf("loadOpen: %v", err)
- }
- return open
-}
diff --git a/internal/game/economy_open_payout_list_test.go b/internal/game/economy_open_payout_list_test.go
deleted file mode 100644
index a16f73b..0000000
--- a/internal/game/economy_open_payout_list_test.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package game
-
-// Regressions for the character-file payout-evidence scheme: an owed payout
-// must never be erased because an unrelated credit change coincidentally
-// lands on the record's post-payout balance, and a payout whose save became
-// durable must never be re-applied (double-paid) just because its settled
-// journal marker was lost.
-
-import (
- "os"
- "testing"
-
- "thehouseoficarus/internal/casino"
- "thehouseoficarus/internal/player"
-)
-
-// newEvidenceHarness builds a minimal Game with just the account store and
-// economy journal — enough for casinoWallet and recoverEconomy.
-func newEvidenceHarness(t *testing.T) (*Game, *player.Player) {
- t.Helper()
- dir := t.TempDir()
- if err := os.MkdirAll(dir+"/players/characters", 0o700); err != nil {
- t.Fatal(err)
- }
- g := &Game{
- Deps: Deps{AccountStore: player.NewAccountStore(dir)},
- Economy: newEconomyJournal(dir),
- }
- p := player.New("alice")
- p.ID = "stable-identity"
- p.Credits = 90
- if err := g.AccountStore.SaveCharacter(p); err != nil {
- t.Fatal(err)
- }
- return g, p
-}
-
-// TestRecoveryReappliesPayoutWhenUnrelatedCreditsMaskTheBalance: a payout
-// whose save failed leaves an OPEN record at balance 90 (want 110). A later,
-// unrelated 20-credit gain brings the durable balance to 110 — the record's
-// CreditsAfter. Recovery must read the character-file evidence (absent: the
-// save never landed) and re-apply the payout, not settle it as "already
-// durable" and silently erase the 20 owed credits.
-func TestRecoveryReappliesPayoutWhenUnrelatedCreditsMaskTheBalance(t *testing.T) {
- g, p := newEvidenceHarness(t)
-
- // Force the payout save to fail without touching its journal.
- path := g.AccountStore.CharPath(p.Name)
- backup := path + ".backup"
- if err := os.Rename(path, backup); err != nil {
- t.Fatal(err)
- }
- if err := os.Mkdir(path, 0o700); err != nil {
- t.Fatal(err)
- }
- w := casinoWallet{g: g, p: p}
- if err := w.PayCredits(20); err != casino.ErrWalletPersistence {
- t.Fatalf("PayCredits error = %v, want ErrWalletPersistence", err)
- }
- if err := os.Remove(path); err != nil {
- t.Fatal(err)
- }
- if err := os.Rename(backup, path); err != nil {
- t.Fatal(err)
- }
- if p.Credits != 90 {
- t.Fatalf("failed payout was paid: credits=%d, want 90", p.Credits)
- }
-
- // The unrelated credit change: a shop sale, a bank withdrawal, anything.
- p.Credits += 20
- if err := g.AccountStore.SaveCharacter(p); err != nil {
- t.Fatal(err)
- }
-
- g.recoverEconomy()
-
- loaded, err := g.AccountStore.LoadCharacter(p.Name)
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 130 {
- t.Fatalf("balance after recovery = %d, want 130 (owed payout re-applied once)", loaded.Credits)
- }
- if open := g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Fatalf("records left open after recovery: %+v", open)
- }
-}
-
-// TestRecoverySettlesDurablePayoutWithoutReapplying: the payout save landed
-// (the character file records the record ID in open_payouts) but the settled
-// journal marker was lost in the crash. Recovery must settle from the file
-// evidence — re-applying would double-pay the win.
-func TestRecoverySettlesDurablePayoutWithoutReapplying(t *testing.T) {
- g, p := newEvidenceHarness(t)
-
- rec, err := g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0)
- if err != nil {
- t.Fatal(err)
- }
- // The save landed: balance and evidence both durable.
- p.Credits = 110
- p.OpenPayouts = append(p.OpenPayouts, rec.ID)
- if err := g.AccountStore.SaveCharacter(p); err != nil {
- t.Fatal(err)
- }
-
- g.recoverEconomy()
-
- loaded, err := g.AccountStore.LoadCharacter(p.Name)
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 110 {
- t.Fatalf("balance after recovery = %d, want 110 (durable payout not re-applied)", loaded.Credits)
- }
- if open := g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Fatalf("records left open after recovery: %+v", open)
- }
-}
diff --git a/internal/game/economy_reanchor_test.go b/internal/game/economy_reanchor_test.go
deleted file mode 100644
index 86d7bf1..0000000
--- a/internal/game/economy_reanchor_test.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package game
-
-import (
- "testing"
-
- "thehouseoficarus/internal/casino"
-)
-
-// TestReanchorOwedPayoutReadsAsOwed: when recovery cannot pay every sibling in
-// an identical-signature payout group, the unpaid remainder must be re-anchored
-// to the current durable balance. Otherwise a later run (after compaction drops
-// the settled sibling evidence) reads the balance as the record's own
-// durability and settles the liability without paying it.
-func TestReanchorOwedPayoutReadsAsOwed(t *testing.T) {
- rec := &economyRecord{Credit: 20, CreditsBefore: 90, CreditsAfter: 110}
- // One sibling was paid: the durable balance now sits at 110.
- reanchorOwedPayout(rec, 110, rec.Credit)
- if rec.CreditsBefore != 110 || rec.CreditsAfter != 130 {
- t.Fatalf("re-anchored checkpoints = before %d after %d, want before 110 after 130", rec.CreditsBefore, rec.CreditsAfter)
- }
-
- // At the economy cap the post-checkpoint must stay reachable: before ==
- // after so the record keeps reading as owed instead of claiming an
- // impossible balance that a coincidental balance could be mistaken for.
- capped := &economyRecord{Credit: 20}
- reanchorOwedPayout(capped, casino.MaxCredits, capped.Credit)
- if capped.CreditsBefore != casino.MaxCredits || capped.CreditsAfter != casino.MaxCredits {
- t.Fatalf("cap re-anchor = before %d after %d, want both %d",
- capped.CreditsBefore, capped.CreditsAfter, casino.MaxCredits)
- }
-}
diff --git a/internal/game/economy_recovery_identity_test.go b/internal/game/economy_recovery_identity_test.go
deleted file mode 100644
index 04d6fed..0000000
--- a/internal/game/economy_recovery_identity_test.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package game
-
-// Identity-resolution regressions for economy recovery: a record carrying a
-// stable character ID must never be resolved — paid or resolved — through a
-// display name that a different character later reused, and money recovery
-// must run only AFTER lifecycle-journal rollback (a rolled-back rename
-// restores an older character before-image that must not erase compensation
-// already applied).
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "thehouseoficarus/internal/persistence"
-)
-
-// TestRecoveryDoesNotPayReusedCharacterName: an owed payout journaled for
-// identity A (named "alice") must not be paid to identity B after B reuses
-// the name, and A's unresolved liability must stay open for operator
-// inspection instead of being silently settled against B's balance.
-func TestRecoveryDoesNotPayReusedCharacterName(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.ID = "original-character"
- p.Credits = 90
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- // A failed payout save left the owed money journaled for recovery.
- if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil {
- t.Error(err)
- return
- }
- // Delete the original character, then reuse its name for a
- // different identity.
- p.ID = "replacement-character"
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- h.g.recoverEconomy()
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 90 {
- t.Errorf("replacement identity received someone else's payout: credits=%d, want 90", loaded.Credits)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 1 {
- t.Errorf("original identity's unresolved liability was discarded: open records=%d", len(open))
- }
-}
-
-// TestEconomyRecoveryRunsAfterLifecycleRollback: a leftover lifecycle
-// transaction (a rename that never reached its commit point) restores the
-// character's before-image at startup, and must do so BEFORE money
-// compensation — otherwise the compensation's settled/compacted records are
-// gone and the rollback silently erases a payout that was already applied.
-func TestEconomyRecoveryRunsAfterLifecycleRollback(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.ID = "stable-character"
- p.Credits = 90
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- // A failed payout save left the owed money journaled for recovery.
- if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil {
- t.Error(err)
- return
- }
- // Simulate a crash mid-rename: apply the move and the renamed
- // character file directly, then leave the lifecycle journal on
- // disk so the next startup rolls it back.
- oldPath := h.g.AccountStore.CharPath("alice")
- newPath := h.g.AccountStore.CharPath("renamed")
- txn := persistence.NewTransaction(h.g.journalDir())
- if err := txn.Move(oldPath, newPath); err != nil {
- t.Error(err)
- return
- }
- p.Name = "renamed"
- data, err := h.g.AccountStore.MarshalCharacter(p)
- if err != nil {
- t.Error(err)
- return
- }
- if err := txn.Replace(newPath, data, 0600); err != nil {
- t.Error(err)
- return
- }
- journal, err := txn.MarshalJournal()
- if err != nil {
- t.Error(err)
- return
- }
- if err := os.WriteFile(filepath.Join(h.g.journalDir(), "txn-audit.json"), journal, 0600); err != nil {
- t.Error(err)
- return
- }
- if err := os.Rename(oldPath, newPath); err != nil {
- t.Error(err)
- return
- }
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- })
- // A fresh Game over the saved files simulates the restart.
- restarted := harnessFromDir(t, h.dir)
- loaded, err := restarted.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 110 {
- t.Errorf("lifecycle rollback erased recovered payout: credits=%d, want 110; open liabilities=%d",
- loaded.Credits, len(restarted.g.Economy.mustLoadOpen(t)))
- }
-}
diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go
deleted file mode 100644
index 207e01d..0000000
--- a/internal/game/economy_recovery_regression_test.go
+++ /dev/null
@@ -1,546 +0,0 @@
-package game
-
-// Economy recovery regressions for the outstanding-stake ledger: a crash
-// with a round in flight refunds the stake, chip stakes recover in chips,
-// a failed payout is re-applied or preserved for operator inspection, and
-// a resolved round (payout evidence) is never refunded twice.
-
-import (
- "testing"
-
- "thehouseoficarus/internal/casino"
- "thehouseoficarus/internal/player"
-)
-
-// TestRecoveryRefundsInFlightMachineWager: a wager debited durably with the
-// round still in flight (no graceful shutdown, no resolution) is refunded by
-// startup recovery — the documented refund-on-crash policy.
-func TestRecoveryRefundsInFlightMachineWager(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- h.g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots}, "alice")
- h.g.Casino.SetMachineBet(1, "slots", "alice", 10, casinoWallet{g: h.g, p: p})
- })
- h.barrier()
-
- // A fresh Game over the saved files simulates losing all live round
- // state in a crash. The stake record is open, so recovery refunds it.
- restarted := harnessFromDir(t, h.dir)
- loaded, err := restarted.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 100 {
- t.Fatalf("crash with an active 10-credit spin: recovered %d credits, want 100", loaded.Credits)
- }
- if open := restarted.g.Economy.openStakes("alice"); len(open) != 0 {
- t.Fatalf("refunded stake left open: %+v", open)
- }
-}
-
-// TestRecoveryRefundsChipOnlyWager: a chip-only debit must be recognized by
-// comparing chip balances, not credits alone (the credit checkpoints are
-// identical before and after a chip-only wager).
-func TestRecoveryRefundsChipOnlyWager(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 20})
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- }
- // Simulate the crash window: the chip debit is durable in the
- // character file but the stake record never resolved.
- p.RemoveItem("chips", 10)
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- }
- if _, err := h.g.Economy.start("wager", p, 10, 0, 100, 100, 20, 10); err != nil {
- t.Error(err)
- }
- h.g.recoverEconomy()
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if got := countChips(loaded); got != 20 {
- t.Fatalf("chip stake after recovery = %d, want 20 (refunded in chips)", got)
- }
-}
-
-// TestRecoveryReappliesLostPayoutWithEvidence: a won hand whose payout
-// failed to save leaves an open payout record AND an open stake record.
-// Recovery re-applies the payout (which includes the stake return) and
-// settles the stake from that evidence — exactly once, no double refund.
-func TestRecoveryReappliesLostPayoutWithEvidence(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 90 // post-debit balance: the 10-credit stake was charged
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- }
- // Open stake for the lost round (before=100, after=90).
- if _, err := h.g.Economy.start("wager", p, 0, 10, 100, 90, 0, 0); err != nil {
- t.Error(err)
- }
- // Open payout for the won hand (2x bet = 20, before=90, after=110).
- if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil {
- t.Error(err)
- }
- h.g.recoverEconomy()
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 110 {
- t.Fatalf("balance after recovery = %d, want 110 (payout re-applied once, no double stake refund)", loaded.Credits)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Fatalf("records left open after recovery: %+v", open)
- }
-}
-
-// TestRecoveryRefundsSequentialStakes: a bet plus a double (two open stake
-// records, newest processed first) both refund after a crash.
-func TestRecoveryRefundsSequentialStakes(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 80 // bet 10 then doubled: 100 -> 90 -> 80
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- }
- if _, err := h.g.Economy.start("wager", p, 0, 10, 100, 90, 0, 0); err != nil {
- t.Error(err)
- }
- if _, err := h.g.Economy.start("wager", p, 0, 10, 90, 80, 0, 0); err != nil {
- t.Error(err)
- }
- h.g.recoverEconomy()
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 100 {
- t.Fatalf("balance after recovery = %d, want 100 (both stakes refunded)", loaded.Credits)
- }
-}
-
-// TestRecoveryReappliesIdenticalFailedPayouts: two won hands whose payout
-// saves both failed leave two OPEN payout records with identical
-// checkpoints (the in-memory balance reverts on save failure, so both were
-// journaled against balance 90). Recovery must re-apply BOTH — settling
-// the second as "already durable" because the first re-application moved
-// the balance to `after` would silently drop 20 owed credits.
-func TestRecoveryReappliesIdenticalFailedPayouts(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 90 // post-debit balance: both payouts failed to save
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- }
- for i := 0; i < 2; i++ {
- if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil {
- t.Error(err)
- }
- }
- h.g.recoverEconomy()
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 130 {
- t.Fatalf("balance after recovery = %d, want 130 (both identical failed payouts re-applied)", loaded.Credits)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Fatalf("records left open after recovery: %+v", open)
- }
-}
-
-// TestRefundOutstandingKeepsStakesOpenWithoutInventoryRoom: a chip refund
-// into a FULL inventory must be refused (stake records stay open) instead
-// of silently dropping the chips and marking the refund complete. Once the
-// player frees a slot, the same outstanding stake refunds successfully.
-func TestRefundOutstandingKeepsStakesOpenWithoutInventoryRoom(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10})
- w := casinoWallet{g: h.g, p: p}
- if _, err := w.Wager(10); err != nil {
- t.Error(err)
- return
- }
- // The debit emptied the chips slot; fill EVERY slot (including the
- // one the wager just freed) so the chip return has nowhere to go.
- for p.FirstFreeSlot() >= 0 {
- p.SetInvSlot(p.FirstFreeSlot(), &player.InventorySlot{ItemID: "filler", Quantity: 1})
- }
- chips, credits, err := w.RefundOutstanding()
- if err != casino.ErrWalletPersistence {
- t.Errorf("refund error = %v, want ErrWalletPersistence (chips=%d credits=%d)", err, chips, credits)
- }
- if got := countChips(p); got != 0 {
- t.Errorf("chips after refused refund = %d, want 0 (nothing delivered; tracked in the open stake)", got)
- }
- if open := h.g.Economy.openStakes(p.Name); len(open) != 1 {
- t.Errorf("stake records after refused refund = %d, want 1 (kept open for a later attempt)", len(open))
- }
-
- // Free a slot: the SAME outstanding stake now refunds in full.
- p.RemoveItem("filler", 1)
- chips, credits, err = w.RefundOutstanding()
- if err != nil || chips != 10 || credits != 0 {
- t.Errorf("retry refund = (%d chips, %d credits, %v), want (10, 0, nil)", chips, credits, err)
- }
- if got := countChips(p); got != 10 {
- t.Errorf("chips after retried refund = %d, want 10", got)
- }
- if open := h.g.Economy.openStakes(p.Name); len(open) != 0 {
- t.Errorf("stake records after successful refund = %d, want 0", len(open))
- }
- })
-}
-
-// TestRecoveryKeepsWagerOpenWhenChipsDoNotFit: recovery must not settle a
-// chip stake as refunded when the character's inventory cannot hold the
-// chips — the record stays open for operator inspection.
-func TestRecoveryKeepsWagerOpenWhenChipsDoNotFit(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- // Durable state at crash time: the 10-chip debit landed (10 -> 0
- // chips on disk), credits untouched, and the inventory is FULL of
- // non-chip filler, so recovery has nowhere to put the refund.
- p.Credits = 100
- for i := 0; i < 28; i++ {
- p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
- }
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- }
- if _, err := h.g.Economy.start("wager", p, 10, 0, 100, 100, 10, 0); err != nil {
- t.Error(err)
- }
- h.g.recoverEconomy()
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if got := countChips(loaded); got != 0 {
- t.Fatalf("chips after recovery = %d, want 0 (no inventory room; nothing delivered)", got)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 1 {
- t.Fatalf("chip wager must stay open for operator inspection, open records: %+v", open)
- }
-}
-
-// TestRecoveryPreservesAmbiguousPayout: a payout whose checkpoints match
-// neither balance state stays open and is surfaced for operator inspection —
-// a liability is never silently dropped.
-func TestRecoveryPreservesAmbiguousPayout(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- // A payout owed at balance 100 failed to save; the player then
- // spent 5 credits elsewhere before the crash.
- p.Credits = 95
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- }
- rec, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0)
- if err != nil {
- t.Error(err)
- return
- }
- // A LEGACY record (no character-file evidence, as written before the
- // open_payouts scheme existed): checkpoint matching is the only
- // evidence, balance 95 matches neither checkpoint, and the record
- // stays open for operator inspection. Tracked records decide by
- // file evidence instead — see economy_open_payout_list_test.go.
- rec.Tracked = false
- h.g.Economy.append(*rec)
- h.g.recoverEconomy()
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 95 {
- t.Fatalf("ambiguous recovery mutated the balance: %d, want 95", loaded.Credits)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 1 {
- t.Fatalf("ambiguous payout must stay open for operator inspection: %+v", open)
- }
-}
-
-// TestRecoveryKeepsUnpaidSiblingsOpenAtCreditCap: two identical open payout
-// records where re-applying the second would exceed the economy cap must
-// settle only the sibling that was actually paid. The unpaid 20-credit
-// liability is never erased: its record stays open for operator inspection.
-func TestRecoveryKeepsUnpaidSiblingsOpenAtCreditCap(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = casino.MaxCredits - 30
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- for i := 0; i < 2; i++ {
- if _, err := h.g.Economy.start("payout", p, 0, 20, p.Credits, p.Credits+20, 0, 0); err != nil {
- t.Error(err)
- return
- }
- }
- h.g.recoverEconomy()
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != casino.MaxCredits-10 {
- t.Fatalf("balance after capped recovery = %d, want %d (one payout applied)", loaded.Credits, casino.MaxCredits-10)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 1 {
- t.Fatalf("unpaid liability must stay open for operator inspection, open records: %+v", open)
- }
-}
-
-// TestRecoveryRestoresChipsFromMixedRefundCrash: a mixed chips+credits
-// refund whose credit side is resolved by a later payout record but whose
-// chip side was never saved must still return the chips. The wager record
-// may be settled only once nothing is owed.
-func TestRecoveryRestoresChipsFromMixedRefundCrash(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10})
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- // Durable 20-credit (10 chips + 10 credits) wager: after the debit
- // the file holds 90 credits and no chips.
- w := casinoWallet{g: h.g, p: p}
- if _, err := w.Wager(20); err != nil {
- t.Error(err)
- return
- }
- // Crash during RefundOutstanding, after the credit-side REFUND
- // payout was journaled but before anything beyond the debit was
- // durable. The record is refund-flagged: recovery re-applies the
- // refund credit AND returns the chip part of the stake in chips.
- if _, err := h.g.Economy.startRefund(p, 10, 90, 100); err != nil {
- t.Error(err)
- return
- }
- h.g.recoverEconomy()
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 100 || countChips(loaded) != 10 {
- t.Fatalf("mixed refund after recovery = (%d credits, %d chips), want (100, 10); open records: %+v",
- loaded.Credits, countChips(loaded), h.g.Economy.mustLoadOpen(t))
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Fatalf("nothing may stay owed after recovery: %+v", open)
- }
-}
-
-// TestRecoveryDoesNotReturnPaidChipStakeInKind: a chip wager whose round
-// resolved with a durable payout (a win returns the stake inside the
-// payout) must not ALSO get the chips back in kind on recovery — the
-// resolving payout is the refund. Only a payout that does not cover the
-// stake's full value (e.g. the credit side of a crashed mixed refund)
-// leaves the chip side outstanding.
-func TestRecoveryDoesNotReturnPaidChipStakeInKind(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10})
- w := casinoWallet{g: h.g, p: p}
- if _, err := w.Wager(10); err != nil {
- t.Error(err)
- return
- }
- // The round resolved: a 2x payout (20) is durable, then the crash
- // lands before the stake's resolution marker.
- if err := w.PayCredits(20); err != nil {
- t.Error(err)
- return
- }
- h.g.recoverEconomy()
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 120 || countChips(loaded) != 0 {
- t.Fatalf("won chip bet after recovery = (%d credits, %d chips), want (120, 0) — no double refund",
- loaded.Credits, countChips(loaded))
- }
-}
-
-// TestRecoveryUnpaidLiabilitySurvivesLaterRecoveries: an unpaid payout
-// liability (the credit cap blocked re-application) must survive repeated
-// recoveries. Without checkpoint re-anchoring, a later recovery mistakes
-// the balance a paid sibling moved for the unpaid record's own durability
-// and silently erases the owed amount.
-func TestRecoveryUnpaidLiabilitySurvivesLaterRecoveries(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = casino.MaxCredits - 30
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- for i := 0; i < 2; i++ {
- if _, err := h.g.Economy.start("payout", p, 0, 20, p.Credits, p.Credits+20, 0, 0); err != nil {
- t.Error(err)
- return
- }
- }
- h.g.recoverEconomy()
- if len(h.g.Economy.mustLoadOpen(t)) != 1 {
- t.Errorf("setup: expected one unpaid record after capped recovery, got %d", len(h.g.Economy.mustLoadOpen(t)))
- return
- }
- // A later startup over the same files: the liability stays owed.
- h.g.recoverEconomy()
- if got := len(h.g.Economy.mustLoadOpen(t)); got != 1 {
- t.Errorf("second recovery erased the unpaid 20-credit liability: open=%d, want 1", got)
- }
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != casino.MaxCredits-10 {
- t.Fatalf("balance after repeated recovery = %d, want %d (one payout applied)", loaded.Credits, casino.MaxCredits-10)
- }
-}
-
-// TestCapBlockedPayoutIsJournaled: a won payout at the economy cap must be
-// recorded as an owed (open) payout record even though it cannot be
-// applied — a reported win is never silently dropped.
-func TestCapBlockedPayoutIsJournaled(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = casino.MaxCredits
- w := casinoWallet{g: h.g, p: p}
- if _, err := w.Wager(10); err != nil {
- t.Error(err)
- return
- }
- if err := w.PayCredits(20); err == nil {
- t.Error("payout at the credit cap must fail, not silently clamp")
- return
- }
- if p.Credits != casino.MaxCredits-10 {
- t.Errorf("cap-blocked payout mutated the balance: %d", p.Credits)
- }
- })
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Fatal(err)
- }
- restarted := harnessFromDir(t, h.dir)
- loaded, err := restarted.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- // Recovery: the owed win cannot be applied (cap), so the liability
- // stays open for operator inspection, never erased; the consumed
- // stake is settled against the payout evidence.
- if got := len(restarted.g.Economy.mustLoadOpen(t)); got != 1 {
- t.Fatalf("owed cap-blocked payout must stay open after recovery, open records: %+v", restarted.g.Economy.mustLoadOpen(t))
- }
- if loaded.Credits != casino.MaxCredits-10 {
- t.Fatalf("recovery mutated a capped balance: %d", loaded.Credits)
- }
-}
-
-// TestRecoveryReappliesPartiallyPaidIdenticalPayouts: two identical payout
-// records were open and an earlier recovery run re-applied (and settled)
-// only the FIRST before crashing again — the second save failed transiently
-// (see TestRecoverySaveFailureRetriesOnNextStartup for the single-record
-// case). The next recovery must NOT settle the remaining record as
-// "already durable" merely because the balance now sits at its
-// post-payout checkpoint: the settled sibling explains that balance, and
-// the open record is still owed.
-func TestRecoveryReappliesPartiallyPaidIdenticalPayouts(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 90
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- // Two identical owed payouts were journaled against balance 90.
- if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil {
- t.Error(err)
- return
- }
- second, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0)
- if err != nil {
- t.Error(err)
- return
- }
- // Simulate the prior recovery run: the first record was re-applied
- // and settled durably; the second's save failed transiently.
- if err := h.g.payCreditsDurable(p, 20); err != nil {
- t.Error(err)
- return
- }
- first := h.g.Economy.mustLoadOpen(t)[0]
- if first.ID == second.ID {
- t.Fatal("setup: expected two distinct records")
- }
- h.g.Economy.settle(first)
- // Recovery over this state: the remaining owed payout must be
- // re-applied, never settled as "already durable".
- h.g.recoverEconomy()
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- if loaded.Credits != 130 {
- t.Errorf("recovered balance = %d, want 130 (remaining owed payout re-applied)", loaded.Credits)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Errorf("records left open after recovery: %+v", open)
- }
- })
-}
diff --git a/internal/game/economy_rollback_regression_test.go b/internal/game/economy_rollback_regression_test.go
deleted file mode 100644
index bceb351..0000000
--- a/internal/game/economy_rollback_regression_test.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package game
-
-// Rollback-failure regressions for the durable money journal. When a journal
-// append fails, the invariant was historically "append failed means no record
-// bytes remain". A failed rollback (the truncate, or the sync that makes it
-// durable) breaks that invariant: the record can survive while the caller
-// believes the append failed. The wallet's journal-unavailable payout fallback
-// must then still record the attempted record's ID as open-payout evidence, or
-// recovery re-applies the surviving payout and pays the player twice.
-
-import (
- "encoding/json"
- "errors"
- "os"
- "path/filepath"
- "testing"
-)
-
-// TestAppendRollbackFailureIsUncertain: /dev/full accepts the open but fails
-// the write with ENOSPC and the rollback truncate with EINVAL, so the append
-// must report that the record MAY have survived.
-func TestAppendRollbackFailureIsUncertain(t *testing.T) {
- if _, err := os.Stat("/dev/full"); err != nil {
- t.Skipf("/dev/full is not available: %v", err)
- }
- j := newEconomyJournal(t.TempDir())
- j.path = "/dev/full"
-
- err := j.append(economyRecord{ID: "uncertain", Kind: "payout", Status: "open"})
- if !errors.Is(err, errJournalAppendUncertain) {
- t.Fatalf("append rollback failure = %v, want errJournalAppendUncertain", err)
- }
-}
-
-// TestFallbackPayoutRecordsEvidenceForSurvivingRecord: a payout whose journal
-// append fails and whose rollback cannot be proven must leave open-payout
-// evidence in the character file. A surviving record is then settled from that
-// evidence at the next startup instead of being re-applied.
-func TestFallbackPayoutRecordsEvidenceForSurvivingRecord(t *testing.T) {
- g, p := newEvidenceHarness(t)
-
- // Break the journal append: point it at its own directory, which cannot
- // be opened for append or truncation. The wallet takes the file-only
- // fallback.
- journalPath := g.Economy.path
- g.Economy.path = filepath.Dir(journalPath)
- w := casinoWallet{g: g, p: p}
- if err := w.PayCredits(20); err != nil {
- t.Fatalf("fallback payout failed: %v", err)
- }
- if p.Credits != 110 {
- t.Fatalf("fallback payout not applied: credits=%d, want 110", p.Credits)
- }
- if len(p.OpenPayouts) != 1 {
- t.Fatalf("fallback payout recorded %d open-payout evidence IDs, want 1: %v",
- len(p.OpenPayouts), p.OpenPayouts)
- }
- id := p.OpenPayouts[0]
-
- // Simulate the failed append having survived in the journal (the exact
- // state a failed rollback produces). Recovery must use the file evidence
- // and settle it, not re-apply it.
- g.Economy.path = journalPath
- rec := economyRecord{
- ID: id, Player: p.Name, PlayerID: p.ID, Kind: "payout", Tracked: true,
- Credit: 20, Status: "open", CreditsBefore: 90, CreditsAfter: 110,
- }
- line, err := json.Marshal(rec)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(journalPath, append(line, '\n'), 0o600); err != nil {
- t.Fatal(err)
- }
-
- g.recoverEconomy()
-
- loaded, err := g.AccountStore.LoadCharacter(p.Name)
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 110 {
- t.Fatalf("balance after recovery = %d, want 110 (surviving payout settled from evidence, not re-applied)",
- loaded.Credits)
- }
- if open := g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Fatalf("records left open after recovery: %+v", open)
- }
-}
diff --git a/internal/game/economy_seq_test.go b/internal/game/economy_seq_test.go
deleted file mode 100644
index 2c5bbdb..0000000
--- a/internal/game/economy_seq_test.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package game
-
-import "testing"
-
-// TestStakeCoveredRespectsCreationOrder: an open payout created BEFORE a stake
-// must not be treated as that stake's settlement, or RefundOutstanding would
-// resolve the later stake without returning its money.
-func TestStakeCoveredRespectsCreationOrder(t *testing.T) {
- j := newEconomyJournal(t.TempDir())
- stake := &economyRecord{ID: "stake", Player: "alice", Kind: "wager", Status: "open", Seq: 2}
- j.registerStake(stake)
-
- older := &economyRecord{ID: "older", Player: "alice", Kind: "payout", Status: "open", Seq: 1, Tracked: true}
- j.openPayoutRecs[stake.key()] = []*economyRecord{older}
- if j.stakeCovered(stake) {
- t.Fatal("a payout created before the stake must not cover it")
- }
-
- newer := &economyRecord{ID: "newer", Player: "alice", Kind: "payout", Status: "open", Seq: 3, Tracked: true}
- j.openPayoutRecs[stake.key()] = []*economyRecord{newer}
- if !j.stakeCovered(stake) {
- t.Fatal("a payout created after the stake should cover it")
- }
-}
diff --git a/internal/game/game.go b/internal/game/game.go
index c0c6488..5aad784 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -107,9 +107,6 @@ type Game struct {
shutdownSaveMu sync.Mutex
shutdownSaveErr error
- // Economy is the durable money journal; see economy.go.
- Economy *economyJournal
-
// loginThrottle applies an escalating per-(account, IP) delay to failed
// interactive password attempts. Lazily created so Game literals built
// directly in tests work without it.
@@ -166,19 +163,16 @@ func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig *
// which statically references executeShutdown, cannot form an
// initialization cycle with the shutdown coordinator.
g.shutdownHook = g.RequestShutdown
- g.Economy = newEconomyJournal(dataDir)
- // Lifecycle-journal rollback MUST run before economy recovery: a rolled-
- // back rename transaction restores an older character before-image, and
- // if money compensation ran first its settled/compacted records would no
- // longer be recoverable — the rollback would silently erase a payout
- // that was already re-applied. Rollback also precedes every store read
- // below (courses, mods, techs, craft index) that a leftover transaction
- // may reference. See the TestEconomyRecoveryRunsAfterLifecycleRollback
- // regression.
+ // Lifecycle-journal rollback MUST run before pending-wager recovery: a
+ // rolled-back rename transaction restores an older character
+ // before-image, and a refund applied to the post-rename file would be
+ // lost when the rollback replaced it with the before-image. Rollback
+ // also precedes every store read below (courses, mods, techs, craft
+ // index) that a leftover transaction may reference.
g.recoverJournals()
- // Compensate open money records from any prior crash before any
- // session can connect.
- g.recoverEconomy()
+ // Refund any stake left in flight by an ungraceful stop before any
+ // session can connect. The character file is the only money record.
+ g.recoverPendingWagers()
g.CourseStore.SetWorld(g.World)
g.CourseStore.LoadAll()
g.LoadMods()
diff --git a/internal/game/hardening_economy_durability_test.go b/internal/game/hardening_economy_durability_test.go
deleted file mode 100644
index 55fff50..0000000
--- a/internal/game/hardening_economy_durability_test.go
+++ /dev/null
@@ -1,272 +0,0 @@
-package game
-
-// Persistence/economy durability regressions: the economy journal's directory
-// entry must be synced, recovery must order payout evidence by the record's
-// creation order (not by its last-written line), the recovery checkpoint
-// re-anchor must respect the economy cap, the journal-unavailable settlement
-// fallback must only resolve the stakes it settles, and a bank credit move
-// whose save failed must be refused instead of leaving memory ahead of the
-// durable file.
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "thehouseoficarus/internal/casino"
- "thehouseoficarus/internal/player"
-
- "gopkg.in/yaml.v3"
-)
-
-// waitForConnText blocks until the session's writer flushes text containing
-// substr; the writer goroutine is asynchronous.
-func waitForConnText(t *testing.T, conn *chanConn, substr string) {
- t.Helper()
- deadline := time.Now().Add(2 * time.Second)
- for !conn.Contains(substr) && time.Now().Before(deadline) {
- time.Sleep(time.Millisecond)
- }
- if !conn.Contains(substr) {
- t.Errorf("expected %q in session output; got=%s", substr, conn.Bytes())
- }
-}
-
-// TestRecoveryIgnoresRewrittenOlderPayoutAsStakeEvidence: payout evidence for
-// a stake is ordered by the payout's CREATION sequence, not by the line of its
-// latest write. A payout created before the stake that is rewritten afterwards
-// (a settle marker, or a recovery checkpoint re-anchor) must not be read as
-// the stake's settlement — otherwise recovery settles the stake without
-// refunding it and the wager is silently lost.
-func TestRecoveryIgnoresRewrittenOlderPayoutAsStakeEvidence(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 1000
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- // An older payout record (created first) ...
- old, err := h.g.Economy.start("payout", p, 0, 20, 1000, 1020, 0, 0)
- if err != nil {
- t.Error(err)
- return
- }
- // ... then the stake ...
- if _, err := h.g.Economy.start("wager", p, 0, 50, 1000, 950, 0, 0); err != nil {
- t.Error(err)
- return
- }
- // ... and finally a later write of the OLD payout lands after the
- // stake's line, so line order alone would call it the stake's
- // settlement.
- if !h.g.Economy.settleDurable(old) {
- t.Error("the older payout's settle marker could not be journaled")
- return
- }
- // The stake's debit is durable; the crashed round is recoverable.
- p.Credits = 950
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- h.g.recoverEconomy()
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 1000 {
- t.Errorf("balance after recovery = %d, want 1000: a payout created before the stake must not settle it (the stake is refunded, not silently kept)",
- loaded.Credits)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Errorf("records left open after recovery: %+v", open)
- }
-}
-
-// TestRecoveryReanchorKeepsCheckpointsWithinCreditCap: when the economy cap
-// blocks a payout's re-application, the re-anchored checkpoints must stay
-// within the cap. An impossible post-state above the cap can never be read as
-// durability, and the record is owed at the current balance: it must keep
-// reading that way across repeated recoveries instead of being settled as
-// "already durable". Legacy records without the tracked-evidence flag take
-// this checkpoint-compensating path.
-func TestRecoveryReanchorKeepsCheckpointsWithinCreditCap(t *testing.T) {
- dir := t.TempDir()
- seedWorld(t, dir)
-
- hash, _ := player.HashPassword("pw")
- accData, _ := yaml.Marshal(&player.Account{Name: "alice", PasswordHash: hash})
- writeSeedFile(t, filepath.Join(dir, "players", "accounts", "alice.yaml"), accData)
- p := player.New("alice")
- p.Credits = casino.MaxCredits - 30
- p.Account = "alice"
- pData, _ := yaml.Marshal(p)
- writeSeedFile(t, filepath.Join(dir, "players", "characters", "alice.yaml"), pData)
-
- // Two identical open payouts of 20 at the cap: the first can be
- // re-applied, the second cannot. Each record is its own journal line.
- var journal []byte
- for i := 0; i < 2; i++ {
- line, err := json.Marshal(economyRecord{
- ID: fmt.Sprintf("unpaid-%d", i), Player: "alice", Kind: "payout",
- Credit: 20, Status: "open",
- CreditsBefore: casino.MaxCredits - 30, CreditsAfter: casino.MaxCredits - 10,
- })
- if err != nil {
- t.Fatal(err)
- }
- journal = append(journal, line...)
- journal = append(journal, '\n')
- }
- journalDir := filepath.Join(dir, "economy")
- if err := os.MkdirAll(journalDir, 0700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(journalDir, "journal.jsonl"), journal, 0600); err != nil {
- t.Fatal(err)
- }
-
- // The first recovery runs in New.
- h := harnessFromDir(t, dir)
- for round := 1; round <= 3; round++ {
- if round > 1 {
- h.g.recoverEconomy()
- }
- open := h.g.Economy.mustLoadOpen(t)
- if len(open) != 1 {
- t.Fatalf("recovery %d: the unpaid liability must stay open exactly once, open=%d: %+v", round, len(open), open)
- }
- for _, rec := range open {
- if rec.CreditsBefore > casino.MaxCredits || rec.CreditsAfter > casino.MaxCredits {
- t.Fatalf("recovery %d re-anchored %s outside the economy cap: before=%d after=%d cap=%d",
- round, rec.ID, rec.CreditsBefore, rec.CreditsAfter, casino.MaxCredits)
- }
- }
- }
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != casino.MaxCredits-10 {
- t.Fatalf("balance after repeated recovery = %d, want %d (one 20-credit payout applied, the other still owed)",
- loaded.Credits, casino.MaxCredits-10)
- }
-}
-
-// TestJournalUnavailableSettlementSettlesOnlyItsOwnStakes: when the economy
-// journal is unavailable, the character-file fallback records resolution
-// evidence only for the stakes the payout settles. A sibling hand's stake is
-// settled by its own payout, so a crash before that payout must still refund
-// it.
-func TestJournalUnavailableSettlementSettlesOnlyItsOwnStakes(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- w := casinoWallet{g: h.g, p: p}
- // Two sibling stakes (a split, or two spots). The sibling is placed
- // first so its post-debit balance is the durable one.
- if _, err := w.Wager(10); err != nil {
- t.Error(err)
- return
- }
- settled, err := w.Wager(10)
- if err != nil {
- t.Error(err)
- return
- }
- // The journal breaks for the second hand's push: the payout lands in
- // the character file only, and may record evidence only for the
- // stake it settles.
- failJournalDuring(h, func() {
- if err := w.PayStakeCover(10, []string{settled.ID}); err != nil {
- t.Errorf("fallback settlement failed: %v", err)
- return
- }
- })
- if p.Credits != 90 {
- t.Errorf("after the fallback settlement: credits=%d, want 90", p.Credits)
- return
- }
- h.g.recoverEconomy()
- })
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 100 {
- t.Errorf("balance after recovery = %d, want 100: the sibling hand's stake must be refunded, not resolved by the other hand's settlement",
- loaded.Credits)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Errorf("records left open after recovery: %+v", open)
- }
-}
-
-// TestBankCreditTransferRefusedWhenSaveFails: a credit move between the wallet
-// and the bank that cannot be made durable is reverted and refused. Leaving it
-// in memory only would put the balance ahead of the character file, and a
-// wager taken from that phantom balance journals checkpoints recovery can
-// never match.
-func TestBankCreditTransferRefusedWhenSaveFails(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- p.Credits = 100
- p.BankedCredits = 50
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Fatal(err)
- }
- sess, conn := h.connect("alice")
-
- h.g.RunOnOwner(func() {
- failingSaveDuring(h.g, h.dir, func() {
- h.g.doBankDepositCredits(sess, []string{"deposit", "credits", "40"})
- })
- })
- if sess.Player.Credits != 100 || sess.Player.BankedCredits != 50 {
- t.Errorf("a deposit whose save failed must be reverted in memory: credits=%d banked=%d, want 100/50",
- sess.Player.Credits, sess.Player.BankedCredits)
- }
- waitForConnText(t, conn, "nothing was moved")
- if loaded, err := h.g.AccountStore.LoadCharacter("alice"); err != nil {
- t.Error(err)
- } else if loaded.Credits != 100 || loaded.BankedCredits != 50 {
- t.Errorf("durable balances after a refused deposit: credits=%d banked=%d, want 100/50",
- loaded.Credits, loaded.BankedCredits)
- }
-
- h.g.RunOnOwner(func() {
- failingSaveDuring(h.g, h.dir, func() {
- h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"})
- })
- })
- if sess.Player.Credits != 100 || sess.Player.BankedCredits != 50 {
- t.Errorf("a withdrawal whose save failed must be reverted in memory: credits=%d banked=%d, want 100/50",
- sess.Player.Credits, sess.Player.BankedCredits)
- }
- waitForConnText(t, conn, "nothing was moved")
-
- // A durable move still works.
- h.g.RunOnOwner(func() {
- h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"})
- })
- if sess.Player.Credits != 110 || sess.Player.BankedCredits != 40 {
- t.Errorf("successful withdrawal: credits=%d banked=%d, want 110/40",
- sess.Player.Credits, sess.Player.BankedCredits)
- }
- if loaded, err := h.g.AccountStore.LoadCharacter("alice"); err != nil {
- t.Error(err)
- } else if loaded.Credits != 110 || loaded.BankedCredits != 40 {
- t.Errorf("durable balances after a successful withdrawal: credits=%d banked=%d, want 110/40",
- loaded.Credits, loaded.BankedCredits)
- }
-}
diff --git a/internal/game/hardening_fixes_batch2_test.go b/internal/game/hardening_fixes_batch2_test.go
index e81dd9c..cdebc4c 100644
--- a/internal/game/hardening_fixes_batch2_test.go
+++ b/internal/game/hardening_fixes_batch2_test.go
@@ -1,11 +1,9 @@
package game
// Regression tests for the second hardening pass: transactional trigger
-// effects, ammo preservation on death, safespot clearing on teleport, and
-// economy-journal torn-tail repair.
+// effects, ammo preservation on death, and safespot clearing on teleport.
import (
- "os"
"path/filepath"
"testing"
@@ -165,39 +163,3 @@ func TestTeleportPlayerClearsSafespot(t *testing.T) {
t.Fatalf("room after teleport = %d, want 2", p.RoomID)
}
}
-
-// TestEconomyJournalRepairsTornTail: a torn final line must be truncated
-// before the next append, so the journal does not become permanently
-// unreadable (which would disable recovery and compaction).
-func TestEconomyJournalRepairsTornTail(t *testing.T) {
- dir := t.TempDir()
- j := newEconomyJournal(dir)
- if err := os.MkdirAll(filepath.Join(dir, "economy"), 0700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(j.path, []byte(`{"id":"torn","kind":"payout","status":"open"`), 0600); err != nil {
- t.Fatal(err)
- }
-
- if err := j.append(economyRecord{ID: "good", Player: "alice", Kind: "payout", Status: "open", Credit: 5}); err != nil {
- t.Fatalf("append after torn tail: %v", err)
- }
- open, err := j.loadOpen()
- if err != nil {
- t.Fatalf("loadOpen after torn-tail repair: %v", err)
- }
- for _, rec := range open {
- if rec.ID == "torn" {
- t.Fatal("torn record survived repair")
- }
- }
- found := false
- for _, rec := range open {
- if rec.ID == "good" {
- found = true
- }
- }
- if !found {
- t.Fatal("valid record missing after torn-tail repair")
- }
-}
diff --git a/internal/game/journal_damage_regression_test.go b/internal/game/journal_damage_regression_test.go
deleted file mode 100644
index d8577fe..0000000
--- a/internal/game/journal_damage_regression_test.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package game
-
-// Journal-damage regressions: a malformed record followed by other records
-// must abort the fold (the journal is preserved for inspection — records
-// beyond the damage may be owed money), an open failure is never "an empty
-// healthy journal", and compaction can never run over an untrusted fold.
-
-import (
- "os"
- "testing"
-)
-
-// TestMalformedJournalRecordAbortsFold: a malformed line followed by a valid
-// record makes loadFolded fail — skipping the line and continuing would let
-// compensation and compaction run over records that may owe money.
-func TestMalformedJournalRecordAbortsFold(t *testing.T) {
- j := newEconomyJournal(t.TempDir())
- original := []byte("{\"id\":\"damaged\",\"kind\":\"payout\"\n{\"id\":\"valid\",\"kind\":\"payout\",\"status\":\"open\",\"credit\":20}\n")
- if err := os.WriteFile(j.path, original, 0600); err != nil {
- t.Fatal(err)
- }
- if _, err := j.loadFolded(); err == nil {
- t.Fatal("malformed mid-file record silently accepted")
- }
- // Compaction must abort and preserve the journal verbatim.
- j.compact()
- after, err := os.ReadFile(j.path)
- if err != nil {
- t.Fatal(err)
- }
- if string(after) != string(original) {
- t.Errorf("compaction rewrote a damaged journal: %s", after)
- }
-}
-
-// TestTornFinalJournalLineIsTolerated: a torn LAST line (crash mid-append)
-// is skipped — every valid record before it still folds.
-func TestTornFinalJournalLineIsTolerated(t *testing.T) {
- j := newEconomyJournal(t.TempDir())
- data := []byte("{\"id\":\"a\",\"kind\":\"wager\",\"status\":\"open\"}\n{\"id\":\"b\",\"kind\":\"payo")
- if err := os.WriteFile(j.path, data, 0600); err != nil {
- t.Fatal(err)
- }
- folded, err := j.loadFolded()
- if err != nil {
- t.Fatalf("torn final line rejected a healthy fold: %v", err)
- }
- if len(folded) != 1 || folded[0].rec.ID != "a" {
- t.Fatalf("unexpected fold: %+v", folded)
- }
-}
-
-// TestJournalOpenErrorIsNotAnEmptyFold: an open failure (permissions, a
-// symlink loop) must be an error, never "no journal" — recovery would
-// otherwise treat owed money as never-charged.
-func TestJournalOpenErrorIsNotAnEmptyFold(t *testing.T) {
- j := newEconomyJournal(t.TempDir())
- if err := os.Symlink(j.path, j.path); err != nil {
- t.Fatal(err)
- }
- if _, err := j.loadFolded(); err == nil {
- t.Error("ELOOP open failure reported as an empty healthy journal")
- }
-}
-
-// TestOpenFailureAbortsRecovery: recoverEconomy over a journal that cannot
-// be fully read (malformed record with later records present) must abort
-// without paying owed money or compacting.
-func TestOpenFailureAbortsRecovery(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- // Corrupt the journal mid-file (a damaged record followed by an
- // owed payout — appends after a torn merged line), then attempt
- // recovery.
- f, err := os.OpenFile(h.g.Economy.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
- if err != nil {
- t.Error(err)
- return
- }
- if _, werr := f.WriteString("{\"id\":\"damaged\",\"kind\":\"payout\"\n"); werr != nil {
- t.Error(werr)
- }
- f.Close()
- if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil {
- t.Errorf("owed payout could not be journaled: %v", err)
- return
- }
- h.g.recoverEconomy()
- // The damaged fold aborted recovery: no compensation ran and no
- // compaction erased the records.
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- if loaded.Credits != 100 {
- t.Errorf("recovery compensated over a damaged journal: credits=%d, want 100 (preserved for inspection)", loaded.Credits)
- }
- data, err := os.ReadFile(h.g.Economy.path)
- if err != nil {
- t.Fatal(err)
- }
- if !containsBytes(data, "kind\":\"payout\"") {
- t.Error("recovery compacted a damaged journal, erasing records preserved for inspection")
- }
- })
-}
-
-func containsBytes(data []byte, s string) bool {
- for i := 0; i+len(s) <= len(data); i++ {
- if string(data[i:i+len(s)]) == s {
- return true
- }
- }
- return false
-}
diff --git a/internal/game/journal_failure_regression_test.go b/internal/game/journal_failure_regression_test.go
deleted file mode 100644
index 043afeb..0000000
--- a/internal/game/journal_failure_regression_test.go
+++ /dev/null
@@ -1,243 +0,0 @@
-package game
-
-// Journal-failure regressions: a broken economy journal (persistent append
-// failure) must never lose money silently. Settlements pay through the
-// character file, stake resolutions are recorded as file evidence, and an
-// unreadable journal aborts recovery/compaction instead of erasing records.
-
-import (
- "os"
- "strings"
- "testing"
- "time"
-
- "thehouseoficarus/internal/casino"
- "thehouseoficarus/internal/player"
-)
-
-// failJournalDuring redirects the economy journal path for the duration of
-// fn, simulating a persistent append failure, and restores it afterwards.
-func failJournalDuring(h *harness, fn func()) {
- path := h.g.Economy.path
- h.g.Economy.path = h.dir
- defer func() { h.g.Economy.path = path }()
- fn()
-}
-
-// TestSettlementFallbackKeepsWholeBalance: a full table round whose payout
-// journal append fails still ends with the player whole — the settlement is
-// paid through the character file, the stake resolution is recorded as
-// evidence, and recovery leaves the balance exact.
-func TestSettlementFallbackKeepsWholeBalance(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 1000
- h.g.Casino.Join(1, casino.TableConfig{ID: "b", Game: casino.GameBaccarat}, "alice")
- var totalPaid int
- failed := false
- w := casinoWallet{g: h.g, p: p}
- // Wrap the wallet so exactly one settlement hits a broken journal.
- wallet := &fallbackProbeWallet{harness: h, inner: w, onPay: func(credits int, failedCall bool) {
- totalPaid += credits
- if failedCall {
- failed = true
- }
- }}
- rounds := 0
- for rounds < 100 && !failed {
- rounds++
- h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet)
- }
- if !failed {
- t.Fatal("probe never observed a settlement payout")
- return
- }
- // One more full round after the failure: the table keeps working.
- rounds++
- h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet)
- h.g.recoverEconomy()
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- want := 1000 - 10*rounds + totalPaid
- if loaded.Credits != want {
- t.Errorf("balance after journal-failure round: credits=%d, want %d (wagers %d, paid %d)",
- loaded.Credits, want, 10*rounds, totalPaid)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Errorf("records left open: %+v", open)
- }
- })
-}
-
-// fallbackProbeWallet routes PayCredits through the real wallet once with a
-// broken journal (the first settlement), then normally.
-type fallbackProbeWallet struct {
- harness *harness
- inner casinoWallet
- onPay func(credits int, failedCall bool)
- broke bool
-}
-
-func (w *fallbackProbeWallet) Wager(amount int) (casino.Wager, error) { return w.inner.Wager(amount) }
-
-func (w *fallbackProbeWallet) PayCredits(amount int) error {
- if w.broke {
- w.onPay(amount, false)
- return w.inner.PayCredits(amount)
- }
- w.broke = true
- w.onPay(amount, true)
- failJournalDuring(w.harness, func() {
- _ = w.inner.PayCredits(amount)
- })
- return nil
-}
-
-func (w *fallbackProbeWallet) ResolveStakes() { w.inner.ResolveStakes() }
-
-func (w *fallbackProbeWallet) PayStakeCover(amount int, stakeIDs []string) error {
- if w.broke {
- w.onPay(amount, false)
- return w.inner.PayStakeCover(amount, stakeIDs)
- }
- w.broke = true
- w.onPay(amount, true)
- failJournalDuring(w.harness, func() {
- _ = w.inner.PayStakeCover(amount, stakeIDs)
- })
- return nil
-}
-
-func (w *fallbackProbeWallet) ResolveStake(stakeID string) { w.inner.ResolveStake(stakeID) }
-func (w *fallbackProbeWallet) RefundOutstanding() (int, int, error) {
- return w.inner.RefundOutstanding()
-}
-
-// TestChipStakeSettlementFallbackNoDoublePay: a chip-funded stake whose win
-// is paid through the character-file fallback must not ALSO get its chips
-// returned in kind by recovery — the settlement returned the stake as
-// credits inside the payout.
-func TestChipStakeSettlementFallbackNoDoublePay(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10})
- w := casinoWallet{g: h.g, p: p}
- if _, err := w.Wager(10); err != nil {
- t.Error(err)
- return
- }
- // The journal is broken for BOTH the settlement and the resolution:
- // the win lands in the character file, the resolution in evidence.
- failJournalDuring(h, func() {
- if err := w.PayCredits(20); err != nil {
- t.Errorf("fallback settlement failed: %v", err)
- return
- }
- w.ResolveStakes()
- })
- h.g.recoverEconomy()
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- if loaded.Credits != 120 || countChips(loaded) != 0 {
- t.Errorf("after recovery: %d credits / %d chips, want 120 / 0 (chip stake debited, win of 20 paid once in credits; no in-kind chip return on top)",
- loaded.Credits, countChips(loaded))
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Errorf("open records: %+v", open)
- }
- })
-}
-
-// TestCorruptJournalAbortsRecoveryWithoutErasure: an unreadable journal
-// region must abort compensation and compaction — the owed payout record
-// beyond the corruption stays in the file for operator inspection instead
-// of being compacted away.
-func TestCorruptJournalAbortsRecoveryWithoutErasure(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- // Corrupt the journal with an oversized line, then journal an owed
- // payout BEYOND it (as appends would after the corruption).
- f, err := os.OpenFile(h.g.Economy.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
- if err != nil {
- t.Error(err)
- return
- }
- _, werr := f.WriteString(strings.Repeat("x", 1024*1024+1) + "\n")
- f.Close()
- if werr != nil {
- t.Error(werr)
- return
- }
- if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil {
- t.Errorf("payout could not be journaled: %v", err)
- return
- }
- h.g.recoverEconomy()
- // The owed payout record is preserved verbatim: no compensation ran
- // (credits unchanged) and no compaction erased the record.
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- data, err := os.ReadFile(h.g.Economy.path)
- if err != nil {
- t.Fatal(err)
- }
- if loaded.Credits != 100 {
- t.Errorf("aborted recovery paid the payout: credits=%d, want 100 (preserved for inspection)", loaded.Credits)
- }
- if !strings.Contains(string(data), `"kind":"payout"`) {
- t.Error("corrupt journal recovery erased the owed payout record instead of preserving it")
- }
- })
-}
-
-// TestBankWithdrawalHonorsCreditCap: a withdrawal that would push the
-// on-hand balance past the shared economy cap is rejected; neither balance
-// changes.
-func TestBankWithdrawalHonorsCreditCap(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- p.Credits = casino.MaxCredits
- p.BankedCredits = 10
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Fatal(err)
- }
- sess, conn := h.connect("alice")
- h.g.RunOnOwner(func() {
- h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"})
- })
- if sess.Player.Credits != casino.MaxCredits || sess.Player.BankedCredits != 10 {
- t.Errorf("rejected withdrawal changed balances: credits=%d banked=%d",
- sess.Player.Credits, sess.Player.BankedCredits)
- }
- // The session writer goroutine flushes asynchronously; wait for the
- // rejection to arrive instead of racing it.
- deadline := time.Now().Add(2 * time.Second)
- for !conn.Contains("cannot hold") && time.Now().Before(deadline) {
- time.Sleep(time.Millisecond)
- }
- if !conn.Contains("cannot hold") {
- t.Errorf("withdrawal rejection not reported; output=%s", conn.Bytes())
- }
-}
diff --git a/internal/game/money.go b/internal/game/money.go
new file mode 100644
index 0000000..35355ca
--- /dev/null
+++ b/internal/game/money.go
@@ -0,0 +1,182 @@
+package game
+
+// Single-file money: the character file is the ONLY durable money record.
+// Every casino money mutation is one atomic SaveCharacter that carries both
+// the balance and the Player.PendingWagers ledger. There is no separate
+// economy journal and no two-phase recovery: startup simply refunds any stake
+// still listed as pending, because a listed stake means the process died
+// before the round resolved. Two-phase journal recovery was deliberately
+// removed as out of scope for a game this size; see
+// building_guide/concurrency.md ("Durable money") before reintroducing it.
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "os"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/player"
+)
+
+// newPendingWagerID returns an opaque wallet handle for a committed stake. A
+// game keeps it to name the stake when its settlement covers it (a table
+// hand) or consumes it (a loss).
+func newPendingWagerID() string {
+ var b [8]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ panic(fmt.Sprintf("pending wager id: %v", err))
+ }
+ return hex.EncodeToString(b[:])
+}
+
+// removePendingWager drops the stake with id from the player's ledger and
+// reports whether it was present.
+func removePendingWager(p *player.Player, id string) bool {
+ if id == "" {
+ return false
+ }
+ kept := p.PendingWagers[:0]
+ removed := false
+ for _, pending := range p.PendingWagers {
+ if pending.ID == id {
+ removed = true
+ continue
+ }
+ kept = append(kept, pending)
+ }
+ p.PendingWagers = kept
+ return removed
+}
+
+// takePendingWagers removes and returns the pending stakes whose IDs are
+// listed, leaving the rest. It is used by a settlement that returns a
+// specific set of stakes (a table hand).
+func takePendingWagers(p *player.Player, ids []string) []player.PendingWager {
+ want := make(map[string]bool, len(ids))
+ for _, id := range ids {
+ want[id] = true
+ }
+ var removed []player.PendingWager
+ kept := p.PendingWagers[:0]
+ for _, pending := range p.PendingWagers {
+ if want[pending.ID] {
+ removed = append(removed, pending)
+ continue
+ }
+ kept = append(kept, pending)
+ }
+ p.PendingWagers = kept
+ return removed
+}
+
+// giveChips re-credits chips to a player's inventory (used by refund paths).
+// It stacks onto an existing chips slot when one exists. It reports false
+// when the chips do NOT fit (no existing stack and no free slot): the caller
+// must then treat the chips as undelivered — they are never silently dropped.
+func (g *Game) giveChips(p *player.Player, qty int) bool {
+ for _, slot := range p.Inventory {
+ if slot != nil && slot.ItemID == "chips" {
+ slot.Quantity += qty
+ return true
+ }
+ }
+ slot := p.FirstFreeSlot()
+ if slot < 0 {
+ return false
+ }
+ p.SetInvSlot(slot, &player.InventorySlot{ItemID: "chips", Quantity: qty})
+ return true
+}
+
+// grantCredits adds credits from ordinary gameplay (shop sales, credit
+// pickups, activity payouts). The economy cap is enforced by clamping: the
+// cap is far above any reachable balance, so hitting it means a data bug —
+// clamp (never wrap) and log loudly for the operator.
+func (g *Game) grantCredits(p *player.Player, amount int) {
+ if amount <= 0 {
+ return
+ }
+ sum, err := addCreditsChecked(p.Credits, amount)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "credit grant for %s clamped at the economy cap: %v\n", p.Name, err)
+ p.Credits = casino.MaxCredits
+ return
+ }
+ p.Credits = sum
+}
+
+// addCreditsChecked is the game-side checked addition against the shared
+// economy cap (casino.MaxCredits, mirrored by the casino wallet: a second,
+// independently drifting cap would let the two sides of the money path
+// disagree about what a balance may hold).
+func addCreditsChecked(a, b int) (int, error) {
+ if b > 0 && a > casino.MaxCredits-b {
+ return casino.MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, casino.MaxCredits)
+ }
+ if b < 0 {
+ return 0, fmt.Errorf("negative credit add %d", b)
+ }
+ return a + b, nil
+}
+
+// recoverPendingWagers refunds every stake left in flight by an ungraceful
+// stop. The character file is the only durable money record, so a pending
+// wager on disk means the process died before the round resolved: the player
+// is made whole by returning the stake (chips in kind, credits as credits).
+// Rounds that resolved are already gone from PendingWagers, removed by the
+// same save that settled them.
+//
+// This is intentionally the entire recovery story. Two-phase journal recovery
+// is out of scope for this game (see building_guide/concurrency.md).
+func (g *Game) recoverPendingWagers() {
+ names, err := g.AccountStore.ListCharacterNames()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "pending wager recovery: list characters: %v\n", err)
+ return
+ }
+ for _, name := range names {
+ p, err := g.AccountStore.LoadCharacter(name)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "pending wager recovery: load %s: %v\n", name, err)
+ continue
+ }
+ if len(p.PendingWagers) == 0 {
+ continue
+ }
+ before := append([]player.PendingWager(nil), p.PendingWagers...)
+ credits, chips := 0, 0
+ for _, pending := range before {
+ credits += pending.Credits
+ chips += pending.Chips
+ }
+ creditsBefore := p.Credits
+ p.PendingWagers = nil
+ if chips > 0 && !g.giveChips(p, chips) {
+ // No inventory room to return the chip stakes: leave the ledger
+ // intact so a later startup (once the player has room) retries.
+ p.PendingWagers = before
+ fmt.Fprintf(os.Stderr, "pending wager recovery: %s: no inventory room for %d chips — stake retained for a later attempt\n", name, chips)
+ continue
+ }
+ sum, capErr := addCreditsChecked(creditsBefore, credits)
+ if capErr != nil {
+ if chips > 0 {
+ p.RemoveItem("chips", chips)
+ }
+ p.PendingWagers = before
+ fmt.Fprintf(os.Stderr, "pending wager recovery: %s: refund blocked at the credit cap: %v — stake retained for a later attempt\n", name, capErr)
+ continue
+ }
+ p.Credits = sum
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ p.Credits = creditsBefore
+ if chips > 0 {
+ p.RemoveItem("chips", chips)
+ }
+ p.PendingWagers = before
+ fmt.Fprintf(os.Stderr, "pending wager recovery: refund save %s: %v\n", name, err)
+ continue
+ }
+ }
+}
diff --git a/internal/game/payout_evidence_regression_test.go b/internal/game/payout_evidence_regression_test.go
deleted file mode 100644
index b58bf9d..0000000
--- a/internal/game/payout_evidence_regression_test.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package game
-
-// Regression: when the economy journal is unavailable, a payout paid
-// through the character file must record the round's stake-resolution
-// evidence in that same file. Otherwise a crash before the games' own
-// ResolveStakes call leaves the wager records open with checkpoint
-// balances that no longer match, and recovery refunds chip stakes the
-// paid settlement had already consumed — duplicating the round's value.
-
-import (
- "testing"
-
- "thehouseoficarus/internal/player"
-)
-
-func TestUnjournalablePayoutCrashDoesNotRefundChips(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10})
- w := casinoWallet{g: h.g, p: p}
- if _, err := w.Wager(10); err != nil {
- t.Error(err)
- return
- }
- path := h.g.Economy.path
- h.g.Economy.path = h.dir // persistent append failure
- if err := w.PayCredits(20); err != nil {
- t.Errorf("fallback payout failed: %v", err)
- return
- }
- h.g.Economy.path = path
- // Process loss BEFORE the game's own ResolveStakes call: the
- // fallback payout itself must be the durable resolution.
- h.g.recoverEconomy()
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- if loaded.Credits != 120 || countChips(loaded) != 0 {
- t.Errorf("recovered credits=%d chips=%d; want 120 credits and no chips (the paid settlement consumed the stake)", loaded.Credits, countChips(loaded))
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Errorf("records left open after recovery: %+v", open)
- }
- })
-}
-
-// The stakes-open count is a proxy for "recovery did not silently refund":
-// with the fix the wager record settles through the file-side evidence.
-func TestUnjournalablePayoutEvidenceRecorded(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- w := casinoWallet{g: h.g, p: p}
- if _, err := w.Wager(10); err != nil {
- t.Error(err)
- return
- }
- path := h.g.Economy.path
- h.g.Economy.path = h.dir
- if err := w.PayCredits(20); err != nil {
- t.Errorf("fallback payout failed: %v", err)
- return
- }
- h.g.Economy.path = path
- if len(p.ResolvedStakes) == 0 {
- t.Error("fallback payout recorded no stake-resolution evidence; a crash before ResolveStakes would double-refund chips")
- }
- })
-}
diff --git a/internal/game/quit_casino_test.go b/internal/game/quit_casino_test.go
index f373457..02dadb0 100644
--- a/internal/game/quit_casino_test.go
+++ b/internal/game/quit_casino_test.go
@@ -15,7 +15,7 @@ import (
// quitTestGame builds the minimal live state quit and stop need: a casino
// manager, a combat tracker, a tick engine for the rest countdown, and a
-// durable wallet (economy journal + account store) so wagers can commit.
+// wallet backed by the account store so wagers can commit.
func quitTestGame(t *testing.T) (*Game, *net.Session) {
t.Helper()
dataDir := t.TempDir()
@@ -28,7 +28,6 @@ func quitTestGame(t *testing.T) (*Game, *net.Session) {
Deps: Deps{AccountStore: player.NewAccountStore(dataDir), Ticks: engine.New()},
Casino: casino.NewManager(1),
Combat: combat.NewTracker(),
- Economy: newEconomyJournal(t.TempDir()),
safespot: NewSafespotManager(),
loggedInChars: map[string]*net.Session{"alice": sess},
restTimers: make(map[string]uint64),
diff --git a/internal/game/readiness_regression_test.go b/internal/game/readiness_regression_test.go
index 41170b7..442bb1f 100644
--- a/internal/game/readiness_regression_test.go
+++ b/internal/game/readiness_regression_test.go
@@ -1,18 +1,15 @@
package game
-// Readiness regressions for the account-rename, disconnect, and durable-money
-// paths reviewed before world/casino expansion: a transport disconnect must
-// be race-free, renames must not break ownership or strand owed payouts,
-// reserved-loot pickups must never destroy items, and recovery failure paths
-// must retry or surface instead of silently dropping money.
+// Readiness regressions for the account-rename and disconnect paths reviewed
+// before world/casino expansion: a transport disconnect must be race-free,
+// renames must not break ownership, and reserved-loot pickups must never
+// destroy items.
import (
- "os"
"path/filepath"
"testing"
"thehouseoficarus/internal/net"
- "thehouseoficarus/internal/player"
)
// TestDisconnectRaceIsFreeAndNeverLost drives DisconnectTick (owner) while a
@@ -72,40 +69,6 @@ func TestReservedPickupDoesNotDestroyItems(t *testing.T) {
})
}
-// TestOwedPayoutSurvivesCharacterRename: economy records resolve their
-// subject by the character's STABLE ID, so renaming the character between
-// the crash and recovery still delivers the owed payout.
-func TestOwedPayoutSurvivesCharacterRename(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("acct")
- p := h.seedCharacter("alice", "acct")
- h.g.RunOnOwner(func() {
- p.ID = "stable-char-id" // production characters carry a stable ID
- p.Credits = 90
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil {
- t.Error(err)
- return
- }
- if err := h.g.renameCharacterTxn("acct", "alice", "alicia"); err != nil {
- t.Error(err)
- return
- }
- h.g.recoverEconomy()
- loaded, err := h.g.AccountStore.LoadCharacter("alicia")
- if err != nil {
- t.Error(err)
- return
- }
- if loaded.Credits != 110 {
- t.Errorf("renamed character never receives owed payout: credits=%d, want 110", loaded.Credits)
- }
- })
-}
-
// TestAccountRenamePreservesAccessAndOwnership: renaming an account
// rewrites the owned character files' owner field in the same transaction
// and republishes stale sessions (still carrying the old cached name) so
@@ -127,174 +90,3 @@ func TestAccountRenamePreservesAccessAndOwnership(t *testing.T) {
}
})
}
-
-// TestSettledSurrenderDoesNotRefundChipStake: a settlement payout — even a
-// partial one like a surrendered hand paying half a chip stake — proves the
-// round resolved, so recovery must not ALSO return the chip stake in kind.
-func TestSettledSurrenderDoesNotRefundChipStake(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10})
- w := casinoWallet{g: h.g, p: p}
- if _, err := w.Wager(10); err != nil {
- t.Error(err)
- return
- }
- // Surrender settles for half the stake; the crash lands before the
- // resolution marker.
- if err := w.PayCredits(5); err != nil {
- t.Error(err)
- return
- }
- h.g.recoverEconomy()
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- if loaded.Credits != 105 || countChips(loaded) != 0 {
- t.Errorf("settled surrender recovered %d credits / %d chips; want 105 / 0", loaded.Credits, countChips(loaded))
- }
- })
-}
-
-// TestRecoverySaveFailureRetriesOnNextStartup: a TRANSIENT save failure
-// during recovery must not re-anchor the owed record to unreadable
-// checkpoints — the record keeps its original checkpoints and a later
-// recovery (after the disk is fixed) re-applies the owed payout.
-func TestRecoverySaveFailureRetriesOnNextStartup(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- charDir := filepath.Join(h.dir, "players", "characters")
- t.Cleanup(func() { os.Chmod(charDir, 0755) })
- h.g.RunOnOwner(func() {
- p.Credits = 90
- if err := h.g.AccountStore.SaveCharacter(p); err != nil {
- t.Error(err)
- return
- }
- if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil {
- t.Error(err)
- return
- }
- if err := os.Chmod(charDir, 0500); err != nil {
- t.Error(err)
- return
- }
- h.g.recoverEconomy() // every save fails
- if err := os.Chmod(charDir, 0755); err != nil {
- t.Error(err)
- return
- }
- h.g.recoverEconomy() // retry with a healthy disk
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- if loaded.Credits != 110 {
- t.Errorf("after fixing the disk and retrying: %d credits, want 110; open=%+v", loaded.Credits, h.g.Economy.mustLoadOpen(t))
- }
- })
-}
-
-// TestResolvedStakeEvidenceSurvivesJournalFailure: a resolution marker
-// that cannot be journaled (persistent append failure) leaves the stake
-// OPEN — never silently forgotten — and records the resolution in the
-// character file as durable evidence. Recovery then settles the stake
-// WITHOUT refunding it: the round genuinely resolved, and the file is the
-// proof. When the file save also fails, no evidence exists and recovery
-// applies the documented refund-on-lost-state policy instead.
-func TestResolvedStakeEvidenceSurvivesJournalFailure(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- w := casinoWallet{g: h.g, p: p}
- if _, err := w.Wager(10); err != nil {
- t.Error(err)
- return
- }
- path := h.g.Economy.path
- h.g.Economy.path = h.dir // persistent append failure
- w.ResolveStakes()
- h.g.Economy.path = path
- if open := h.g.Economy.openStakes(economyKey(p)); len(open) != 1 {
- t.Errorf("unresolvable stake was silently dropped: open=%d", len(open))
- }
- // The resolution is recorded durably in the character file.
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- if len(loaded.ResolvedStakes) != 1 {
- t.Errorf("resolved-stake evidence not recorded: %+v", loaded.ResolvedStakes)
- return
- }
- h.g.recoverEconomy()
- loaded, err = h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- if loaded.Credits != 90 {
- t.Errorf("after recovery: credits=%d, want 90 (round resolved; evidence prevents a refund)", loaded.Credits)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Errorf("records left open after recovery: %+v", open)
- }
- if len(loaded.ResolvedStakes) != 0 {
- t.Errorf("stale resolved-stake evidence not pruned: %+v", loaded.ResolvedStakes)
- }
- })
-}
-
-// TestUnjournalablePayoutPaidThroughCharacterFile: a payout that cannot be
-// journaled (persistent append failure) is still made durable through the
-// character file — the save is the commit point, so the win is real and
-// announced. The resolved stake is recorded as file evidence so recovery
-// neither refunds the stake (double pay) nor drops the round.
-func TestUnjournalablePayoutPaidThroughCharacterFile(t *testing.T) {
- h := newHarness(t)
- h.seedAccount("alice")
- p := h.seedCharacter("alice", "alice")
- h.g.RunOnOwner(func() {
- p.Credits = 100
- w := casinoWallet{g: h.g, p: p}
- if _, err := w.Wager(10); err != nil {
- t.Error(err)
- return
- }
- path := h.g.Economy.path
- h.g.Economy.path = h.dir
- err := w.PayCredits(20)
- h.g.Economy.path = path
- if err != nil {
- t.Errorf("character-file fallback failed: %v", err)
- return
- }
- if p.Credits != 110 {
- t.Errorf("fallback payout did not land: credits=%d, want 110", p.Credits)
- }
- // As the games do after a successful settlement: resolve the stake.
- w.ResolveStakes()
- h.g.recoverEconomy()
- loaded, err := h.g.AccountStore.LoadCharacter("alice")
- if err != nil {
- t.Error(err)
- return
- }
- if loaded.Credits != 110 {
- t.Errorf("after recovery: credits=%d, want 110 (win durable once; no stake refund on top)", loaded.Credits)
- }
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Errorf("open records: %+v", open)
- }
- })
-}
diff --git a/internal/game/economy_shutdown_test.go b/internal/game/wallet_test.go
index 27190fb..218ce36 100644
--- a/internal/game/economy_shutdown_test.go
+++ b/internal/game/wallet_test.go
@@ -1,12 +1,12 @@
package game
-// Durable-money and graceful-shutdown regressions: journal-first wagers
-// with revert on save failure, startup recovery that refunds lost wagers
-// and re-pays lost payouts, checked currency arithmetic, and a shutdown
-// that persists online characters.
+// Single-file money wallet regressions: a wager debits and records a pending
+// stake in one atomic character save, a settlement credits and clears the
+// stakes it returns in one save, a failed save reverts, and startup refunds
+// anything left in flight. See internal/game/money.go for the design and
+// building_guide/concurrency.md for why two-phase recovery is out of scope.
import (
- "encoding/json"
"os"
"path/filepath"
"testing"
@@ -21,7 +21,7 @@ import (
)
// harnessFromDir builds a Game over an existing directory (used when tests
-// must pre-seed character files and journal records before startup).
+// must pre-seed character files before startup).
func harnessFromDir(t *testing.T, dir string) *harness {
t.Helper()
colors := &config.ColorsConfig{}
@@ -63,25 +63,7 @@ func writeSeedFile(t *testing.T, path string, data []byte) {
}
}
-func writeEconomyJournal(t *testing.T, dir string, records []economyRecord) {
- t.Helper()
- if err := os.MkdirAll(filepath.Join(dir, "economy"), 0700); err != nil {
- t.Fatal(err)
- }
- var data []byte
- for _, rec := range records {
- line, err := json.Marshal(rec)
- if err != nil {
- t.Fatal(err)
- }
- data = append(data, line...)
- }
- if err := os.WriteFile(filepath.Join(dir, "economy", "journal.jsonl"), data, 0600); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestEconomyWagerDebitsDurallyAndSettles(t *testing.T) {
+func TestWagerDebitsDurablyAndLeavesPendingStake(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
@@ -90,9 +72,10 @@ func TestEconomyWagerDebitsDurallyAndSettles(t *testing.T) {
})
w := casinoWallet{g: h.g, p: p}
+ var wager casino.Wager
var wagerErr error
h.g.RunOnOwner(func() {
- _, wagerErr = w.Wager(10)
+ wager, wagerErr = w.Wager(10)
})
h.barrier()
if wagerErr != nil {
@@ -101,23 +84,31 @@ func TestEconomyWagerDebitsDurallyAndSettles(t *testing.T) {
if p.Credits != 90 {
t.Fatalf("credits after wager = %d, want 90", p.Credits)
}
- // The debit is durable, and the record stays OPEN: the stake is
- // outstanding until the round resolves (recovery refunds open stakes
- // after a crash).
- if open := h.g.Economy.openStakes("alice"); len(open) != 1 || open[0].Credit != 10 {
- t.Fatalf("outstanding stake after a successful wager: %+v", open)
+ if len(p.PendingWagers) != 1 || p.PendingWagers[0].Credits != 10 || p.PendingWagers[0].ID != wager.ID {
+ t.Fatalf("pending stake after wager = %+v, want one 10-credit stake %q", p.PendingWagers, wager.ID)
}
- // Resolution (round resolved or refunded) closes it.
+ // The debit and the pending entry are already durable in the file.
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 90 || len(loaded.PendingWagers) != 1 {
+ t.Fatalf("durable state after wager = credits %d, pending %+v; want 90 and one stake", loaded.Credits, loaded.PendingWagers)
+ }
+
+ // A settlement that covers the stake credits and clears it in one save.
h.g.RunOnOwner(func() {
- w.ResolveStakes()
+ if err := w.PayStakeCover(10, []string{wager.ID}); err != nil {
+ t.Error(err)
+ }
})
h.barrier()
- if open := h.g.Economy.openStakes("alice"); len(open) != 0 {
- t.Fatalf("open stakes remain after resolution: %v", open)
+ if p.Credits != 100 || len(p.PendingWagers) != 0 {
+ t.Fatalf("after covered settlement = credits %d, pending %+v; want 100 and none", p.Credits, p.PendingWagers)
}
}
-func TestEconomyFailedSaveRevertsAndVoids(t *testing.T) {
+func TestFailedWagerSaveReverts(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
@@ -141,84 +132,60 @@ func TestEconomyFailedSaveRevertsAndVoids(t *testing.T) {
t.Fatalf("wager error = %v, want ErrWalletPersistence", wagerErr)
}
// The in-memory mutation must have been reverted: nothing was charged.
- if p.Credits != 100 {
- t.Fatalf("credits after failed wager = %d, want 100 (reverted)", p.Credits)
+ if p.Credits != 100 || len(p.PendingWagers) != 0 {
+ t.Fatalf("after failed wager = credits %d, pending %+v; want 100 and none (reverted)", p.Credits, p.PendingWagers)
}
- // The record is void, not open: recovery must not compensate.
os.Chmod(charDir, 0700)
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Fatalf("void record remained open: %v", open)
- }
}
-func TestEconomyRecoveryRefundsDurableLostWager(t *testing.T) {
- dir := t.TempDir()
- seedWorld(t, dir)
-
- // Character with the debit already durable (crash after save).
+// seedWageringCharacter writes a character whose stake is durable and still
+// pending, simulating a process death with a round in flight.
+func seedWageringCharacter(t *testing.T, dir, name string, credits int, pending []player.PendingWager) {
+ t.Helper()
hash, _ := player.HashPassword("pw")
- accData, _ := yaml.Marshal(&player.Account{Name: "alice", PasswordHash: hash})
- writeSeedFile(t, filepath.Join(dir, "players", "accounts", "alice.yaml"), accData)
- p := player.New("alice")
- p.Credits = 90 // post-debit balance
- p.Account = "alice"
+ accData, _ := yaml.Marshal(&player.Account{Name: name, PasswordHash: hash})
+ writeSeedFile(t, filepath.Join(dir, "players", "accounts", name+".yaml"), accData)
+ p := player.New(name)
+ p.Credits = credits
+ p.Account = name
+ p.PendingWagers = pending
pData, _ := yaml.Marshal(p)
- writeSeedFile(t, filepath.Join(dir, "players", "characters", "alice.yaml"), pData)
+ writeSeedFile(t, filepath.Join(dir, "players", "characters", name+".yaml"), pData)
+}
- // Open wager record: 10 credits debited (before=100, after=90).
- writeEconomyJournal(t, dir, []economyRecord{{
- ID: "lost-wager", Player: "alice", Kind: "wager",
- Chips: 0, Credit: 10, Status: "open",
- CreditsBefore: 100, CreditsAfter: 90,
- ChipsBefore: 0, ChipsAfter: 0,
- }})
+func TestRecoveryRefundsPendingWagers(t *testing.T) {
+ dir := t.TempDir()
+ seedWorld(t, dir)
+ seedWageringCharacter(t, dir, "alice", 90, []player.PendingWager{{ID: "lost-wager", Credits: 10}})
h := harnessFromDir(t, dir)
- // Recovery must refund the stake: credits back to 100.
loaded, err := h.g.AccountStore.LoadCharacter("alice")
if err != nil {
t.Fatal(err)
}
if loaded.Credits != 100 {
- t.Fatalf("credits after recovery = %d, want 100 (refund)", loaded.Credits)
+ t.Fatalf("credits after recovery = %d, want 100 (stake refunded)", loaded.Credits)
}
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Fatalf("recovered record left open: %v", open)
+ if len(loaded.PendingWagers) != 0 {
+ t.Fatalf("pending wagers after recovery = %+v, want none", loaded.PendingWagers)
}
}
-func TestEconomyRecoveryAppliesLostPayout(t *testing.T) {
+func TestRecoveryRefundsChipWagersInKind(t *testing.T) {
dir := t.TempDir()
seedWorld(t, dir)
-
- // Character at pre-payout balance; the payout was journaled but the
- // save never happened (crash before save). The player is owed it.
- hash, _ := player.HashPassword("pw")
- accData, _ := yaml.Marshal(&player.Account{Name: "bob", PasswordHash: hash})
- writeSeedFile(t, filepath.Join(dir, "players", "accounts", "bob.yaml"), accData)
- p := player.New("bob")
- p.Credits = 50
- p.Account = "bob"
- pData, _ := yaml.Marshal(p)
- writeSeedFile(t, filepath.Join(dir, "players", "characters", "bob.yaml"), pData)
-
- writeEconomyJournal(t, dir, []economyRecord{{
- ID: "lost-payout", Player: "bob", Kind: "payout",
- Chips: 0, Credit: 25, Status: "open",
- CreditsBefore: 50, CreditsAfter: 75,
- ChipsBefore: 0, ChipsAfter: 0,
- }})
+ seedWageringCharacter(t, dir, "alice", 100, []player.PendingWager{{ID: "chips-only", Chips: 10}})
h := harnessFromDir(t, dir)
- loaded, err := h.g.AccountStore.LoadCharacter("bob")
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
if err != nil {
t.Fatal(err)
}
- if loaded.Credits != 75 {
- t.Fatalf("credits after recovery = %d, want 75 (payout re-applied)", loaded.Credits)
+ if got := countChips(loaded); got != 10 {
+ t.Fatalf("chips after recovery = %d, want 10 (stake refunded in kind)", got)
}
- if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
- t.Fatalf("recovered record left open: %v", open)
+ if len(loaded.PendingWagers) != 0 {
+ t.Fatalf("pending wagers after recovery = %+v, want none", loaded.PendingWagers)
}
}
diff --git a/internal/player/player.go b/internal/player/player.go
index efa906b..22ef04a 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -186,20 +186,13 @@ type Player struct {
HP int `yaml:"hp"`
Credits int `yaml:"credits"`
BankedCredits int `yaml:"banked_credits"`
- // OpenPayouts is durable evidence for the economy journal (see
- // internal/game/economy.go): the IDs of journaled payout records whose
- // amounts are ALREADY INCLUDED in this file's credit balance. Recovery
- // uses membership to prove a payout became durable instead of guessing
- // from balance checkpoints, which unrelated credit changes can fake.
- OpenPayouts []string `yaml:"open_payouts,omitempty"`
- // ResolvedStakes is durable evidence for the economy journal (see
- // internal/game/economy.go): the IDs of outstanding wager records the
- // session RESOLVED (won, lost, pushed, refunded) but whose settled
- // markers could not be appended to the journal (e.g. a broken economy
- // path). Recovery treats membership as proof the round resolved — the
- // stake must not be refunded and its chip part must not return in kind
- // on top of a settlement that already returned it as credits.
- ResolvedStakes []string `yaml:"resolved_stakes,omitempty"`
+ // PendingWagers is the durable ledger of committed casino stakes whose
+ // round is still in flight. The character file is the only durable money
+ // record: the same atomic SaveCharacter that debits a stake appends it
+ // here, and the same save that settles or refunds the round removes it.
+ // If the process dies with an entry still listed, startup returns the
+ // stake (refund-on-lost-round). See PendingWager.
+ PendingWagers []PendingWager `yaml:"pending_wagers,omitempty"`
Description string `yaml:"description"`
AttackStyle AttackStyle `yaml:"attack_style"`
Prompt string `yaml:"prompt"`
@@ -249,6 +242,16 @@ type MapSymbolData struct {
Color string `yaml:"color,omitempty"`
}
+// PendingWager is one committed casino stake whose round is still in flight.
+// It is the entire durable representation of that obligation; there is no
+// separate economy journal. Chips and Credits are exactly the amounts debited
+// by the wager, so a startup refund returns them in kind (chips as chips).
+type PendingWager struct {
+ ID string `yaml:"id"`
+ Chips int `yaml:"chips,omitempty"`
+ Credits int `yaml:"credits,omitempty"`
+}
+
// PendingSequence is the persisted snapshot of an in-flight LOCKED trigger
// sequence (Trigger.Lock == true). Unlocked sequences are never persisted.
// On reconnect, the game rebuilds a sequence from this snapshot and resumes