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. // // 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" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "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"` // 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"` // 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"` } type economyJournal struct { path string // 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 } 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)} } func newEconomyRecordID() string { var b [8]byte if _, err := rand.Read(b[:]); err != nil { panic(fmt.Sprintf("economy record id: %v", err)) } return hex.EncodeToString(b[:]) } // append writes one durable line. Errors are returned to the caller, which // must treat the transaction as failed. func (j *economyJournal) append(rec economyRecord) error { rec.TS = time.Now() data, err := json.Marshal(rec) if err != nil { return err } f, err := os.OpenFile(j.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return fmt.Errorf("open economy journal: %w", err) } defer f.Close() if _, err := f.Write(append(data, '\n')); err != nil { return fmt.Errorf("append economy journal: %w", err) } if err := f.Sync(); err != nil { return fmt.Errorf("sync economy journal: %w", err) } return nil } // appendRetry appends one durable line, retrying a bounded number of // times: a transient write failure must not permanently lose a marker // that money correctness depends on. func (j *economyJournal) appendRetry(rec economyRecord, attempts int) error { var err error for i := 0; i < max(attempts, 1); i++ { if err = j.append(rec); err == nil { return nil } } return err } // start opens a durable record for the in-flight transaction. func (j *economyJournal) start(kind string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { return j.startKind(kind, false, 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, p, 0, credits, creditsBefore, creditsAfter, 0, 0) } func (j *economyJournal) startKind(kind string, refund bool, 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, Chips: chips, Credit: credits, Status: "open", CreditsBefore: creditsBefore, CreditsAfter: creditsAfter, ChipsBefore: chipsBefore, ChipsAfter: chipsAfter, } if err := j.append(*rec); err != nil { return nil, err } return rec, nil } // settle marks a record settled (the balance mutation is durable). func (j *economyJournal) settle(rec *economyRecord) { rec.Status = "settled" _ = j.append(*rec) } // void marks a record void (the mutation was reverted in memory and never // became durable; recovery must not compensate). func (j *economyJournal) void(rec *economyRecord) { rec.Status = "void" _ = j.append(*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. func (j *economyJournal) ResolveStakes(player string) { 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) } } if len(remaining) == 0 { delete(j.stakes, player) } else { j.stakes[player] = remaining } } // 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 } // reloadStakes rebuilds the outstanding-stake index from the journal file: // open wager records remain tracked (and refundable/resolvable) after // recovery compaction. func (j *economyJournal) reloadStakes() { j.stakes = make(map[string][]*economyRecord) for _, fr := range j.loadFolded() { if fr.rec.Kind == "wager" && fr.rec.Status == "open" { rec := fr.rec j.registerStake(&rec) } } } // loadOpen folds the journal and returns the still-open records. func (j *economyJournal) loadOpen() []*economyRecord { var open []*economyRecord for _, fr := range j.loadFolded() { if fr.rec.Status == "open" { rec := fr.rec open = append(open, &rec) } } return open } // foldedRecord is the latest state of one record ID together with the // order (line position of its latest write) it occupied in the journal. type foldedRecord struct { rec economyRecord seq int } // loadFolded folds the journal (latest line per record ID wins) and // returns the surviving records in journal append order. func (j *economyJournal) loadFolded() []foldedRecord { f, err := os.Open(j.path) if err != nil { return nil } defer f.Close() latest := map[string]*foldedRecord{} var order []*foldedRecord 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 torn final line (crash mid-append) is not a valid record; // skip it. Appends are small and single-line, so only the last // line can be torn. continue } if existing, ok := latest[rec.ID]; ok { existing.rec = rec existing.seq = seq } else { fr := &foldedRecord{rec: rec, seq: seq} latest[rec.ID] = fr order = append(order, fr) } } folded := make([]foldedRecord, 0, len(order)) for _, fr := range order { folded = append(folded, *fr) } return folded } // compact rewrites the journal keeping only still-open records. func (j *economyJournal) compact() { open := j.loadOpen() tmp := j.path + ".compact" f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { 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 } w.Write(data) 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. if err := os.Rename(tmp, j.path); err != nil { os.Remove(tmp) } } // 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 } // addCreditsChecked is the game-side checked addition against the economy // cap (mirrors casino.MaxCredits; kept local to avoid a dependency cycle). func addCreditsChecked(a, b int) (int, error) { const maxCredits = 1_000_000_000_000 if b > 0 && a > maxCredits-b { return maxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, maxCredits) } if b < 0 { return 0, fmt.Errorf("negative credit add %d", b) } return a + b, nil } // 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; 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 := g.Economy.loadFolded() // 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 } } } loadPlayer := func(rec *economyRecord) *player.Player { if rec.PlayerID != "" { if p, ok := playersByID[rec.PlayerID]; ok { return p } } if rec.Player != "" { if p, ok := playersByName[strings.ToLower(rec.Player)]; ok { return p } } if rec.PlayerID != "" { // 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 p } } } } // Character file unreadable: leave the record open so a later // attempt (or operator inspection) can resolve it; do not // silently drop money. fmt.Fprintf(os.Stderr, "economy recovery: load %s (id %q): character not found\n", rec.Player, rec.PlayerID) return nil } // laterPayoutEvidence reports whether any non-void payout record was // journaled for the player after seq — evidence the round resolved and // its settlement was recorded. // // 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. laterPayoutEvidence := func(name string, seq int) (resolved bool, settlement bool) { for _, fr := range folded { if fr.rec.Kind == "payout" && fr.rec.Player == name && fr.rec.Status != "void" && fr.seq > seq { resolved = true if !fr.rec.Refund { settlement = true } } } return resolved, settlement } compensated := 0 // Phase 1 — payouts, in append order. Records with identical // checkpoints are decided as one group: k sibling records all journaled // against balance `before` were all written against the same in-memory // balance (a failed save reverts it), so either none became durable // (the file is still at `before`: all k are owed) or at most one did // (the file is at `after` and k == 1). Re-applying identical siblings // one at a time would let the second record misread the first // re-application as its own durability and silently drop the owed // payout — the group is settled together instead. type payoutSig struct { player 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.Player, rec.CreditsBefore, rec.CreditsAfter, rec.Credit} siblings[sig] = append(siblings[sig], rec) } } settled := make(map[string]bool) 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 } sig := payoutSig{rec.Player, 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. 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. paid := 0 capBlocked := false for m := 0; m < len(group); m++ { if _, err := addCreditsChecked(p.Credits, rec.Credit); err != nil { capBlocked = true break } if err := g.payCreditsDurable(p, rec.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 { // The credit cap blocked re-application. Re-anchor the // record's checkpoints to the current durable balance so // a later recovery re-applies it against a balance it can // actually be paid from — without this, the balance a PAID // sibling moved reads as this record's own durability and // the liability is silently settled instead of paid. other.CreditsBefore = p.Credits other.CreditsAfter = p.Credits + other.Credit settled[other.ID] = true // handled for the rest of this run _ = g.Economy.append(*other) continue } // A TRANSIENT save failure blocked re-application and the // balance still has room below the cap: keep the record open // with its ORIGINAL checkpoints. Re-anchoring here would make // the record unreadable against the unchanged durable balance // (before-checkpoint mismatch) and permanently ambiguous; the // original checkpoints let a later recovery retry cleanly. settled[other.ID] = true // handled for the rest of this run } case p.Credits == rec.CreditsAfter && len(group) == 1: g.Economy.settle(rec) settled[rec.ID] = true 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 resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec.Player, folded[openWagers[k]].seq) p := loadPlayer(rec) if p == nil { continue } // 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. p.Credits += rec.Credit if err := g.AccountStore.SaveCharacter(p); err != nil { // Revert the in-memory refund so later records for this // player still compare against the durable balance; the // record stays open and a later startup can retry. p.Credits = creditsNow if chipsOwed { p.RemoveItem("chips", rec.Chips) } fmt.Fprintf(os.Stderr, "economy recovery: refund save %s: %v\n", p.Name, err) continue } g.Economy.settle(rec) compensated++ default: // Persist an owed chip return even when the credit side stays // ambiguous: the record remains open either way, but the chips // must not be silently lost. A failed save reverts them. if saveChipReturn() != nil { continue } if resolvedByPayout { g.Economy.settle(rec) continue } fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n", rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) } } g.Economy.compact() // Rebuild the outstanding-stake index from whatever open wager records // remain (normally none; ambiguous ones stay refundable/resolvable). g.Economy.reloadStakes() }