aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy_shutdown_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/economy_shutdown_test.go')
-rw-r--r--internal/game/economy_shutdown_test.go263
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)
+ }
+}