diff options
| author | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
| commit | 153acb7f644d755c45533b7c5311e3ffa47e8973 (patch) | |
| tree | 7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/game/economy.go | |
| parent | 87d08f0151e643951769e323995f1c5ed89ee200 (diff) | |
| download | thehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz | |
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/game/economy.go')
| -rw-r--r-- | internal/game/economy.go | 83 |
1 files changed, 63 insertions, 20 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go index b39dbe4..b436a29 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -52,6 +52,7 @@ import ( "strings" "time" + "thehouseoficarus/internal/casino" "thehouseoficarus/internal/player" ) @@ -106,6 +107,12 @@ type economyRecord struct { type economyJournal struct { path string + // dirSynced records whether the directory entry of path has been made + // durable. A file's own fsync does not persist the entry that names it, + // so the first append must sync the directory (as persistence.WriteFile + // does) or a crash can lose the whole journal — including the record of + // a debit that is already durable in the character file. + dirSynced bool // 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 @@ -132,6 +139,17 @@ func newEconomyJournal(dataDir string) *economyJournal { return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool), openPayoutRecs: make(map[string][]*economyRecord)} } +// syncDir fsyncs a directory so an entry created or replaced in it survives a +// crash (the same durability step persistence.WriteFile performs). +func syncDir(dir string) error { + d, err := os.Open(dir) + if err != nil { + return err + } + defer d.Close() + return d.Sync() +} + func newEconomyRecordID() string { var b [8]byte if _, err := rand.Read(b[:]); err != nil { @@ -159,6 +177,12 @@ func (j *economyJournal) append(rec economyRecord) error { if err := f.Sync(); err != nil { return fmt.Errorf("sync economy journal: %w", err) } + if !j.dirSynced { + if err := syncDir(filepath.Dir(j.path)); err != nil { + return fmt.Errorf("sync economy journal dir: %w", err) + } + j.dirSynced = true + } return nil } @@ -497,11 +521,12 @@ func (j *economyJournal) loadOpen() ([]*economyRecord, error) { return open, nil } -// foldedRecord is the latest state of one record ID together with the -// order (line position of its latest write) it occupied in the journal. +// foldedRecord is the latest state of one record ID. Ordering decisions +// (which record came first) are made from economyRecord.Seq — the record's +// creation order — never from the record's line position, which a later +// rewrite of the same record moves. type foldedRecord struct { rec economyRecord - seq int } // loadFolded folds the journal (latest line per record ID wins) and @@ -556,9 +581,8 @@ func (j *economyJournal) loadFolded() ([]foldedRecord, error) { } if existing, ok := latest[rec.ID]; ok { existing.rec = rec - existing.seq = seq } else { - fr := &foldedRecord{rec: rec, seq: seq} + fr := &foldedRecord{rec: rec} latest[rec.ID] = fr order = append(order, fr) } @@ -617,9 +641,14 @@ func (j *economyJournal) compact() { } f.Close() // Best-effort atomic-ish replacement; a torn compaction leaves the - // original file intact. + // original file intact. The rename is made durable like the file's own + // sync, so a crash cannot lose the compacted journal entirely. if err := os.Rename(tmp, j.path); err != nil { os.Remove(tmp) + return + } + if err := syncDir(filepath.Dir(j.path)); err != nil { + fmt.Fprintf(os.Stderr, "economy compaction: sync journal dir: %v\n", err) } } @@ -698,22 +727,19 @@ func (g *Game) grantCredits(p *player.Player, amount int) { sum, err := addCreditsChecked(p.Credits, amount) if err != nil { fmt.Fprintf(os.Stderr, "credit grant for %s clamped at the economy cap: %v\n", p.Name, err) - p.Credits = maxCreditsValue + p.Credits = casino.MaxCredits return } p.Credits = sum } -// maxCreditsValue mirrors casino.MaxCredits; kept local to avoid a -// dependency cycle (see addCreditsChecked). -const maxCreditsValue = 1_000_000_000_000 - -// addCreditsChecked is the game-side checked addition against the economy -// cap (mirrors casino.MaxCredits; kept local to avoid a dependency cycle). +// addCreditsChecked is the game-side checked addition against the shared +// economy cap (casino.MaxCredits, mirrored by the casino wallet: a second, +// independently drifting cap would let the two sides of the money path +// disagree about what a balance may hold). 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 && a > casino.MaxCredits-b { + return casino.MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, casino.MaxCredits) } if b < 0 { return 0, fmt.Errorf("negative credit add %d", b) @@ -907,9 +933,16 @@ func (g *Game) recoverEconomy() { // 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. - stakeEvidence := func(rec *economyRecord, seq int) (evidence bool, settlement bool) { + stakeEvidence := func(rec *economyRecord) (evidence bool, settlement bool) { for _, fr := range folded { - if fr.rec.Kind != "payout" || fr.rec.Status == "void" || fr.seq <= seq || + // Ordering is by the record's CREATION Seq, not by the line of + // its latest write: a payout created before this stake is not + // its settlement even when a later rewrite of that payout + // (a settle marker, or a recovery checkpoint re-anchor) lands + // after the stake's line. Line order would let an old payout + // mask a later stake and silently erase its refund (see + // stakeCovered, which orders the same way). + if fr.rec.Kind != "payout" || fr.rec.Status == "void" || fr.rec.Seq <= rec.Seq || !payoutIdentifies(fr.rec, *rec) { continue } @@ -1005,8 +1038,18 @@ func (g *Game) recoverEconomy() { // 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. + // The re-anchored post-state must respect the cap: when it + // would exceed it, no reachable balance can pay the record, + // so the current balance is recorded as both checkpoints — + // the record then keeps reading as owed at this balance + // (retrying the re-application) instead of claiming an + // impossible post-state that a coincidental balance could + // be mistaken for as this record's own durability. other.CreditsBefore = p.Credits - other.CreditsAfter = p.Credits + other.Credit + other.CreditsAfter = p.Credits + if after, err := addCreditsChecked(p.Credits, other.Credit); err == nil { + other.CreditsAfter = after + } settled[other.ID] = true // handled for the rest of this run _ = g.Economy.append(*other) continue @@ -1110,7 +1153,7 @@ func (g *Game) recoverEconomy() { compensated++ continue } - resolvedByPayout, resolvedBySettlement := stakeEvidence(rec, folded[openWagers[k]].seq) + resolvedByPayout, resolvedBySettlement := stakeEvidence(rec) // Checkpoint comparisons use the DURABLE state, captured before any // in-memory chip restoration below. creditsNow, chipsNow := p.Credits, countChips(p) |
