package game // Economy recovery regressions for the outstanding-stake ledger: a crash // with a round in flight refunds the stake, chip stakes recover in chips, // a failed payout is re-applied or preserved for operator inspection, and // a resolved round (payout evidence) is never refunded twice. import ( "testing" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/player" ) // TestRecoveryRefundsInFlightMachineWager: a wager debited durably with the // round still in flight (no graceful shutdown, no resolution) is refunded by // startup recovery — the documented refund-on-crash policy. func TestRecoveryRefundsInFlightMachineWager(t *testing.T) { h := newHarness(t) h.seedAccount("alice", "pw", false) p := h.seedCharacter("alice", "alice") h.g.RunOnOwner(func() { p.Credits = 100 h.g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots}, "alice") h.g.Casino.SetMachineBet(1, "slots", "alice", 10, casinoWallet{g: h.g, p: p}) }) h.barrier() // A fresh Game over the saved files simulates losing all live round // state in a crash. The stake record is open, so recovery refunds it. restarted := harnessFromDir(t, h.dir) loaded, err := restarted.g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } if loaded.Credits != 100 { t.Fatalf("crash with an active 10-credit spin: recovered %d credits, want 100", loaded.Credits) } if open := restarted.g.Economy.openStakes("alice"); len(open) != 0 { t.Fatalf("refunded stake left open: %+v", open) } } // TestRecoveryRefundsChipOnlyWager: a chip-only debit must be recognized by // comparing chip balances, not credits alone (the credit checkpoints are // identical before and after a chip-only wager). func TestRecoveryRefundsChipOnlyWager(t *testing.T) { h := newHarness(t) h.seedAccount("alice", "pw", false) p := h.seedCharacter("alice", "alice") h.g.RunOnOwner(func() { p.Credits = 100 p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 20}) if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Error(err) } // Simulate the crash window: the chip debit is durable in the // character file but the stake record never resolved. p.RemoveItem("chips", 10) if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Error(err) } if _, err := h.g.Economy.start("wager", p, 10, 0, 100, 100, 20, 10); 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 != 20 { t.Fatalf("chip stake after recovery = %d, want 20 (refunded in chips)", got) } } // TestRecoveryReappliesLostPayoutWithEvidence: a won hand whose payout // failed to save leaves an open payout record AND an open stake record. // Recovery re-applies the payout (which includes the stake return) and // settles the stake from that evidence — exactly once, no double refund. func TestRecoveryReappliesLostPayoutWithEvidence(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: the 10-credit stake was charged if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Error(err) } // Open stake for the lost round (before=100, after=90). if _, err := h.g.Economy.start("wager", p, 0, 10, 100, 90, 0, 0); err != nil { t.Error(err) } // Open payout for the won hand (2x bet = 20, before=90, after=110). 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 != 110 { t.Fatalf("balance after recovery = %d, want 110 (payout re-applied once, no double stake refund)", loaded.Credits) } if open := h.g.Economy.loadOpen(); len(open) != 0 { t.Fatalf("records left open after recovery: %+v", open) } } // TestRecoveryRefundsSequentialStakes: a bet plus a double (two open stake // records, newest processed first) both refund after a crash. func TestRecoveryRefundsSequentialStakes(t *testing.T) { h := newHarness(t) h.seedAccount("alice", "pw", false) p := h.seedCharacter("alice", "alice") h.g.RunOnOwner(func() { p.Credits = 80 // bet 10 then doubled: 100 -> 90 -> 80 if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Error(err) } if _, err := h.g.Economy.start("wager", p, 0, 10, 100, 90, 0, 0); err != nil { t.Error(err) } if _, err := h.g.Economy.start("wager", p, 0, 10, 90, 80, 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 != 100 { t.Fatalf("balance after recovery = %d, want 100 (both stakes refunded)", loaded.Credits) } } // 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. func TestRecoveryPreservesAmbiguousPayout(t *testing.T) { h := newHarness(t) h.seedAccount("alice", "pw", false) p := h.seedCharacter("alice", "alice") h.g.RunOnOwner(func() { // A payout owed at balance 100 failed to save; the player then // spent 5 credits elsewhere before the crash. p.Credits = 95 if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Error(err) } if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 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 != 95 { t.Fatalf("ambiguous recovery mutated the balance: %d, want 95", loaded.Credits) } if open := h.g.Economy.loadOpen(); len(open) != 1 { t.Fatalf("ambiguous payout must stay open for operator inspection: %+v", open) } }