diff options
| author | historia <[not public]> | 2026-09-16 14:09:22 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-16 14:09:22 -0400 |
| commit | 2f34e75f27e5f99cfc1c5f8eaf6ab4ff2fddb1d9 (patch) | |
| tree | c50388fe639286bda1b1999ca6c2c972d03eb3a5 /internal/game/economy.go | |
| parent | c054b27f628f23fecc505b9e09e78721738f3c4e (diff) | |
| download | thehouseoficarus-2f34e75f27e5f99cfc1c5f8eaf6ab4ff2fddb1d9.tar.gz | |
fix: reserved-loot pickup, credit pickup, payout recovery, and slot stake/bonus shutdown handling
Diffstat (limited to 'internal/game/economy.go')
| -rw-r--r-- | internal/game/economy.go | 137 |
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) |
