From a8a67482bbff8297a96adff65d2fd7029ed998cc Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 21:36:36 -0400 Subject: refactor: replace economy journal with single-file pending-wager wallet --- internal/game/wallet_test.go | 240 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 internal/game/wallet_test.go (limited to 'internal/game/wallet_test.go') diff --git a/internal/game/wallet_test.go b/internal/game/wallet_test.go new file mode 100644 index 0000000..218ce36 --- /dev/null +++ b/internal/game/wallet_test.go @@ -0,0 +1,240 @@ +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 covers the stake credits and clears it in one save. + h.g.RunOnOwner(func() { + if err := w.PayStakeCover(10, []string{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) + } +} + +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) + } +} -- cgit v1.2.3