diff options
Diffstat (limited to 'internal/game/economy.go')
| -rw-r--r-- | internal/game/economy.go | 241 |
1 files changed, 196 insertions, 45 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go index 90c24ed..fcc0010 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -2,20 +2,37 @@ package game // The economy journal: durable, idempotent money movement. Every casino // wager and payout writes a journal record BEFORE the balance mutation, -// then the character save makes the balance durable, then the record is -// settled. A crash at any point leaves either: +// then the character save makes the balance durable. +// +// Two kinds of records exist: +// +// - "wager": a committed stake. The record stays OPEN while the stake is +// outstanding (the round is in flight); the game resolves it with a +// settled marker when the round resolves (won, lost, pushed, or +// forfeited). At startup, open wager records are refunded — the round +// 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. +// +// A crash at any point therefore leaves either: // - an open record with the character file at its pre-transaction state // (crash before save) — recovery settles it as void (debit) or applies // it (payout, which the player is owed); or -// - an open record with the character file at its post-transaction state -// (crash after save) — recovery refunds open debit records (the round -// state was lost in the crash, so the stake returns) and voids payout -// records that are already durable. +// - an open wager with the file at its post-transaction state and no +// later payout evidence (the round state was lost in the crash) — the +// stake is refunded; or +// - an open payout (crash after the journal write, before the save) — +// the owed amount is re-applied. +// +// Records whose checkpoints match neither balance state after compensating +// earlier records are ambiguous: they are kept open and surfaced for +// operator inspection rather than silently settled or voided. // // The reducer folds journal lines by record ID (later lines win), so // settlement markers are idempotent appends. Recovery runs once at startup // before any session can join; compaction rewrites the journal keeping only -// open records (normally none). +// open records (outstanding stakes and unresolved payouts). import ( "bufio" @@ -48,12 +65,16 @@ type economyRecord struct { type economyJournal struct { path string + // stakes indexes outstanding wager records per player name. 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 } func newEconomyJournal(dataDir string) *economyJournal { dir := filepath.Join(dataDir, "economy") _ = os.MkdirAll(dir, 0700) - return &economyJournal{path: filepath.Join(dir, "journal.jsonl")} + return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord)} } func newEconomyRecordID() string { @@ -119,17 +140,72 @@ func (j *economyJournal) void(rec *economyRecord) { _ = j.append(*rec) } +// registerStake tracks an outstanding wager record for its player. Wager +// records stay open (refundable) until the round resolves. +func (j *economyJournal) registerStake(rec *economyRecord) { + j.stakes[rec.Player] = append(j.stakes[rec.Player], rec) +} + +// openStakes returns the outstanding stake records for a player. +func (j *economyJournal) openStakes(player string) []*economyRecord { + return j.stakes[player] +} + +// ResolveStakes closes every outstanding stake for a player with a settled +// marker per record: the round resolved, so recovery must not refund them. +func (j *economyJournal) ResolveStakes(player string) { + for _, rec := range j.stakes[player] { + rec.Status = "settled" + _ = j.append(*rec) + } + delete(j.stakes, player) +} + +// 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() { + j.stakes = make(map[string][]*economyRecord) + for _, fr := range j.loadFolded() { + if fr.rec.Kind == "wager" && fr.rec.Status == "open" { + rec := fr.rec + j.registerStake(&rec) + } + } +} + // loadOpen folds the journal and returns the still-open records. func (j *economyJournal) loadOpen() []*economyRecord { + var open []*economyRecord + for _, fr := range j.loadFolded() { + if fr.rec.Status == "open" { + rec := fr.rec + open = append(open, &rec) + } + } + return open +} + +// foldedRecord is the latest state of one record ID together with the +// order (line position of its latest write) it occupied in the journal. +type foldedRecord struct { + rec economyRecord + seq int +} + +// loadFolded folds the journal (latest line per record ID wins) and +// returns the surviving records in journal append order. +func (j *economyJournal) loadFolded() []foldedRecord { f, err := os.Open(j.path) if err != nil { return nil } defer f.Close() - latest := map[string]*economyRecord{} + latest := map[string]*foldedRecord{} + var order []*foldedRecord scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for scanner.Scan() { + for seq := 0; scanner.Scan(); seq++ { line := scanner.Bytes() if len(line) == 0 { continue @@ -141,15 +217,20 @@ func (j *economyJournal) loadOpen() []*economyRecord { // line can be torn. continue } - latest[rec.ID] = &rec - } - var open []*economyRecord - for _, rec := range latest { - if rec.Status == "open" { - open = append(open, rec) + if existing, ok := latest[rec.ID]; ok { + existing.rec = rec + existing.seq = seq + } else { + fr := &foldedRecord{rec: rec, seq: seq} + latest[rec.ID] = fr + order = append(order, fr) } } - return open + folded := make([]foldedRecord, 0, len(order)) + for _, fr := range order { + folded = append(folded, *fr) + } + return folded } // compact rewrites the journal keeping only still-open records. @@ -230,32 +311,112 @@ func addCreditsChecked(a, b int) (int, error) { // recoverEconomy compensates open records from the last crash. It is called // once from New, before any session connects. +// +// Recovery runs in two phases so checkpoints stay meaningful: +// +// 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). +// 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; +// at its post-debit state → the stake was charged but the round state +// was lost in the crash → the stake is refunded, unless a payout record +// written later proves the round resolved (a win/push/refund already +// returned the stake inside that payout) → settled with no refund. +// Anything else stays open for operator inspection. func (g *Game) recoverEconomy() { if g.Economy == nil { return } - records := g.Economy.loadOpen() - compensated := 0 - for _, rec := range records { - p, err := g.AccountStore.LoadCharacter(rec.Player) + folded := g.Economy.loadFolded() + players := make(map[string]*player.Player) + loadPlayer := func(name string) *player.Player { + if p, ok := players[name]; ok { + return p + } + p, err := g.AccountStore.LoadCharacter(name) if err != nil { // Character file unreadable: leave the record open so a later // attempt (or operator inspection) can resolve it; do not // silently drop money. - fmt.Fprintf(os.Stderr, "economy recovery: load %s: %v\n", rec.Player, err) + fmt.Fprintf(os.Stderr, "economy recovery: load %s: %v\n", name, err) + return nil + } + players[name] = p + return p + } + hasLaterPayout := func(name string, seq int) bool { + for _, fr := range folded { + if fr.rec.Kind == "payout" && fr.rec.Player == name && fr.rec.Status != "void" && fr.seq > seq { + return true + } + } + return false + } + compensated := 0 + + // Phase 1 — payouts, in append order. + for i := range folded { + rec := &folded[i].rec + if rec.Kind != "payout" || rec.Status != "open" { + continue + } + p := loadPlayer(rec.Player) + if p == nil { + continue + } + switch { + case p.Credits == rec.CreditsBefore: + // The payout was lost in the crash: the player is owed it. + if err := g.payCreditsDurable(p, rec.Credit); err != nil { + fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err) + continue + } + g.Economy.settle(rec) + compensated++ + case p.Credits == rec.CreditsAfter: + g.Economy.settle(rec) + 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) + } + } + + // Phase 2 — outstanding wagers, newest first. + var openWagers []int + for i := range folded { + if folded[i].rec.Kind == "wager" && folded[i].rec.Status == "open" { + openWagers = append(openWagers, i) + } + } + for k := len(openWagers) - 1; k >= 0; k-- { + rec := &folded[openWagers[k]].rec + resolvedByPayout := hasLaterPayout(rec.Player, folded[openWagers[k]].seq) + p := loadPlayer(rec.Player) + if p == nil { continue } + creditsNow, chipsNow := p.Credits, countChips(p) switch { - case rec.Kind == "wager" && p.Credits == rec.CreditsBefore: + case creditsNow == rec.CreditsBefore && chipsNow == rec.ChipsBefore: // The debit never became durable: void it. g.Economy.void(rec) - case rec.Kind == "wager" && p.Credits == rec.CreditsAfter: + case creditsNow == rec.CreditsAfter && chipsNow == rec.ChipsAfter: + if resolvedByPayout { + // A later payout record proves the round resolved (the win, + // push, or refund already returned this stake inside that + // payout). Only the resolution marker was lost. + g.Economy.settle(rec) + continue + } // The debit is durable but the round was lost in the crash: // refund the stake (approved refund-on-recovery policy). - chips, credits := rec.Chips, rec.Credit - p.Credits += credits - if chips > 0 { - g.giveChips(p, chips) + p.Credits += rec.Credit + if rec.Chips > 0 { + g.giveChips(p, rec.Chips) } if err := g.AccountStore.SaveCharacter(p); err != nil { fmt.Fprintf(os.Stderr, "economy recovery: refund save %s: %v\n", p.Name, err) @@ -263,27 +424,17 @@ func (g *Game) recoverEconomy() { } g.Economy.settle(rec) compensated++ - case rec.Kind == "wager": - // Balance moved past the recorded checkpoint: cannot compensate - // reliably; surface for operator inspection. - fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d)\n", - rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) - g.Economy.void(rec) - case rec.Kind == "payout" && p.Credits == rec.CreditsBefore: - // The payout was lost in the crash: the player is owed it. - if err := g.payCreditsDurable(p, rec.Credit); err != nil { - fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err) + default: + if resolvedByPayout { + g.Economy.settle(rec) continue } - g.Economy.settle(rec) - compensated++ - case rec.Kind == "payout" && p.Credits == rec.CreditsAfter: - g.Economy.settle(rec) - case rec.Kind == "payout": - fmt.Fprintf(os.Stderr, "economy recovery: ambiguous payout %s for %s (credits %d, want before %d or after %d)\n", + fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %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) - g.Economy.settle(rec) } } g.Economy.compact() + // Rebuild the outstanding-stake index from whatever open wager records + // remain (normally none; ambiguous ones stay refundable/resolvable). + g.Economy.reloadStakes() } |
