aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 21:36:36 -0400
committerhistoria <[not public]>2026-09-18 21:36:36 -0400
commita8a67482bbff8297a96adff65d2fd7029ed998cc (patch)
treee84f8ba636c8f2edb4d2ff8b45d7cff7298fa7cc /internal/game/economy.go
parentff04d26ce90d6b411a04a2fb59f7c24ceefb161f (diff)
downloadthehouseoficarus-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.go1466
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)
- }
-}