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 /internal/game/economy.go | |
| parent | ff04d26ce90d6b411a04a2fb59f7c24ceefb161f (diff) | |
| download | thehouseoficarus-a8a67482bbff8297a96adff65d2fd7029ed998cc.tar.gz | |
refactor: replace economy journal with single-file pending-wager wallet
Diffstat (limited to 'internal/game/economy.go')
| -rw-r--r-- | internal/game/economy.go | 1466 |
1 files changed, 0 insertions, 1466 deletions
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) - } -} |
