package game // Single-file money wallet regressions: a wager debits and records a pending // stake in one atomic character save, a settlement credits and clears the // stakes it returns in one save, a failed save reverts, and startup refunds // anything left in flight. See internal/game/money.go for the design and // building_guide/concurrency.md for why two-phase recovery is out of scope. import ( "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 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 TestWagerDebitsDurablyAndLeavesPendingStake(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 wager casino.Wager var wagerErr error h.g.RunOnOwner(func() { wager, 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) } if len(p.PendingWagers) != 1 || p.PendingWagers[0].Credits != 10 || p.PendingWagers[0].ID != wager.ID { t.Fatalf("pending stake after wager = %+v, want one 10-credit stake %q", p.PendingWagers, wager.ID) } // The debit and the pending entry are already durable in the file. loaded, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } if loaded.Credits != 90 || len(loaded.PendingWagers) != 1 { t.Fatalf("durable state after wager = credits %d, pending %+v; want 90 and one stake", loaded.Credits, loaded.PendingWagers) } // A settlement that resolves the stake credits and clears it in one save. h.g.RunOnOwner(func() { if err := w.PayCredits(10, wager.ID); err != nil { t.Error(err) } }) h.barrier() if p.Credits != 100 || len(p.PendingWagers) != 0 { t.Fatalf("after covered settlement = credits %d, pending %+v; want 100 and none", p.Credits, p.PendingWagers) } } // TestWinningPayoutConsumesStakeInSameSave: PayCredits credits the payout and // removes the stakes it resolves in ONE character save, so the durable file // never holds a paid win with the stake still pending (which startup would // refund on top of the win). func TestWinningPayoutConsumesStakeInSameSave(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} wager, err := w.Wager(10) if err != nil { t.Error(err) return } if err := w.PayCredits(20, wager.ID); err != nil { t.Error(err) } }) loaded, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } if loaded.Credits != 110 { t.Fatalf("credits after winning payout = %d, want 110", loaded.Credits) } if len(loaded.PendingWagers) != 0 { t.Fatalf("durable pending wagers after a winning payout = %+v, want none (credited and consumed in one save)", loaded.PendingWagers) } } func TestFailedWagerSaveReverts(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 || len(p.PendingWagers) != 0 { t.Fatalf("after failed wager = credits %d, pending %+v; want 100 and none (reverted)", p.Credits, p.PendingWagers) } os.Chmod(charDir, 0700) } // seedWageringCharacter writes a character whose stake is durable and still // pending, simulating a process death with a round in flight. func seedWageringCharacter(t *testing.T, dir, name string, credits int, pending []player.PendingWager) { t.Helper() hash, _ := player.HashPassword("pw") accData, _ := yaml.Marshal(&player.Account{Name: name, PasswordHash: hash}) writeSeedFile(t, filepath.Join(dir, "players", "accounts", name+".yaml"), accData) p := player.New(name) p.Credits = credits p.Account = name p.PendingWagers = pending pData, _ := yaml.Marshal(p) writeSeedFile(t, filepath.Join(dir, "players", "characters", name+".yaml"), pData) } func TestRecoveryRefundsPendingWagers(t *testing.T) { dir := t.TempDir() seedWorld(t, dir) seedWageringCharacter(t, dir, "alice", 90, []player.PendingWager{{ID: "lost-wager", Credits: 10}}) h := harnessFromDir(t, dir) 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 (stake refunded)", loaded.Credits) } if len(loaded.PendingWagers) != 0 { t.Fatalf("pending wagers after recovery = %+v, want none", loaded.PendingWagers) } } func TestRecoveryRefundsChipWagersInKind(t *testing.T) { dir := t.TempDir() seedWorld(t, dir) seedWageringCharacter(t, dir, "alice", 100, []player.PendingWager{{ID: "chips-only", Chips: 10}}) h := harnessFromDir(t, dir) loaded, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } if got := countChips(loaded); got != 10 { t.Fatalf("chips after recovery = %d, want 10 (stake refunded in kind)", got) } if len(loaded.PendingWagers) != 0 { t.Fatalf("pending wagers after recovery = %+v, want none", loaded.PendingWagers) } } 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) } }