diff options
| author | historia <[not public]> | 2026-09-18 17:48:26 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 17:48:26 -0400 |
| commit | 8fe9e3c3fef0049df82281a62afe35dda516dfe2 (patch) | |
| tree | dd269f2c0710f334e19f00a191260ebc46284a90 /internal/game/economy.go | |
| parent | 194164a77eb4f2479b94c19e6f0e1278ab608781 (diff) | |
| download | thehouseoficarus-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.go | 74 |
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). |
