diff options
Diffstat (limited to 'internal/game/economy.go')
| -rw-r--r-- | internal/game/economy.go | 289 |
1 files changed, 289 insertions, 0 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go new file mode 100644 index 0000000..90c24ed --- /dev/null +++ b/internal/game/economy.go @@ -0,0 +1,289 @@ +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() +} |
