diff options
| author | historia <[not public]> | 2026-09-15 20:35:23 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 20:35:23 -0400 |
| commit | fcb806c3445ce56f2e57e208de773b4dac957c8d (patch) | |
| tree | 3b08c3d51bfe5456dcd215449cf07509aca2353a /internal/game/economy_recovery_regression_test.go | |
| parent | 69235cac5b3da5ff0dc20310e7474cabd8e63ba3 (diff) | |
| download | thehouseoficarus-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.go | 115 |
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. |
