diff options
Diffstat (limited to 'internal/game/economy.go')
| -rw-r--r-- | internal/game/economy.go | 61 |
1 files changed, 45 insertions, 16 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go index e917fa6..be3f5ef 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -234,6 +234,24 @@ func (rec *economyRecord) key() string { return rec.Player } +// 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 +// consume) another character's resolution evidence. Legacy records without +// IDs keep the historical name-based match among themselves. +func payoutIdentifies(a, b economyRecord) bool { + if a.PlayerID != "" && b.PlayerID != "" { + return a.PlayerID == b.PlayerID + } + if a.PlayerID != "" || b.PlayerID != "" { + // Mixed legacy/ID records: refuse to match on name alone so an ID + // can never be resolved through a name that another character may + // have reused. + return false + } + return strings.EqualFold(a.Player, b.Player) +} + // reloadStakes rebuilds the outstanding-stake index from the journal file: // open wager records remain tracked (and refundable/resolvable) after // recovery compaction. @@ -435,16 +453,14 @@ func (g *Game) recoverEconomy() { } loadPlayer := func(rec *economyRecord) *player.Player { if rec.PlayerID != "" { + // A record carrying a stable ID resolves to THAT identity only. + // Never fall back to the display name: a different character + // that later reused the name would collect the original + // character's payout and the original liability would be + // settled as paid. See TestRecoveryDoesNotPayReusedCharacterName. if p, ok := playersByID[rec.PlayerID]; ok { return p } - } - if rec.Player != "" { - if p, ok := playersByName[strings.ToLower(rec.Player)]; ok { - 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 { @@ -456,11 +472,20 @@ func (g *Game) recoverEconomy() { } } } + // Identity not found (character deleted or unreadable): leave + // the record open so a later attempt (or operator inspection) + // can resolve it; do not silently drop or reassign money. + fmt.Fprintf(os.Stderr, "economy recovery: load %s (id %q): character not found\n", rec.Player, rec.PlayerID) + return nil + } + // Legacy record written before stable IDs existed: the (renameable) + // name is the only identity available. + if rec.Player != "" { + if p, ok := playersByName[strings.ToLower(rec.Player)]; ok { + 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) + fmt.Fprintf(os.Stderr, "economy recovery: load %s: character not found\n", rec.Player) return nil } // laterPayoutEvidence reports whether any non-void payout record was @@ -476,9 +501,10 @@ func (g *Game) recoverEconomy() { // 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) { + laterPayoutEvidence := func(rec *economyRecord, 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 { + if fr.rec.Kind == "payout" && fr.rec.Status != "void" && fr.seq > seq && + payoutIdentifies(fr.rec, *rec) { resolved = true if !fr.rec.Refund { settlement = true @@ -499,6 +525,9 @@ func (g *Game) recoverEconomy() { // re-application as its own durability and silently drop the owed // payout — the group is settled together instead. type payoutSig struct { + // player is the record's stable identity key (PlayerID when known, + // else the name): grouping by display name alone would merge two + // different characters that reused the same name. player string before, after int credit int @@ -507,7 +536,7 @@ func (g *Game) recoverEconomy() { for i := range folded { rec := &folded[i].rec if rec.Kind == "payout" && rec.Status == "open" { - sig := payoutSig{rec.Player, rec.CreditsBefore, rec.CreditsAfter, rec.Credit} + sig := payoutSig{rec.key(), rec.CreditsBefore, rec.CreditsAfter, rec.Credit} siblings[sig] = append(siblings[sig], rec) } } @@ -521,7 +550,7 @@ func (g *Game) recoverEconomy() { if p == nil { continue } - sig := payoutSig{rec.Player, rec.CreditsBefore, rec.CreditsAfter, rec.Credit} + sig := payoutSig{rec.key(), rec.CreditsBefore, rec.CreditsAfter, rec.Credit} group := siblings[sig] switch { case p.Credits == rec.CreditsBefore: @@ -589,7 +618,7 @@ func (g *Game) recoverEconomy() { } for k := len(openWagers) - 1; k >= 0; k-- { rec := &folded[openWagers[k]].rec - resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec.Player, folded[openWagers[k]].seq) + resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec, folded[openWagers[k]].seq) p := loadPlayer(rec) if p == nil { continue |
