aboutsummaryrefslogtreecommitdiff
path: root/internal/game/wallet_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 21:36:36 -0400
committerhistoria <[not public]>2026-09-18 21:36:36 -0400
commita8a67482bbff8297a96adff65d2fd7029ed998cc (patch)
treee84f8ba636c8f2edb4d2ff8b45d7cff7298fa7cc /internal/game/wallet_test.go
parentff04d26ce90d6b411a04a2fb59f7c24ceefb161f (diff)
downloadthehouseoficarus-a8a67482bbff8297a96adff65d2fd7029ed998cc.tar.gz
refactor: replace economy journal with single-file pending-wager wallet
Diffstat (limited to 'internal/game/wallet_test.go')
-rw-r--r--internal/game/wallet_test.go240
1 files changed, 240 insertions, 0 deletions
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)
+ }
+}