aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 05:31:09 -0400
committerhistoria <[not public]>2026-09-18 05:31:09 -0400
commit153acb7f644d755c45533b7c5311e3ffa47e8973 (patch)
tree7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/game/economy.go
parent87d08f0151e643951769e323995f1c5ed89ee200 (diff)
downloadthehouseoficarus-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.go83
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)