diff options
| author | historia <[not public]> | 2026-09-18 01:50:17 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 01:50:17 -0400 |
| commit | 87d08f0151e643951769e323995f1c5ed89ee200 (patch) | |
| tree | dc42a0b30ebb5a00dd7ba505cc4eaa44f4f308bc /internal/game/economy.go | |
| parent | 32a3c59cebf2a16277e54d715a67adfdbe00b456 (diff) | |
| download | thehouseoficarus-87d08f0151e643951769e323995f1c5ed89ee200.tar.gz | |
fix: harden economy recovery, shutdown concurrency, world validation, and inventory-loss paths
Diffstat (limited to 'internal/game/economy.go')
| -rw-r--r-- | internal/game/economy.go | 40 |
1 files changed, 34 insertions, 6 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go index 21e7721..b39dbe4 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -69,6 +69,10 @@ type economyRecord struct { Credit int `json:"credits"` Status string `json:"status"` // "open", "settled", "void" TS time.Time `json:"ts"` + // Seq is the record's monotonic creation order within the journal. It + // lets coverage checks distinguish a settlement that predates a stake + // (and therefore cannot return it) from one written after it. + Seq int64 `json:"seq,omitempty"` // Refund marks a payout record as a SHUTDOWN/EVICTION REFUND (the // stake being returned), not a game settlement. Recovery must not // treat the chip part of a refund as paid in credits: a refund's chip @@ -117,6 +121,9 @@ type economyJournal struct { // (stakeCovered). Same ownership discipline as stakes: all mutations // happen on the game owner or during startup recovery. openPayoutRecs map[string][]*economyRecord + // seq is the monotonic record counter (see economyRecord.Seq). It is + // advanced by startKind and raised to the journal maximum on every fold. + seq int64 } func newEconomyJournal(dataDir string) *economyJournal { @@ -198,6 +205,8 @@ func (j *economyJournal) startKind(kind string, refund bool, covers []string, p } // Retry a bounded number of times: a transient append failure must not // drop the record that makes the wager or owed payout durable. + j.seq++ + rec.Seq = j.seq if err := j.appendRetry(*rec, 5); err != nil { return nil, err } @@ -225,12 +234,16 @@ func (j *economyJournal) closePayoutTracking(rec *economyRecord) { } } -// settle marks a record settled (the balance mutation is durable). -// Fire-and-forget: callers whose recovery semantics depend on the marker -// being durable use settleDurable instead. +// settle marks a record settled (the balance mutation is durable). The +// marker is retried and the record is left OPEN (with its tracking index +// intact) if no append succeeds: a non-durable settle must never drop the +// evidence a later recovery needs, or the liability is paid twice. func (j *economyJournal) settle(rec *economyRecord) { rec.Status = "settled" - _ = j.append(*rec) + if err := j.appendRetry(*rec, 3); err != nil { + rec.Status = "open" + return + } j.closePayoutTracking(rec) } @@ -249,10 +262,15 @@ func (j *economyJournal) settleDurable(rec *economyRecord) bool { } // void marks a record void (the mutation was reverted in memory and never -// became durable; recovery must not compensate). +// became durable; recovery must not compensate). Like settle, the marker is +// retried and the record is left open on failure so its index/evidence is +// preserved for a later attempt. func (j *economyJournal) void(rec *economyRecord) { rec.Status = "void" - _ = j.append(*rec) + if err := j.appendRetry(*rec, 3); err != nil { + rec.Status = "open" + return + } j.closePayoutTracking(rec) } @@ -344,6 +362,13 @@ func (j *economyJournal) stakeCovered(stake *economyRecord) bool { if payout.Refund { continue } + // A payout written BEFORE the stake cannot be its settlement: without + // this ordering check an old open payout (for example a failed slots + // win) would mask a later, unrelated stake and RefundOutstanding + // would resolve it without returning the money. + if payout.Seq <= stake.Seq { + continue + } if len(payout.Covers) == 0 || containsString(payout.Covers, stake.ID) { return true } @@ -526,6 +551,9 @@ func (j *economyJournal) loadFolded() ([]foldedRecord, error) { if corruptPending { return nil, fmt.Errorf("economy journal line %d is malformed; later records exist and may be owed money (journal preserved for inspection)", corruptSeq) } + if rec.Seq > j.seq { + j.seq = rec.Seq + } if existing, ok := latest[rec.ID]; ok { existing.rec = rec existing.seq = seq |
