diff options
Diffstat (limited to 'internal/game/economy_shutdown_test.go')
| -rw-r--r-- | internal/game/economy_shutdown_test.go | 263 |
1 files changed, 263 insertions, 0 deletions
diff --git a/internal/game/economy_shutdown_test.go b/internal/game/economy_shutdown_test.go new file mode 100644 index 0000000..bd6c253 --- /dev/null +++ b/internal/game/economy_shutdown_test.go @@ -0,0 +1,263 @@ +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", "pw", false) + 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 journal must show the record settled (debit is durable). + if open := h.g.Economy.loadOpen(); len(open) != 0 { + t.Fatalf("open records remain after a successful wager: %v", open) + } +} + +func TestEconomyFailedSaveRevertsAndVoids(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + 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.loadOpen(); 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.loadOpen(); 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.loadOpen(); len(open) != 0 { + t.Fatalf("recovered record left open: %v", open) + } +} + +func TestShutdownSavesOnlineCharactersAndRefundsWagers(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + h.seedAccount("bob", "pw", false) + 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) + } +} |
