diff options
Diffstat (limited to 'internal/game/economy_shutdown_test.go')
| -rw-r--r-- | internal/game/economy_shutdown_test.go | 273 |
1 files changed, 0 insertions, 273 deletions
diff --git a/internal/game/economy_shutdown_test.go b/internal/game/economy_shutdown_test.go deleted file mode 100644 index 27190fb..0000000 --- a/internal/game/economy_shutdown_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package game - -// Durable-money and graceful-shutdown regressions: journal-first wagers -// with revert on save failure, startup recovery that refunds lost wagers -// and re-pays lost payouts, checked currency arithmetic, and a shutdown -// that persists online characters. - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - "thehouseoficarus/internal/casino" - "thehouseoficarus/internal/config" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" - - "gopkg.in/yaml.v3" -) - -// harnessFromDir builds a Game over an existing directory (used when tests -// must pre-seed character files and journal records before startup). -func harnessFromDir(t *testing.T, dir string) *harness { - t.Helper() - colors := &config.ColorsConfig{} - g := New(dir, colors, colors, config.ValidationConfig{}, 1) - hub := net.NewHub() - g.SetHub(hub) - stopTick := make(chan struct{}) - done := make(chan struct{}) - go func() { - defer close(done) - tick := time.NewTicker(2 * time.Millisecond) - defer tick.Stop() - for { - select { - case <-tick.C: - g.Ticks.Tick() - case <-stopTick: - return - } - } - }() - h := &harness{ - t: t, dir: dir, g: g, hub: hub, - sessions: make(map[string]*net.Session), - conns: make(map[string]*chanConn), - } - h.stop = func() { close(stopTick); <-done } - t.Cleanup(h.stop) - return h -} - -func writeSeedFile(t *testing.T, path string, data []byte) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0600); err != nil { - t.Fatal(err) - } -} - -func writeEconomyJournal(t *testing.T, dir string, records []economyRecord) { - t.Helper() - if err := os.MkdirAll(filepath.Join(dir, "economy"), 0700); err != nil { - t.Fatal(err) - } - var data []byte - for _, rec := range records { - line, err := json.Marshal(rec) - if err != nil { - t.Fatal(err) - } - data = append(data, line...) - } - if err := os.WriteFile(filepath.Join(dir, "economy", "journal.jsonl"), data, 0600); err != nil { - t.Fatal(err) - } -} - -func TestEconomyWagerDebitsDurallyAndSettles(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - }) - w := casinoWallet{g: h.g, p: p} - - var wagerErr error - h.g.RunOnOwner(func() { - _, wagerErr = w.Wager(10) - }) - h.barrier() - if wagerErr != nil { - t.Fatalf("wager failed: %v", wagerErr) - } - if p.Credits != 90 { - t.Fatalf("credits after wager = %d, want 90", p.Credits) - } - // The debit is durable, and the record stays OPEN: the stake is - // outstanding until the round resolves (recovery refunds open stakes - // after a crash). - if open := h.g.Economy.openStakes("alice"); len(open) != 1 || open[0].Credit != 10 { - t.Fatalf("outstanding stake after a successful wager: %+v", open) - } - // Resolution (round resolved or refunded) closes it. - h.g.RunOnOwner(func() { - w.ResolveStakes() - }) - h.barrier() - if open := h.g.Economy.openStakes("alice"); len(open) != 0 { - t.Fatalf("open stakes remain after resolution: %v", open) - } -} - -func TestEconomyFailedSaveRevertsAndVoids(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - }) - - // Make character saves fail: read-only characters directory. - charDir := filepath.Join(h.dir, "players", "characters") - if err := os.Chmod(charDir, 0500); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { os.Chmod(charDir, 0700) }) - - var wagerErr error - h.g.RunOnOwner(func() { - _, wagerErr = casinoWallet{g: h.g, p: p}.Wager(10) - }) - h.barrier() - if wagerErr != casino.ErrWalletPersistence { - t.Fatalf("wager error = %v, want ErrWalletPersistence", wagerErr) - } - // The in-memory mutation must have been reverted: nothing was charged. - if p.Credits != 100 { - t.Fatalf("credits after failed wager = %d, want 100 (reverted)", p.Credits) - } - // The record is void, not open: recovery must not compensate. - os.Chmod(charDir, 0700) - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Fatalf("void record remained open: %v", open) - } -} - -func TestEconomyRecoveryRefundsDurableLostWager(t *testing.T) { - dir := t.TempDir() - seedWorld(t, dir) - - // Character with the debit already durable (crash after save). - hash, _ := player.HashPassword("pw") - accData, _ := yaml.Marshal(&player.Account{Name: "alice", PasswordHash: hash}) - writeSeedFile(t, filepath.Join(dir, "players", "accounts", "alice.yaml"), accData) - p := player.New("alice") - p.Credits = 90 // post-debit balance - p.Account = "alice" - pData, _ := yaml.Marshal(p) - writeSeedFile(t, filepath.Join(dir, "players", "characters", "alice.yaml"), pData) - - // Open wager record: 10 credits debited (before=100, after=90). - writeEconomyJournal(t, dir, []economyRecord{{ - ID: "lost-wager", Player: "alice", Kind: "wager", - Chips: 0, Credit: 10, Status: "open", - CreditsBefore: 100, CreditsAfter: 90, - ChipsBefore: 0, ChipsAfter: 0, - }}) - - h := harnessFromDir(t, dir) - // Recovery must refund the stake: credits back to 100. - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 100 { - t.Fatalf("credits after recovery = %d, want 100 (refund)", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Fatalf("recovered record left open: %v", open) - } -} - -func TestEconomyRecoveryAppliesLostPayout(t *testing.T) { - dir := t.TempDir() - seedWorld(t, dir) - - // Character at pre-payout balance; the payout was journaled but the - // save never happened (crash before save). The player is owed it. - hash, _ := player.HashPassword("pw") - accData, _ := yaml.Marshal(&player.Account{Name: "bob", PasswordHash: hash}) - writeSeedFile(t, filepath.Join(dir, "players", "accounts", "bob.yaml"), accData) - p := player.New("bob") - p.Credits = 50 - p.Account = "bob" - pData, _ := yaml.Marshal(p) - writeSeedFile(t, filepath.Join(dir, "players", "characters", "bob.yaml"), pData) - - writeEconomyJournal(t, dir, []economyRecord{{ - ID: "lost-payout", Player: "bob", Kind: "payout", - Chips: 0, Credit: 25, Status: "open", - CreditsBefore: 50, CreditsAfter: 75, - ChipsBefore: 0, ChipsAfter: 0, - }}) - - h := harnessFromDir(t, dir) - loaded, err := h.g.AccountStore.LoadCharacter("bob") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 75 { - t.Fatalf("credits after recovery = %d, want 75 (payout re-applied)", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Fatalf("recovered record left open: %v", open) - } -} - -func TestShutdownSavesOnlineCharactersAndRefundsWagers(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - h.seedAccount("bob") - h.seedCharacter("alice", "alice") - h.seedCharacter("bob", "bob") - sA, _ := h.connect("alice") - pA := sA.Player - sB, _ := h.connect("bob") - pB := sB.Player - - // bob sits at a machine with a committed stake; shutdown must refund. - h.g.RunOnOwner(func() { - pB.Credits = 100 - h.g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots, MinBet: 1}, "bob") - h.g.Casino.SetMachineBet(1, "slots", "bob", 20, casinoWallet{g: h.g, p: pB}) - }) - h.barrier() - if pB.Credits != 80 { - t.Fatalf("bob's credits after wager = %d, want 80", pB.Credits) - } - - transportClosed := make(chan struct{}) - h.g.SetTransportStop(func() { close(transportClosed) }) - h.g.RequestShutdown("test") - // The shutdown goroutine closes the transports at the end of the - // ordered sequence; block for it. - select { - case <-transportClosed: - case <-time.After(5 * time.Second): - t.Fatal("transport stop was never invoked during shutdown") - } - - // Both characters must be saved with the wager refunded. - loadedA, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loadedA.Credits != pA.Credits { - t.Fatalf("alice save mismatch: file %d, memory %d", loadedA.Credits, pA.Credits) - } - loadedB, err := h.g.AccountStore.LoadCharacter("bob") - if err != nil { - t.Fatal(err) - } - if loadedB.Credits != 100 { - t.Fatalf("bob's file = %d, want 100 (stake refunded at shutdown)", loadedB.Credits) - } -} |
