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) } }