diff options
| author | historia <[not public]> | 2026-09-16 14:41:15 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-16 14:41:15 -0400 |
| commit | 9192d94dea6be5498ab080f3e2df5667af871d7a (patch) | |
| tree | e7aef3089d5d1e8da686c6b6ca9d4faeacb8b20b /internal/game/economy.go | |
| parent | 2f34e75f27e5f99cfc1c5f8eaf6ab4ff2fddb1d9 (diff) | |
| download | thehouseoficarus-9192d94dea6be5498ab080f3e2df5667af871d7a.tar.gz | |
fix: casino room eviction, session close race fix, open-payout recovery evidence
Diffstat (limited to 'internal/game/economy.go')
| -rw-r--r-- | internal/game/economy.go | 181 |
1 files changed, 176 insertions, 5 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go index 3d48605..0d5f08f 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -13,7 +13,14 @@ package game // state was lost in the crash, so the stake returns to the player. // - "payout": money movement (winnings, pushes, refunds). The record // closes when the balance mutation is durable; an open payout at -// startup is owed money and is re-applied. +// startup is owed money and is re-applied. Durability is proven by +// the character file itself, not by balance checkpoints: the same +// save that includes the payout in the balance records the record's +// ID in the character's open_payouts evidence list, and the entry is +// dropped once the settled marker is durable. Checkpoints alone are +// ambiguous — an unrelated credit change (shop sale, bank deposit) +// can coincidentally land on the record's CreditsAfter balance, and +// settling on that coincidence silently erases an owed payout. // // A crash at any point therefore leaves either: // - an open record with the character file at its pre-transaction state @@ -70,6 +77,13 @@ type economyRecord struct { // the round resolved, closing the stake without any chip return. Refund bool `json:"refund,omitempty"` + // Tracked marks a payout written under the character-file evidence + // scheme: the record's ID is recorded in the character's open_payouts + // list by the same save that includes its amount in the balance, and + // recovery uses that membership — not balance checkpoints — to decide + // whether the payout became durable. + Tracked bool `json:"tracked,omitempty"` + // Balance checkpoints used to decide recovery compensation. CreditsBefore int `json:"credits_before"` CreditsAfter int `json:"credits_after"` @@ -84,12 +98,17 @@ type economyJournal struct { // All journal mutations happen on the game owner (or during startup // recovery before any session connects), so no lock is needed. stakes map[string][]*economyRecord + // openPayouts indexes payout records that are still open (journaled but + // not provably durable). It drives evidence pruning: a character-file + // ID absent from this set is settled or void and must not be trusted as + // durability evidence. + openPayouts map[string]bool } func newEconomyJournal(dataDir string) *economyJournal { dir := filepath.Join(dataDir, "economy") _ = os.MkdirAll(dir, 0700) - return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord)} + return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool)} } func newEconomyRecordID() string { @@ -153,6 +172,7 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c PlayerID: p.ID, Kind: kind, Refund: refund, + Tracked: kind == "payout", Chips: chips, Credit: credits, Status: "open", @@ -164,13 +184,33 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c if err := j.append(*rec); err != nil { return nil, err } + if rec.Tracked { + j.openPayouts[rec.ID] = true + } return rec, nil } // 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. func (j *economyJournal) settle(rec *economyRecord) { rec.Status = "settled" _ = j.append(*rec) + delete(j.openPayouts, rec.ID) +} + +// settleDurable marks a record settled and persists the marker with retries, +// reporting whether the marker became durable. A false return leaves the +// record open in the journal: callers keeping file-side evidence for this +// record must NOT drop that evidence until this returns true. +func (j *economyJournal) settleDurable(rec *economyRecord) bool { + rec.Status = "settled" + if err := j.appendRetry(*rec, 3); err != nil { + rec.Status = "open" + return false + } + delete(j.openPayouts, rec.ID) + return true } // void marks a record void (the mutation was reverted in memory and never @@ -178,6 +218,7 @@ func (j *economyJournal) settle(rec *economyRecord) { func (j *economyJournal) void(rec *economyRecord) { rec.Status = "void" _ = j.append(*rec) + delete(j.openPayouts, rec.ID) } // registerStake tracks an outstanding wager record for its player. Wager @@ -257,11 +298,15 @@ func payoutIdentifies(a, b economyRecord) bool { // recovery compaction. func (j *economyJournal) reloadStakes() { j.stakes = make(map[string][]*economyRecord) + j.openPayouts = make(map[string]bool) for _, fr := range j.loadFolded() { if fr.rec.Kind == "wager" && fr.rec.Status == "open" { rec := fr.rec j.registerStake(&rec) } + if fr.rec.Kind == "payout" && fr.rec.Status == "open" { + j.openPayouts[fr.rec.ID] = true + } } } @@ -399,6 +444,30 @@ func (g *Game) payCreditsDurable(p *player.Player, amount int) error { return nil } +// applyOpenPayout re-applies an open TRACKED payout record during recovery: +// checked credit increase plus a character save that records the record ID +// in the file's open_payouts evidence list. A failed save reverts the +// in-memory mutation (credits and evidence) so the record stays open and a +// later recovery attempt retries cleanly. +func (g *Game) applyOpenPayout(p *player.Player, rec *economyRecord) error { + if rec.Credit < 0 { + return fmt.Errorf("negative payout %d", rec.Credit) + } + sum, err := addCreditsChecked(p.Credits, rec.Credit) + if err != nil { + return err + } + before := p.Credits + p.Credits = sum + p.OpenPayouts = append(p.OpenPayouts, rec.ID) + if err := g.AccountStore.SaveCharacter(p); err != nil { + p.Credits = before + removeOpenPayout(p, rec.ID) + return err + } + return nil +} + // addCreditsChecked is the game-side checked addition against the economy // cap (mirrors casino.MaxCredits; kept local to avoid a dependency cycle). func addCreditsChecked(a, b int) (int, error) { @@ -412,6 +481,54 @@ func addCreditsChecked(a, b int) (int, error) { return a + b, nil } +// openPayoutRecorded reports whether the character's durable evidence list +// claims rec's amount is included in the balance. +func openPayoutRecorded(p *player.Player, id string) bool { + for _, existing := range p.OpenPayouts { + if existing == id { + return true + } + } + return false +} + +// removeOpenPayout drops id from the in-memory evidence list; the on-disk +// copy is corrected by the next character save. +func removeOpenPayout(p *player.Player, id string) { + kept := p.OpenPayouts[:0] + for _, existing := range p.OpenPayouts { + if existing != id { + kept = append(kept, existing) + } + } + p.OpenPayouts = kept +} + +// pruneOpenPayouts drops evidence IDs that no longer correspond to an open +// payout record (settled or void). It reports whether anything changed, so +// callers only pay for a save when the file would actually differ. +func pruneOpenPayouts(j *economyJournal, p *player.Player) bool { + if j == nil || len(p.OpenPayouts) == 0 { + return false + } + if j.openPayouts == nil { + j.openPayouts = make(map[string]bool) + } + kept := p.OpenPayouts[:0] + changed := false + for _, id := range p.OpenPayouts { + if j.openPayouts[id] { + kept = append(kept, id) + } else { + changed = true + } + } + if changed { + p.OpenPayouts = kept + } + return changed +} + // recoverEconomy compensates open records from the last crash. It is called // once from New, before any session connects. // @@ -439,6 +556,19 @@ func (g *Game) recoverEconomy() { return } folded := g.Economy.loadFolded() + if g.Economy.openPayouts == nil { + g.Economy.openPayouts = make(map[string]bool) + } else { + for id := range g.Economy.openPayouts { + delete(g.Economy.openPayouts, id) + } + } + for i := range folded { + rec := &folded[i].rec + if rec.Kind == "payout" && rec.Status == "open" { + g.Economy.openPayouts[rec.ID] = true + } + } // Player records resolve by the STABLE character ID (a rename must // never strand an owed payout); the name lookup remains for legacy // records written before IDs existed. Every character file is loaded @@ -455,6 +585,18 @@ func (g *Game) recoverEconomy() { } } } + // finalizeLoaded prunes stale open-payout evidence the first time a + // character file is touched: IDs the journal shows as settled or void + // cannot prove anything and are dropped (with a durable save) so the + // list stays small and meaningful. + finalizeLoaded := func(p *player.Player) *player.Player { + if pruneOpenPayouts(g.Economy, p) { + if err := g.AccountStore.SaveCharacter(p); err != nil { + fmt.Fprintf(os.Stderr, "economy recovery: evidence prune save %s: %v\n", p.Name, err) + } + } + return p + } loadPlayer := func(rec *economyRecord) *player.Player { if rec.PlayerID != "" { // A record carrying a stable ID resolves to THAT identity only. @@ -463,7 +605,7 @@ func (g *Game) recoverEconomy() { // character's payout and the original liability would be // settled as paid. See TestRecoveryDoesNotPayReusedCharacterName. if p, ok := playersByID[rec.PlayerID]; ok { - return p + return finalizeLoaded(p) } // A character file added since the index was built: load every // file once and match by stable ID. @@ -472,7 +614,7 @@ func (g *Game) recoverEconomy() { if p, err := g.AccountStore.LoadCharacter(name); err == nil && p.ID == rec.PlayerID { playersByID[p.ID] = p playersByName[strings.ToLower(p.Name)] = p - return p + return finalizeLoaded(p) } } } @@ -486,7 +628,7 @@ func (g *Game) recoverEconomy() { // name is the only identity available. if rec.Player != "" { if p, ok := playersByName[strings.ToLower(rec.Player)]; ok { - return p + return finalizeLoaded(p) } } fmt.Fprintf(os.Stderr, "economy recovery: load %s: character not found\n", rec.Player) @@ -623,6 +765,35 @@ func (g *Game) recoverEconomy() { if p == nil { continue } + if rec.Tracked { + // Tracked records carry durable evidence in the character file + // itself: the same save that includes the amount in the balance + // records the record ID in open_payouts. Membership decides. + // Balance checkpoints are NEVER consulted for tracked records: + // unrelated credit changes (shop sales, bank deposits, other + // games) can coincidentally land on CreditsAfter, and settling + // on that coincidence silently erases an owed payout. + if openPayoutRecorded(p, rec.ID) { + // The save landed; the amount is already in the balance. + g.Economy.settle(rec) + settled[rec.ID] = true + continue + } + if err := g.applyOpenPayout(p, rec); err != nil { + // Cap-blocked or transiently failed: the record stays open + // (no evidence entry was added) and a later recovery + // retries. A liability is never silently dropped. + fmt.Fprintf(os.Stderr, "economy recovery: tracked payout %s for %s not re-applied (%v) — kept open for operator inspection\n", + rec.ID, rec.Player, err) + } else { + if g.Economy.settleDurable(rec) { + removeOpenPayout(p, rec.ID) + } + compensated++ + } + settled[rec.ID] = true + continue + } sig := payoutSig{rec.key(), rec.CreditsBefore, rec.CreditsAfter, rec.Credit} group := siblings[sig] switch { |
