aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/economy.go')
-rw-r--r--internal/game/economy.go289
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()
+}