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, then the record is // settled. A crash at any point 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 record with the character file at its post-transaction state // (crash after save) — recovery refunds open debit records (the round // state was lost in the crash, so the stake returns) and voids payout // records that are already durable. // // 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 (normally none). 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 } func newEconomyJournal(dataDir string) *economyJournal { dir := filepath.Join(dataDir, "economy") _ = os.MkdirAll(dir, 0700) return &economyJournal{path: filepath.Join(dir, "journal.jsonl")} } 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) } // loadOpen folds the journal and returns the still-open records. func (j *economyJournal) loadOpen() []*economyRecord { f, err := os.Open(j.path) if err != nil { return nil } defer f.Close() latest := map[string]*economyRecord{} scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { 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 } latest[rec.ID] = &rec } var open []*economyRecord for _, rec := range latest { if rec.Status == "open" { open = append(open, rec) } } return open } // 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. func (g *Game) recoverEconomy() { if g.Economy == nil { return } records := g.Economy.loadOpen() compensated := 0 for _, rec := range records { p, err := g.AccountStore.LoadCharacter(rec.Player) 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", rec.Player, err) continue } switch { case rec.Kind == "wager" && p.Credits == rec.CreditsBefore: // The debit never became durable: void it. g.Economy.void(rec) case rec.Kind == "wager" && p.Credits == rec.CreditsAfter: // The debit is durable but the round was lost in the crash: // refund the stake (approved refund-on-recovery policy). chips, credits := rec.Chips, rec.Credit p.Credits += credits if chips > 0 { g.giveChips(p, 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++ case rec.Kind == "wager": // Balance moved past the recorded checkpoint: cannot compensate // reliably; surface for operator inspection. fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d)\n", rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) g.Economy.void(rec) case rec.Kind == "payout" && 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 rec.Kind == "payout" && p.Credits == rec.CreditsAfter: g.Economy.settle(rec) case rec.Kind == "payout": fmt.Fprintf(os.Stderr, "economy recovery: ambiguous payout %s for %s (credits %d, want before %d or after %d)\n", rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) g.Economy.settle(rec) } } g.Economy.compact() }