diff options
| author | historia <[not public]> | 2026-09-18 21:36:36 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 21:36:36 -0400 |
| commit | a8a67482bbff8297a96adff65d2fd7029ed998cc (patch) | |
| tree | e84f8ba636c8f2edb4d2ff8b45d7cff7298fa7cc /building_guide/concurrency.md | |
| parent | ff04d26ce90d6b411a04a2fb59f7c24ceefb161f (diff) | |
| download | thehouseoficarus-a8a67482bbff8297a96adff65d2fd7029ed998cc.tar.gz | |
refactor: replace economy journal with single-file pending-wager wallet
Diffstat (limited to 'building_guide/concurrency.md')
| -rw-r--r-- | building_guide/concurrency.md | 139 |
1 files changed, 50 insertions, 89 deletions
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 |
