diff options
| author | historia <[not public]> | 2026-09-16 19:57:45 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-16 19:57:45 -0400 |
| commit | 6ef40a5739f1723ec26cc32dfc48fe764f776dca (patch) | |
| tree | e215a4dc96ea639bcea07bb589aae3e31c3609bb /internal/game/economy.go | |
| parent | 1dad15698845d370e998f7cc5feb8aece3fab58f (diff) | |
| download | thehouseoficarus-6ef40a5739f1723ec26cc32dfc48fe764f776dca.tar.gz | |
fix: durable payouts survive journal failure, cap bank withdrawals, hash passwords off the owner loop.
Diffstat (limited to 'internal/game/economy.go')
| -rw-r--r-- | internal/game/economy.go | 215 |
1 files changed, 190 insertions, 25 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go index 0d5f08f..fc1b289 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -181,7 +181,9 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c ChipsBefore: chipsBefore, ChipsAfter: chipsAfter, } - if err := j.append(*rec); err != nil { + // Retry a bounded number of times: a transient append failure must not + // drop the record that makes the wager or owed payout durable. + if err := j.appendRetry(*rec, 5); err != nil { return nil, err } if rec.Tracked { @@ -240,7 +242,13 @@ func (j *economyJournal) openStakes(player string) []*economyRecord { // payout evidence, or — if the round was a loss with no settlement // payout — refunds it under the documented refund-on-crash policy. A // stake is only forgotten once its marker is durable. -func (j *economyJournal) ResolveStakes(player string) { +// +// It returns the records whose settled marker could NOT be made durable. +// Callers holding the player should record those IDs as resolved-stake +// evidence (player.Player.ResolvedStakes) so recovery still honors the +// resolution even though the journal could not persist its marker. +func (j *economyJournal) ResolveStakes(player string) []*economyRecord { + var unresolved []*economyRecord remaining := j.stakes[player][:0] for _, rec := range j.stakes[player] { rec.Status = "settled" @@ -249,6 +257,7 @@ func (j *economyJournal) ResolveStakes(player string) { // (or a later attempt) still resolves it. rec.Status = "open" remaining = append(remaining, rec) + unresolved = append(unresolved, rec) } } if len(remaining) == 0 { @@ -256,6 +265,7 @@ func (j *economyJournal) ResolveStakes(player string) { } else { j.stakes[player] = remaining } + return unresolved } // economyKey is the identity a player's journal records are indexed by: @@ -275,6 +285,41 @@ func (rec *economyRecord) key() string { return rec.Player } +// resolvedStakeRecorded reports whether the character's durable evidence +// list claims rec's stake was resolved by the session (its settled marker +// could not be journaled; see player.Player.ResolvedStakes). +func resolvedStakeRecorded(p *player.Player, id string) bool { + for _, existing := range p.ResolvedStakes { + if existing == id { + return true + } + } + return false +} + +// pruneResolvedStakes drops resolved-stake evidence IDs that no longer +// correspond to an open wager record (settled or void). It reports whether +// anything changed, so callers only pay for a save when the file would +// actually differ. +func pruneResolvedStakes(openStakeIDs map[string]bool, p *player.Player) bool { + if len(p.ResolvedStakes) == 0 { + return false + } + kept := p.ResolvedStakes[:0] + changed := false + for _, id := range p.ResolvedStakes { + if openStakeIDs[id] { + kept = append(kept, id) + } else { + changed = true + } + } + if changed { + p.ResolvedStakes = kept + } + return changed +} + // payoutIdentifies reports whether two journal records belong to the same // character identity. ID-carrying records match only on the stable ID: a // different character that reused the display name must never supply (or @@ -295,11 +340,16 @@ func payoutIdentifies(a, b economyRecord) bool { // reloadStakes rebuilds the outstanding-stake index from the journal file: // open wager records remain tracked (and refundable/resolvable) after -// recovery compaction. -func (j *economyJournal) reloadStakes() { +// recovery compaction. A read failure leaves the previous indexes intact +// (never a wiped index over a live journal); the caller logs loudly. +func (j *economyJournal) reloadStakes() error { + folded, err := j.loadFolded() + if err != nil { + return err + } j.stakes = make(map[string][]*economyRecord) j.openPayouts = make(map[string]bool) - for _, fr := range j.loadFolded() { + for _, fr := range folded { if fr.rec.Kind == "wager" && fr.rec.Status == "open" { rec := fr.rec j.registerStake(&rec) @@ -308,18 +358,23 @@ func (j *economyJournal) reloadStakes() { j.openPayouts[fr.rec.ID] = true } } + return nil } // loadOpen folds the journal and returns the still-open records. -func (j *economyJournal) loadOpen() []*economyRecord { +func (j *economyJournal) loadOpen() ([]*economyRecord, error) { var open []*economyRecord - for _, fr := range j.loadFolded() { + folded, err := j.loadFolded() + if err != nil { + return nil, err + } + for _, fr := range folded { if fr.rec.Status == "open" { rec := fr.rec open = append(open, &rec) } } - return open + return open, nil } // foldedRecord is the latest state of one record ID together with the @@ -330,11 +385,15 @@ type foldedRecord struct { } // loadFolded folds the journal (latest line per record ID wins) and -// returns the surviving records in journal append order. -func (j *economyJournal) loadFolded() []foldedRecord { +// returns the surviving records in journal append order. A read failure +// (oversized or unreadable region) is an ERROR, never a silent truncation: +// callers must not compact or compensate over a partially-read journal, +// because compaction would permanently erase every record beyond the +// unreadable point — including owed payouts and outstanding stakes. +func (j *economyJournal) loadFolded() ([]foldedRecord, error) { f, err := os.Open(j.path) if err != nil { - return nil + return nil, nil // no journal yet: nothing to fold } defer f.Close() latest := map[string]*foldedRecord{} @@ -362,16 +421,29 @@ func (j *economyJournal) loadFolded() []foldedRecord { order = append(order, fr) } } + if err := scanner.Err(); err != nil { + // A scanner error means the fold is INCOMPLETE (a line exceeded the + // buffer or the file became unreadable mid-scan). Returning a + // partial fold would let compaction erase valid records; surface + // the failure instead. + return nil, fmt.Errorf("scan economy journal: %w", err) + } folded := make([]foldedRecord, 0, len(order)) for _, fr := range order { folded = append(folded, *fr) } - return folded + return folded, nil } -// compact rewrites the journal keeping only still-open records. +// compact rewrites the journal keeping only still-open records. A fold +// failure aborts the rewrite: compacting over a journal that could not be +// fully read would silently erase every record beyond the unreadable point. func (j *economyJournal) compact() { - open := j.loadOpen() + open, err := j.loadOpen() + if err != nil { + fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err) + return + } tmp := j.path + ".compact" f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { @@ -468,6 +540,27 @@ func (g *Game) applyOpenPayout(p *player.Player, rec *economyRecord) error { return nil } +// grantCredits adds credits from ordinary gameplay (shop sales, credit +// pickups, activity payouts). The economy cap is enforced by clamping: the +// cap is far above any reachable balance, so hitting it means a data bug — +// clamp (never wrap) and log loudly for the operator. +func (g *Game) grantCredits(p *player.Player, amount int) { + if amount <= 0 { + return + } + sum, err := addCreditsChecked(p.Credits, amount) + if err != nil { + fmt.Fprintf(os.Stderr, "credit grant for %s clamped at the economy cap: %v\n", p.Name, err) + p.Credits = maxCreditsValue + return + } + p.Credits = sum +} + +// maxCreditsValue mirrors casino.MaxCredits; kept local to avoid a +// dependency cycle (see addCreditsChecked). +const maxCreditsValue = 1_000_000_000_000 + // 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) { @@ -555,7 +648,14 @@ func (g *Game) recoverEconomy() { if g.Economy == nil { return } - folded := g.Economy.loadFolded() + folded, err := g.Economy.loadFolded() + if err != nil { + // The journal could not be fully read. Compensation over a partial + // fold could double-pay or erase liabilities, and compaction would + // truncate the file. Preserve everything for operator inspection. + fmt.Fprintf(os.Stderr, "economy recovery aborted: %v (journal and character files preserved for inspection)\n", err) + return + } if g.Economy.openPayouts == nil { g.Economy.openPayouts = make(map[string]bool) } else { @@ -585,12 +685,21 @@ 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 prunes stale open-payout evidence and resolved-stake + // 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 lists stay small and meaningful. + openStakeIDs := make(map[string]bool) + for i := range folded { + rec := &folded[i].rec + if rec.Kind == "wager" && rec.Status == "open" { + openStakeIDs[rec.ID] = true + } + } finalizeLoaded := func(p *player.Player) *player.Player { - if pruneOpenPayouts(g.Economy, p) { + prunedPayouts := pruneOpenPayouts(g.Economy, p) + prunedStakes := pruneResolvedStakes(openStakeIDs, p) + if prunedPayouts || prunedStakes { if err := g.AccountStore.SaveCharacter(p); err != nil { fmt.Fprintf(os.Stderr, "economy recovery: evidence prune save %s: %v\n", p.Name, err) } @@ -830,11 +939,23 @@ func (g *Game) recoverEconomy() { } for k := len(openWagers) - 1; k >= 0; k-- { rec := &folded[openWagers[k]].rec - resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec, folded[openWagers[k]].seq) p := loadPlayer(rec) if p == nil { continue } + // File-side resolution evidence: the session resolved this stake + // but its settled marker could not be journaled (e.g. a broken + // economy path), so the character file carries the resolution ID. + // The round resolved — the stake must not be refunded, and its chip + // part must not return in kind on top of a settlement that already + // returned it as credits (a refund, by contrast, already gave the + // chips back in the file). + if resolvedStakeRecorded(p, rec.ID) { + g.Economy.settle(rec) + compensated++ + continue + } + resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec, folded[openWagers[k]].seq) // Checkpoint comparisons use the DURABLE state, captured before any // in-memory chip restoration below. creditsNow, chipsNow := p.Credits, countChips(p) @@ -887,8 +1008,19 @@ func (g *Game) recoverEconomy() { // The debit is durable but the round was lost in the crash: // refund the stake (approved refund-on-recovery policy). The // chips (if owed) are already restored in memory; one durable - // save covers both the chip return and the credit refund. - p.Credits += rec.Credit + // save covers both the chip return and the credit refund. The + // credit refund is checked: a record can never push the + // balance past the economy cap. + sum, capErr := addCreditsChecked(creditsNow, rec.Credit) + if capErr != nil { + if chipsOwed { + p.RemoveItem("chips", rec.Chips) + } + fmt.Fprintf(os.Stderr, "economy recovery: refund %s for %s blocked (%v) — kept open for operator inspection\n", + rec.ID, rec.Player, capErr) + continue + } + p.Credits = sum if err := g.AccountStore.SaveCharacter(p); err != nil { // Revert the in-memory refund so later records for this // player still compare against the durable balance; the @@ -918,7 +1050,40 @@ func (g *Game) recoverEconomy() { } } g.Economy.compact() + // Final evidence prune: the phases above settled or voided records, so + // evidence IDs that no longer correspond to an open record are stale + // (a settled stake's resolution evidence, an applied payout's entry). + // Drop them now so the character files stay small and meaningful; a + // failed save only defers the prune to the next startup (finalizeLoaded). + stillOpen := make(map[string]bool) + for i := range folded { + rec := &folded[i].rec + if rec.Kind == "wager" && rec.Status == "open" { + stillOpen[rec.ID] = true + } + } + pruneSeen := make(map[*player.Player]bool) + prunePlayer := func(p *player.Player) { + if p == nil || pruneSeen[p] { + return + } + pruneSeen[p] = true + if pruneOpenPayouts(g.Economy, p) || pruneResolvedStakes(stillOpen, p) { + if err := g.AccountStore.SaveCharacter(p); err != nil { + fmt.Fprintf(os.Stderr, "economy recovery: final evidence prune save %s: %v\n", p.Name, err) + } + } + } + for _, p := range playersByID { + prunePlayer(p) + } + for _, p := range playersByName { + prunePlayer(p) + } // Rebuild the outstanding-stake index from whatever open wager records - // remain (normally none; ambiguous ones stay refundable/resolvable). - g.Economy.reloadStakes() + // remain (normally none; ambiguous ones stay refundable/resolvable). A + // fold failure leaves the previous index intact and was already logged. + if err := g.Economy.reloadStakes(); err != nil { + fmt.Fprintf(os.Stderr, "economy recovery: stake index rebuild failed: %v\n", err) + } } |
