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" "time" "thehouseoficarus/internal/player" ) type economyRecord struct { ID string `json:"id"` Player string `json:"player"` 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"` // 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 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 } // 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) { rec := &economyRecord{ ID: newEconomyRecordID(), Player: p.Name, Kind: kind, 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.Player] = append(j.stakes[rec.Player], rec) } // openStakes returns the outstanding stake records for a player. 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. func (j *economyJournal) ResolveStakes(player string) { for _, rec := range j.stakes[player] { rec.Status = "settled" _ = j.append(*rec) } delete(j.stakes, 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 recovery // refunds). Stacks onto an existing chips slot when one exists. func (g *Game) giveChips(p *player.Player, qty int) { for _, slot := range p.Inventory { if slot != nil && slot.ItemID == "chips" { slot.Quantity += qty return } } slot := p.FirstFreeSlot() if slot >= 0 { p.SetInvSlot(slot, &player.InventorySlot{ItemID: "chips", Quantity: qty}) } } // payCreditsDurable applies a checked credit increase and persists it. 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 } p.Credits = sum return g.AccountStore.SaveCharacter(p) } // 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() players := make(map[string]*player.Player) loadPlayer := func(name string) *player.Player { if p, ok := players[name]; ok { return p } p, err := g.AccountStore.LoadCharacter(name) if err != nil { // 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: %v\n", name, err) return nil } players[name] = p return p } hasLaterPayout := func(name string, seq int) bool { for _, fr := range folded { if fr.rec.Kind == "payout" && fr.rec.Player == name && fr.rec.Status != "void" && fr.seq > seq { return true } } return false } compensated := 0 // Phase 1 — payouts, in append order. for i := range folded { rec := &folded[i].rec if rec.Kind != "payout" || rec.Status != "open" { continue } p := loadPlayer(rec.Player) if p == nil { continue } switch { case p.Credits == rec.CreditsBefore: // The payout was lost in the crash: the player is owed it. if err := g.payCreditsDurable(p, rec.Credit); err != nil { fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err) continue } g.Economy.settle(rec) compensated++ case p.Credits == rec.CreditsAfter: g.Economy.settle(rec) 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 := hasLaterPayout(rec.Player, folded[openWagers[k]].seq) p := loadPlayer(rec.Player) if p == nil { continue } creditsNow, chipsNow := p.Credits, countChips(p) switch { case creditsNow == rec.CreditsBefore && chipsNow == rec.ChipsBefore: // The debit never became durable: void it. g.Economy.void(rec) case creditsNow == rec.CreditsAfter && chipsNow == rec.ChipsAfter: if resolvedByPayout { // A later payout record proves the round resolved (the win, // push, or refund already returned this stake inside that // payout). Only the resolution marker was lost. 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). p.Credits += rec.Credit if rec.Chips > 0 { g.giveChips(p, rec.Chips) } if err := g.AccountStore.SaveCharacter(p); err != nil { fmt.Fprintf(os.Stderr, "economy recovery: refund save %s: %v\n", p.Name, err) continue } g.Economy.settle(rec) compensated++ default: 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() }