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" "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[:]) } // append writes one durable line. Errors are returned to the caller, which // must treat the transaction as failed. 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() if _, err := f.Write(append(data, '\n')); err != nil { return fmt.Errorf("append economy journal: %w", err) } if err := f.Sync(); err != nil { return fmt.Errorf("sync economy journal: %w", err) } if !j.dirSynced { if err := syncDir(filepath.Dir(j.path)); err != nil { return 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 for i := 0; i < max(attempts, 1); i++ { if err = j.append(rec); err == nil { return nil } } 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 nil, 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 // 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) { 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 _ = g.Economy.append(*other) 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 } 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) } } 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) } }