From fcb806c3445ce56f2e57e208de773b4dac957c8d Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 15 Sep 2026 20:35:23 -0400 Subject: fix: owner re-entrancy deadlock, telnet write queue, recovery/refund money safety, admin path traversal --- internal/game/economy.go | 83 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 66 insertions(+), 17 deletions(-) (limited to 'internal/game/economy.go') diff --git a/internal/game/economy.go b/internal/game/economy.go index fcc0010..fd12d07 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -268,19 +268,24 @@ func (j *economyJournal) compact() { } } -// giveChips re-credits chips to a player's inventory (used by recovery -// refunds). Stacks onto an existing chips slot when one exists. -func (g *Game) giveChips(p *player.Player, qty int) { +// giveChips re-credits chips to a player's inventory (used by refund and +// recovery paths). It stacks onto an existing chips slot when one exists. +// It reports false when the chips do NOT fit (no existing stack and no free +// slot): the caller must then treat the chips as undelivered — they are +// never silently dropped; the money record stays open instead. +func (g *Game) giveChips(p *player.Player, qty int) bool { for _, slot := range p.Inventory { if slot != nil && slot.ItemID == "chips" { slot.Quantity += qty - return + return true } } slot := p.FirstFreeSlot() - if slot >= 0 { - p.SetInvSlot(slot, &player.InventorySlot{ItemID: "chips", Quantity: qty}) + if slot < 0 { + return false } + p.SetInvSlot(slot, &player.InventorySlot{ItemID: "chips", Quantity: qty}) + return true } // payCreditsDurable applies a checked credit increase and persists it. @@ -358,27 +363,58 @@ func (g *Game) recoverEconomy() { } compensated := 0 - // Phase 1 — payouts, in append order. + // Phase 1 — payouts, in append order. Records with identical + // checkpoints are decided as one group: k sibling records all journaled + // against balance `before` were all written against the same in-memory + // balance (a failed save reverts it), so either none became durable + // (the file is still at `before`: all k are owed) or at most one did + // (the file is at `after` and k == 1). Re-applying identical siblings + // one at a time would let the second record misread the first + // re-application as its own durability and silently drop the owed + // payout — the group is settled together instead. + type payoutSig struct { + player string + before, after int + credit int + } + siblings := make(map[payoutSig][]*economyRecord) + 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} + siblings[sig] = append(siblings[sig], rec) + } + } + settled := make(map[string]bool) for i := range folded { rec := &folded[i].rec - if rec.Kind != "payout" || rec.Status != "open" { + if rec.Kind != "payout" || rec.Status != "open" || settled[rec.ID] { continue } p := loadPlayer(rec.Player) if p == nil { continue } + sig := payoutSig{rec.Player, rec.CreditsBefore, rec.CreditsAfter, rec.Credit} + group := siblings[sig] 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 + // The payout (and every identical sibling) was lost in the + // crash: the player is owed them all. + for m := 0; m < len(group); m++ { + if err := g.payCreditsDurable(p, rec.Credit); err != nil { + fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err) + break + } + compensated++ } + for _, other := range group { + g.Economy.settle(other) + settled[other.ID] = true + } + case p.Credits == rec.CreditsAfter && len(group) == 1: g.Economy.settle(rec) - compensated++ - case p.Credits == rec.CreditsAfter: - g.Economy.settle(rec) + settled[rec.ID] = true 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) @@ -415,10 +451,23 @@ func (g *Game) recoverEconomy() { // The debit is durable but the round was lost in the crash: // refund the stake (approved refund-on-recovery policy). p.Credits += rec.Credit - if rec.Chips > 0 { - g.giveChips(p, rec.Chips) + if rec.Chips > 0 && !g.giveChips(p, rec.Chips) { + // No inventory room for the chip return: revert the credit + // and keep the record open for operator inspection rather + // than silently dropping the chips. + p.Credits = creditsNow + fmt.Fprintf(os.Stderr, "economy recovery: wager %s for %s: no inventory room for %d chips — kept open for operator inspection\n", + rec.ID, rec.Player, rec.Chips) + continue } 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 + // record stays open and a later startup can retry. + p.Credits = creditsNow + if rec.Chips > 0 { + p.RemoveItem("chips", rec.Chips) + } fmt.Fprintf(os.Stderr, "economy recovery: refund save %s: %v\n", p.Name, err) continue } -- cgit v1.2.3