aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 17:48:26 -0400
committerhistoria <[not public]>2026-09-18 17:48:26 -0400
commit8fe9e3c3fef0049df82281a62afe35dda516dfe2 (patch)
treedd269f2c0710f334e19f00a191260ebc46284a90 /internal/game/economy.go
parent194164a77eb4f2479b94c19e6f0e1278ab608781 (diff)
downloadthehouseoficarus-8fe9e3c3fef0049df82281a62afe35dda516dfe2.tar.gz
fix: close combat ghost, admin traversal, login throttle, journal double-pay, and quality/economy gaps
Diffstat (limited to 'internal/game/economy.go')
-rw-r--r--internal/game/economy.go74
1 files changed, 69 insertions, 5 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go
index 0b9e92f..9853671 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -184,15 +184,39 @@ func (j *economyJournal) append(rec economyRecord) error {
return fmt.Errorf("open economy journal: %w", err)
}
defer f.Close()
+ // Record the pre-write size so a failed write/sync rolls the record back.
+ // "append failed" must mean "no record bytes remain": the wallet's
+ // journal-unavailable fallback commits the payout through the character
+ // file only, and a later recovery must not see the same payout record as
+ // still owed (that double-pays). It also keeps a short write from merging
+ // into the next append as a malformed mid-file line.
+ startSize := int64(-1)
+ if fi, statErr := f.Stat(); statErr == nil {
+ startSize = fi.Size()
+ }
+ rollback := func(cause error) error {
+ if startSize >= 0 {
+ if terr := f.Truncate(startSize); terr != nil {
+ fmt.Fprintf(os.Stderr, "economy journal: rollback truncate after %v: %v\n", cause, terr)
+ } else {
+ _ = f.Sync()
+ }
+ }
+ return cause
+ }
if _, err := f.Write(append(data, '\n')); err != nil {
- return fmt.Errorf("append economy journal: %w", err)
+ return rollback(fmt.Errorf("append economy journal: %w", err))
}
if err := f.Sync(); err != nil {
- return fmt.Errorf("sync economy journal: %w", err)
+ return rollback(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)
+ // The record bytes are in the file but the directory entry is not
+ // guaranteed durable. Roll the record back so the caller's failure
+ // is truthful, and leave dirSynced false so a later append retries
+ // the directory sync.
+ return rollback(fmt.Errorf("sync economy journal dir: %w", err))
}
j.dirSynced = true
}
@@ -1043,6 +1067,10 @@ func (g *Game) recoverEconomy() {
return evidence, settlement
}
compensated := 0
+ // skipCompact is set when a recovery-time append that a later run depends
+ // on could not be made durable; compaction must then be skipped so the
+ // evidence it would erase is preserved.
+ skipCompact := false
// Phase 1 — payouts, in append order. Records with identical
// checkpoints are decided as one group: k sibling records all journaled
@@ -1097,6 +1125,19 @@ func (g *Game) recoverEconomy() {
// settled; a save failure (e.g. the credit cap) leaves the unpaid
// remainder open for operator inspection — a liability is never erased.
reapplyGroup := func(group []*economyRecord, p *player.Player, credit int) {
+ // A mixed tracked/untracked group can contain a record already settled
+ // earlier in this pass; paying it again would create credits. Filter
+ // to the still-unsettled members.
+ active := make([]*economyRecord, 0, len(group))
+ for _, rec := range group {
+ if !settled[rec.ID] {
+ active = append(active, rec)
+ }
+ }
+ group = active
+ if len(group) == 0 {
+ return
+ }
paid := 0
capBlocked := false
for m := 0; m < len(group); m++ {
@@ -1130,7 +1171,13 @@ func (g *Game) recoverEconomy() {
// and settles the remainder as "already durable").
reanchorOwedPayout(other, p.Credits, other.Credit)
settled[other.ID] = true // handled for the rest of this run
- _ = g.Economy.append(*other)
+ if err := g.Economy.appendRetry(*other, 3); err != nil {
+ // The re-anchor is the evidence the next run needs to avoid
+ // silently settling this liability. If it is not durable,
+ // skip compaction so the settled sibling evidence survives.
+ fmt.Fprintf(os.Stderr, "economy recovery: re-anchor append %s failed: %v — skipping compaction this run\n", other.ID, err)
+ skipCompact = true
+ }
continue
}
// A TRANSIENT save failure blocked the FIRST re-application
@@ -1323,11 +1370,28 @@ func (g *Game) recoverEconomy() {
g.Economy.settle(rec)
continue
}
+ // The chips were returned in kind and persisted; clear the
+ // record's chip obligation durably so a later session refund
+ // (RefundOutstanding) cannot return the same chips again. The
+ // record stays open for the credit side.
+ if chipsOwed {
+ rec.Chips = 0
+ rec.ChipsBefore = chipsNow
+ rec.ChipsAfter = chipsNow
+ if err := g.Economy.appendRetry(*rec, 3); err != nil {
+ fmt.Fprintf(os.Stderr, "economy recovery: chip-obligation clear %s failed: %v\n", rec.ID, err)
+ skipCompact = true
+ }
+ }
fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n",
rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter)
}
}
- g.Economy.compact()
+ if skipCompact {
+ fmt.Fprintln(os.Stderr, "economy recovery: compaction skipped to preserve payout evidence")
+ } else {
+ g.Economy.compact()
+ }
// Final evidence prune: the phases above settled or voided records, so
// evidence IDs that no longer correspond to an open record are stale
// (a settled stake's resolution evidence, an applied payout's entry).