aboutsummaryrefslogtreecommitdiff
path: root/building_guide
diff options
context:
space:
mode:
Diffstat (limited to 'building_guide')
-rw-r--r--building_guide/concurrency.md139
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