diff options
Diffstat (limited to 'internal/game/economy.go')
| -rw-r--r-- | internal/game/economy.go | 242 |
1 files changed, 189 insertions, 53 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go index afdcdd8..e917fa6 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -42,19 +42,33 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "thehouseoficarus/internal/player" ) type economyRecord struct { - ID string `json:"id"` - Player string `json:"player"` - Kind string `json:"kind"` // "wager" or "payout" - Chips int `json:"chips"` - Credit int `json:"credits"` - Status string `json:"status"` // "open", "settled", "void" - TS time.Time `json:"ts"` + ID string `json:"id"` + Player string `json:"player"` + // PlayerID is the character's stable UUID (player.Player.ID), assigned + // at creation and backfilled at first startup. Records resolve their + // subject by ID at recovery so a character RENAME can never strand an + // owed payout or outstanding stake; Player (the name) is kept for + // operator inspection and for legacy records written before IDs. + PlayerID string `json:"player_id,omitempty"` + Kind string `json:"kind"` // "wager" or "payout" + Chips int `json:"chips"` + Credit int `json:"credits"` + Status string `json:"status"` // "open", "settled", "void" + TS time.Time `json:"ts"` + // Refund marks a payout record as a SHUTDOWN/EVICTION REFUND (the + // stake being returned), not a game settlement. Recovery must not + // treat the chip part of a refund as paid in credits: a refund's chip + // stake returns in kind, so a crash mid-refund still owes the chips. + // Settlement payouts (a win, push, or surrender return) instead prove + // the round resolved, closing the stake without any chip return. + Refund bool `json:"refund,omitempty"` // Balance checkpoints used to decide recovery compensation. CreditsBefore int `json:"credits_before"` @@ -65,8 +79,9 @@ 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 + // stakes indexes outstanding wager records per player identity key + // (economyKey: the character's stable ID when known, else its 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 } @@ -107,12 +122,37 @@ func (j *economyJournal) append(rec economyRecord) error { return nil } +// appendRetry appends one durable line, retrying a bounded number of +// times: a transient write failure must not permanently lose a marker +// that money correctness depends on. +func (j *economyJournal) appendRetry(rec economyRecord, attempts int) error { + var err error + for i := 0; i < max(attempts, 1); i++ { + if err = j.append(rec); err == nil { + return nil + } + } + return err +} + // start opens a durable record for the in-flight transaction. func (j *economyJournal) start(kind string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { + return j.startKind(kind, false, p, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter) +} + +// startRefund opens a refund payout record (stake returned at shutdown or +// eviction; see economyRecord.Refund). +func (j *economyJournal) startRefund(p *player.Player, credits, creditsBefore, creditsAfter int) (*economyRecord, error) { + return j.startKind("payout", true, p, 0, credits, creditsBefore, creditsAfter, 0, 0) +} + +func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { rec := &economyRecord{ ID: newEconomyRecordID(), Player: p.Name, + PlayerID: p.ID, Kind: kind, + Refund: refund, Chips: chips, Credit: credits, Status: "open", @@ -143,22 +183,55 @@ func (j *economyJournal) void(rec *economyRecord) { // 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) + j.stakes[rec.key()] = append(j.stakes[rec.key()], rec) } -// openStakes returns the outstanding stake records for a player. +// openStakes returns the outstanding stake records for a player identity +// key (see economyKey). 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. +// A marker whose append cannot be made durable leaves that stake OPEN +// (never dropped from the index): recovery then settles it from later +// 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) { + remaining := j.stakes[player][:0] for _, rec := range j.stakes[player] { rec.Status = "settled" - _ = j.append(*rec) + if err := j.appendRetry(*rec, 3); err != nil { + // The marker is not durable: the stake stays open so recovery + // (or a later attempt) still resolves it. + rec.Status = "open" + remaining = append(remaining, rec) + } + } + if len(remaining) == 0 { + delete(j.stakes, player) + } else { + j.stakes[player] = remaining } - delete(j.stakes, player) +} + +// economyKey is the identity a player's journal records are indexed by: +// the stable character ID when known, else the (renameable) name. +func economyKey(p *player.Player) string { + if p.ID != "" { + return p.ID + } + return p.Name +} + +// economyRecordKey mirrors economyKey for a raw record. +func (rec *economyRecord) key() string { + if rec.PlayerID != "" { + return rec.PlayerID + } + return rec.Player } // reloadStakes rebuilds the outstanding-stake index from the journal file: @@ -289,6 +362,8 @@ func (g *Game) giveChips(p *player.Player, qty int) bool { } // payCreditsDurable applies a checked credit increase and persists it. +// A failed save reverts the in-memory mutation: recovery must keep +// comparing later records against the DURABLE balance. func (g *Game) payCreditsDurable(p *player.Player, amount int) error { if amount < 0 { return fmt.Errorf("negative payout %d", amount) @@ -297,8 +372,13 @@ func (g *Game) payCreditsDurable(p *player.Player, amount int) error { if err != nil { return err } + before := p.Credits p.Credits = sum - return g.AccountStore.SaveCharacter(p) + if err := g.AccountStore.SaveCharacter(p); err != nil { + p.Credits = before + return err + } + return nil } // addCreditsChecked is the game-side checked addition against the economy @@ -337,37 +417,75 @@ func (g *Game) recoverEconomy() { return } folded := g.Economy.loadFolded() - players := make(map[string]*player.Player) - loadPlayer := func(name string) *player.Player { - if p, ok := players[name]; ok { - return p + // 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 + // up front so records resolve by stable ID without filename guessing. + playersByID := make(map[string]*player.Player) + playersByName := make(map[string]*player.Player) + if names, err := g.AccountStore.ListCharacterNames(); err == nil { + for _, name := range names { + if p, err := g.AccountStore.LoadCharacter(name); err == nil { + if p.ID != "" { + playersByID[p.ID] = p + } + playersByName[strings.ToLower(p.Name)] = 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", name, err) - return nil + } + loadPlayer := func(rec *economyRecord) *player.Player { + if rec.PlayerID != "" { + if p, ok := playersByID[rec.PlayerID]; ok { + return p + } + } + if rec.Player != "" { + if p, ok := playersByName[strings.ToLower(rec.Player)]; ok { + return p + } } - players[name] = p - return p + if rec.PlayerID != "" { + // A character file added since the index was built: load every + // file once and match by stable ID. + if names, err := g.AccountStore.ListCharacterNames(); err == nil { + for _, name := range names { + 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 + } + } + } + } + // 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 (id %q): character not found\n", rec.Player, rec.PlayerID) + return nil } // laterPayoutEvidence reports whether any non-void payout record was // journaled for the player after seq — evidence the round resolved and - // its settlement was recorded — together with the total credits those - // records promise. A payout covering the stake's full value returned - // the stake inside the payout (win, push, or refund); a smaller total - // (e.g. the credit side of a crashed mixed refund) leaves the chip - // side of the stake outstanding. - laterPayoutEvidence := func(name string, seq int) (resolved bool, credits int) { + // its settlement was recorded. + // + // settlement separately reports whether a SETTLEMENT payout (not a + // refund) was journaled later: a settlement — even a partial one like + // a surrendered hand or the first hand of a multi-hand win — proves + // the round resolved, so the stake must NOT be refunded and its chip + // part must NOT return in kind on top of the settlement (the chips + // were lost at settlement, by the rules of the game). A REFUND payout + // instead marks a refund in progress: its credit part is re-applied by + // phase 1 and the chip part (if the debit was durable) still comes + // back in chips. + laterPayoutEvidence := func(name string, seq int) (resolved bool, settlement bool) { for _, fr := range folded { if fr.rec.Kind == "payout" && fr.rec.Player == name && fr.rec.Status != "void" && fr.seq > seq { resolved = true - credits += fr.rec.Credit + if !fr.rec.Refund { + settlement = true + } } } - return resolved, credits + return resolved, settlement } compensated := 0 @@ -399,7 +517,7 @@ func (g *Game) recoverEconomy() { if rec.Kind != "payout" || rec.Status != "open" || settled[rec.ID] { continue } - p := loadPlayer(rec.Player) + p := loadPlayer(rec) if p == nil { continue } @@ -413,7 +531,12 @@ func (g *Game) recoverEconomy() { // 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 @@ -427,17 +550,26 @@ func (g *Game) recoverEconomy() { settled[other.ID] = true continue } - // An unpaid sibling (the save failed, or the credit cap - // blocked re-application) stays owed. Re-anchor its - // 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 + 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 - _ = g.Economy.append(*other) } case p.Credits == rec.CreditsAfter && len(group) == 1: g.Economy.settle(rec) @@ -457,8 +589,8 @@ func (g *Game) recoverEconomy() { } for k := len(openWagers) - 1; k >= 0; k-- { rec := &folded[openWagers[k]].rec - resolvedByPayout, laterPayoutCredits := laterPayoutEvidence(rec.Player, folded[openWagers[k]].seq) - p := loadPlayer(rec.Player) + resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec.Player, folded[openWagers[k]].seq) + p := loadPlayer(rec) if p == nil { continue } @@ -467,12 +599,16 @@ func (g *Game) recoverEconomy() { creditsNow, chipsNow := p.Credits, countChips(p) // Chips are recovered independently of the credit checkpoints. Any // state where the chip debit is durable but the chips have not come - // back (post-debit chip balance) still owes the chips — unless the - // resolving payout already returned the stake's full value in - // credits (a win, push, or refund includes the stake): returning - // the chips in kind on top would double-refund the round. + // back (post-debit chip balance) still owes the chips — unless a + // SETTLEMENT payout was journaled later: a settlement proves the + // round resolved, so the chips were lost at settlement by the rules + // of the game, and returning them in kind on top would double-pay + // the round (e.g. a surrendered hand paying half the stake must not + // ALSO return the full chip stake). A REFUND payout instead marks a + // refund in progress: its credit part was re-applied by phase 1 and + // the chip part still returns in chips. chipsOwed := rec.Chips > 0 && chipsNow == rec.ChipsAfter && - (!resolvedByPayout || laterPayoutCredits < rec.Chips+rec.Credit) + !resolvedBySettlement if chipsOwed && !g.giveChips(p, rec.Chips) { // No inventory room for the chip return: keep the record open // for operator inspection rather than silently dropping the |
