aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 01:50:17 -0400
committerhistoria <[not public]>2026-09-18 01:50:17 -0400
commit87d08f0151e643951769e323995f1c5ed89ee200 (patch)
treedc42a0b30ebb5a00dd7ba505cc4eaa44f4f308bc /internal/game/economy.go
parent32a3c59cebf2a16277e54d715a67adfdbe00b456 (diff)
downloadthehouseoficarus-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.go40
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