aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy_recovery_regression_test.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_recovery_regression_test.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_recovery_regression_test.go')
-rw-r--r--internal/game/economy_recovery_regression_test.go115
1 files changed, 115 insertions, 0 deletions
diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go
index e085e67..738dece 100644
--- a/internal/game/economy_recovery_regression_test.go
+++ b/internal/game/economy_recovery_regression_test.go
@@ -137,6 +137,121 @@ func TestRecoveryRefundsSequentialStakes(t *testing.T) {
}
}
+// TestRecoveryReappliesIdenticalFailedPayouts: two won hands whose payout
+// saves both failed leave two OPEN payout records with identical
+// checkpoints (the in-memory balance reverts on save failure, so both were
+// journaled against balance 90). Recovery must re-apply BOTH — settling
+// the second as "already durable" because the first re-application moved
+// the balance to `after` would silently drop 20 owed credits.
+func TestRecoveryReappliesIdenticalFailedPayouts(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 90 // post-debit balance: both payouts failed to save
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Error(err)
+ }
+ for i := 0; i < 2; i++ {
+ if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil {
+ t.Error(err)
+ }
+ }
+ h.g.recoverEconomy()
+ })
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 130 {
+ t.Fatalf("balance after recovery = %d, want 130 (both identical failed payouts re-applied)", loaded.Credits)
+ }
+ if open := h.g.Economy.loadOpen(); len(open) != 0 {
+ t.Fatalf("records left open after recovery: %+v", open)
+ }
+}
+
+// TestRefundOutstandingKeepsStakesOpenWithoutInventoryRoom: a chip refund
+// into a FULL inventory must be refused (stake records stay open) instead
+// of silently dropping the chips and marking the refund complete. Once the
+// player frees a slot, the same outstanding stake refunds successfully.
+func TestRefundOutstandingKeepsStakesOpenWithoutInventoryRoom(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10})
+ w := casinoWallet{g: h.g, p: p}
+ if _, err := w.Wager(10); err != nil {
+ t.Error(err)
+ return
+ }
+ // The debit emptied the chips slot; fill EVERY slot (including the
+ // one the wager just freed) so the chip return has nowhere to go.
+ for p.FirstFreeSlot() >= 0 {
+ p.SetInvSlot(p.FirstFreeSlot(), &player.InventorySlot{ItemID: "filler", Quantity: 1})
+ }
+ chips, credits, err := w.RefundOutstanding()
+ if err != casino.ErrWalletPersistence {
+ t.Errorf("refund error = %v, want ErrWalletPersistence (chips=%d credits=%d)", err, chips, credits)
+ }
+ if got := countChips(p); got != 0 {
+ t.Errorf("chips after refused refund = %d, want 0 (nothing delivered; tracked in the open stake)", got)
+ }
+ if open := h.g.Economy.openStakes(p.Name); len(open) != 1 {
+ t.Errorf("stake records after refused refund = %d, want 1 (kept open for a later attempt)", len(open))
+ }
+
+ // Free a slot: the SAME outstanding stake now refunds in full.
+ p.RemoveItem("filler", 1)
+ chips, credits, err = w.RefundOutstanding()
+ if err != nil || chips != 10 || credits != 0 {
+ t.Errorf("retry refund = (%d chips, %d credits, %v), want (10, 0, nil)", chips, credits, err)
+ }
+ if got := countChips(p); got != 10 {
+ t.Errorf("chips after retried refund = %d, want 10", got)
+ }
+ if open := h.g.Economy.openStakes(p.Name); len(open) != 0 {
+ t.Errorf("stake records after successful refund = %d, want 0", len(open))
+ }
+ })
+}
+
+// TestRecoveryKeepsWagerOpenWhenChipsDoNotFit: recovery must not settle a
+// chip stake as refunded when the character's inventory cannot hold the
+// chips — the record stays open for operator inspection.
+func TestRecoveryKeepsWagerOpenWhenChipsDoNotFit(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ // Durable state at crash time: the 10-chip debit landed (10 -> 0
+ // chips on disk), credits untouched, and the inventory is FULL of
+ // non-chip filler, so recovery has nowhere to put the refund.
+ p.Credits = 100
+ for i := 0; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
+ }
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Error(err)
+ }
+ if _, err := h.g.Economy.start("wager", p, 10, 0, 100, 100, 10, 0); err != nil {
+ t.Error(err)
+ }
+ h.g.recoverEconomy()
+ })
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := countChips(loaded); got != 0 {
+ t.Fatalf("chips after recovery = %d, want 0 (no inventory room; nothing delivered)", got)
+ }
+ if open := h.g.Economy.loadOpen(); len(open) != 1 {
+ t.Fatalf("chip wager must stay open for operator inspection, open records: %+v", open)
+ }
+}
+
// TestRecoveryPreservesAmbiguousPayout: a payout whose checkpoints match
// neither balance state stays open and is surfaced for operator inspection —
// a liability is never silently dropped.