aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 20:35:23 -0400
committerhistoria <[not public]>2026-09-15 20:35:23 -0400
commitfcb806c3445ce56f2e57e208de773b4dac957c8d (patch)
tree3b08c3d51bfe5456dcd215449cf07509aca2353a /internal/game/economy.go
parent69235cac5b3da5ff0dc20310e7474cabd8e63ba3 (diff)
downloadthehouseoficarus-fcb806c3445ce56f2e57e208de773b4dac957c8d.tar.gz
fix: owner re-entrancy deadlock, telnet write queue, recovery/refund money safety, admin path traversal
Diffstat (limited to 'internal/game/economy.go')
-rw-r--r--internal/game/economy.go83
1 files changed, 66 insertions, 17 deletions
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
}