aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/economy.go')
-rw-r--r--internal/game/economy.go137
1 files changed, 89 insertions, 48 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go
index be3f5ef..3d48605 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -420,8 +420,12 @@ func addCreditsChecked(a, b int) (int, error) {
// 1. Open PAYOUT records are processed in append order against the evolving
// durable balance: file at the pre-payout balance → the payout is
// re-applied (the player is owed it); at the post-payout balance → it is
-// already durable and settles; anything else stays open and is surfaced
-// for operator inspection (a liability is never silently dropped).
+// already durable and settles — UNLESS an earlier recovery run (or the
+// original settlement) already settled a same-checkpoint sibling, in
+// which case the balance is explained by THAT record and this open
+// record is still owed and re-applied; anything else stays open and is
+// surfaced for operator inspection (a liability is never silently
+// dropped).
// 2. Open WAGER records are processed NEWEST FIRST (each refund restores
// the balance an earlier record's checkpoints refer to). A record at
// its pre-debit state (credits AND chips) never became durable → void;
@@ -541,6 +545,75 @@ func (g *Game) recoverEconomy() {
}
}
settled := make(map[string]bool)
+ // settledSiblings counts SETTLED payout records (from earlier recovery
+ // runs; visible in the folded journal) with the same signature as rec.
+ // Each one already explains one durable credit unit of the current
+ // balance: a record moved the balance from `before` to `after` and was
+ // settled, so any other open record with the SAME checkpoints is still
+ // owed — it must never be settled as "already durable" because the
+ // balance happens to sit at `after`.
+ settledSiblings := func(sig payoutSig, selfID string) int {
+ n := 0
+ for j := range folded {
+ other := &folded[j].rec
+ if other.ID == selfID || other.Kind != "payout" || other.Status != "settled" {
+ continue
+ }
+ if other.key() == sig.player && other.CreditsBefore == sig.before &&
+ other.CreditsAfter == sig.after && other.Credit == sig.credit {
+ n++
+ }
+ }
+ return n
+ }
+ // reapplyGroup re-applies every identical open sibling: they were all
+ // journaled against the same in-memory balance and are all owed. Only
+ // the siblings whose re-application actually became durable are
+ // 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) {
+ paid := 0
+ capBlocked := false
+ for m := 0; m < len(group); m++ {
+ if _, err := addCreditsChecked(p.Credits, credit); err != nil {
+ capBlocked = true
+ break
+ }
+ if err := g.payCreditsDurable(p, credit); err != nil {
+ fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err)
+ break
+ }
+ paid++
+ compensated++
+ }
+ for idx, other := range group {
+ if idx < paid {
+ g.Economy.settle(other)
+ settled[other.ID] = true
+ continue
+ }
+ if capBlocked {
+ // The credit cap blocked re-application. Re-anchor the
+ // record's checkpoints to the current durable balance so
+ // a later recovery re-applies it against a balance it can
+ // 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.
+ other.CreditsBefore = p.Credits
+ other.CreditsAfter = p.Credits + other.Credit
+ settled[other.ID] = true // handled for the rest of this run
+ _ = g.Economy.append(*other)
+ continue
+ }
+ // A TRANSIENT save failure blocked re-application and the
+ // balance still has room below the cap: keep the record open
+ // with its ORIGINAL checkpoints. Re-anchoring here would make
+ // the record unreadable against the unchanged durable balance
+ // (before-checkpoint mismatch) and permanently ambiguous; the
+ // original checkpoints let a later recovery retry cleanly.
+ settled[other.ID] = true // handled for the rest of this run
+ }
+ }
for i := range folded {
rec := &folded[i].rec
if rec.Kind != "payout" || rec.Status != "open" || settled[rec.ID] {
@@ -555,54 +628,22 @@ func (g *Game) recoverEconomy() {
switch {
case p.Credits == rec.CreditsBefore:
// The payout (and every identical sibling) was lost in the
- // crash: the player is owed them all. Only the siblings whose
- // re-application actually became durable are settled; a save
- // failure (e.g. the credit cap) leaves the unpaid remainder open
- // for operator inspection — a liability is never erased.
- paid := 0
- capBlocked := false
- for m := 0; m < len(group); m++ {
- if _, err := addCreditsChecked(p.Credits, rec.Credit); err != nil {
- capBlocked = true
- break
- }
- if err := g.payCreditsDurable(p, rec.Credit); err != nil {
- fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err)
- break
- }
- paid++
- compensated++
- }
- for idx, other := range group {
- if idx < paid {
- g.Economy.settle(other)
- settled[other.ID] = true
- continue
- }
- if capBlocked {
- // The credit cap blocked re-application. Re-anchor the
- // record's checkpoints to the current durable balance so
- // a later recovery re-applies it against a balance it can
- // 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.
- other.CreditsBefore = p.Credits
- other.CreditsAfter = p.Credits + other.Credit
- settled[other.ID] = true // handled for the rest of this run
- _ = g.Economy.append(*other)
- continue
- }
- // A TRANSIENT save failure blocked re-application and the
- // balance still has room below the cap: keep the record open
- // with its ORIGINAL checkpoints. Re-anchoring here would make
- // the record unreadable against the unchanged durable balance
- // (before-checkpoint mismatch) and permanently ambiguous; the
- // original checkpoints let a later recovery retry cleanly.
- settled[other.ID] = true // handled for the rest of this run
- }
- case p.Credits == rec.CreditsAfter && len(group) == 1:
+ // crash: the player is owed them all.
+ reapplyGroup(group, p, rec.Credit)
+ case p.Credits == rec.CreditsAfter && len(group) == 1 && settledSiblings(sig, rec.ID) == 0:
+ // The only record with these checkpoints sits at its
+ // post-payout balance: it is already durable, settle it.
g.Economy.settle(rec)
settled[rec.ID] = true
+ case p.Credits == rec.CreditsAfter && settledSiblings(sig, rec.ID) > 0:
+ // A SETTLED sibling with identical checkpoints explains the
+ // balance: an earlier recovery run already re-applied (and
+ // settled) it, or its original settlement moved the balance
+ // here. This open record is still OWED — settling it as
+ // "already durable" because the balance reads `after` would
+ // silently drop the liability (see the
+ // partially-paid-identical-siblings regression).
+ reapplyGroup(group, p, rec.Credit)
default:
fmt.Fprintf(os.Stderr, "economy recovery: ambiguous payout %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)