package game // Readiness regressions for the account-rename, disconnect, and durable-money // paths reviewed before world/casino expansion: a transport disconnect must // be race-free, renames must not break ownership or strand owed payouts, // reserved-loot pickups must never destroy items, and recovery failure paths // must retry or surface instead of silently dropping money. import ( "os" "path/filepath" "testing" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) // TestDisconnectRaceIsFreeAndNeverLost drives DisconnectTick (owner) while a // transport goroutine arms the disconnect (exactly net.Server.handleSession's // deferred Hub.Remove path). Guards the atomic disconnect flags: a plain // bool/int pair is a data race and can lose the disconnect entirely. func TestDisconnectRaceIsFreeAndNeverLost(t *testing.T) { h := newHarness(t) h.seedAccount("alice") h.seedCharacter("alice", "alice") sess, _ := h.connect("alice") started, finished := make(chan struct{}), make(chan struct{}) go func() { h.g.RunOnOwner(func() { close(started) for i := 0; i < 10000; i++ { h.g.DisconnectTick() } }) close(finished) }() <-started h.hub.Remove(sess) // transport goroutine; never on the owner <-finished if sessions := h.g.Hub.AllSessions(); len(sessions) != 0 { t.Fatalf("disconnect lost under the race: %d sessions remain", len(sessions)) } } // TestReservedPickupDoesNotDestroyItems: picking up a ground stack that // shares a name with another player's reserved stack must take only the // takeable stock — never half-remove the ground with nothing delivered. func TestReservedPickupDoesNotDestroyItems(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") writeSeedFile(t, filepath.Join(h.dir, "items", "chips.yaml"), []byte("id: chips\nname: chips\nstackable: true\n")) sess := &net.Session{Conn: newChanConn(), Player: p, State: net.StateGame} h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) if _, err := h.g.ItemStore.Load("chips"); err != nil { t.Error(err) return } h.g.World.AddGroundItem(1, "chips", 10) // A reserved stack of the same item sits after the takeable one: // the old mid-loop abort destroyed the already-removed half. h.g.World.AddReservedItem(1, "chips", 10, "bob") h.g.doGet(sess, "chips") total := countChips(p) + h.g.World.GroundItems(1)["chips"] if total != 20 { t.Errorf("pickup destroyed items: inventory+ground=%d, want 20", total) } }) } // TestOwedPayoutSurvivesCharacterRename: economy records resolve their // subject by the character's STABLE ID, so renaming the character between // the crash and recovery still delivers the owed payout. func TestOwedPayoutSurvivesCharacterRename(t *testing.T) { h := newHarness(t) h.seedAccount("acct") p := h.seedCharacter("alice", "acct") h.g.RunOnOwner(func() { p.ID = "stable-char-id" // production characters carry a stable ID p.Credits = 90 if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Error(err) return } if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { t.Error(err) return } if err := h.g.renameCharacterTxn("acct", "alice", "alicia"); err != nil { t.Error(err) return } h.g.recoverEconomy() loaded, err := h.g.AccountStore.LoadCharacter("alicia") if err != nil { t.Error(err) return } if loaded.Credits != 110 { t.Errorf("renamed character never receives owed payout: credits=%d, want 110", loaded.Credits) } }) } // TestAccountRenamePreservesAccessAndOwnership: renaming an account // rewrites the owned character files' owner field in the same transaction // and republishes stale sessions (still carrying the old cached name) so // both sessions stay authenticated and keep character access. func TestAccountRenamePreservesAccessAndOwnership(t *testing.T) { h := newHarness(t) s1, s2 := setupAcct(t, h) h.g.RunOnOwner(func() { h.g.handleRenameAccount(s1, "renamed") if !h.g.AccountStore.AccountExists("renamed") { t.Error("rename did not succeed") return } if s1.Account.Name != "renamed" || s2.Account.Name != "renamed" { t.Errorf("sessions remain bound to %q/%q, want renamed/renamed", s1.Account.Name, s2.Account.Name) } if !h.g.isMemberOf("renamed", "alice") { t.Error("renamed account cannot access its own character") } }) } // TestSettledSurrenderDoesNotRefundChipStake: a settlement payout — even a // partial one like a surrendered hand paying half a chip stake — proves the // round resolved, so recovery must not ALSO return the chip stake in kind. func TestSettledSurrenderDoesNotRefundChipStake(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") h.g.RunOnOwner(func() { p.Credits = 100 p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10}) w := casinoWallet{g: h.g, p: p} if _, err := w.Wager(10); err != nil { t.Error(err) return } // Surrender settles for half the stake; the crash lands before the // resolution marker. if err := w.PayCredits(5); err != nil { t.Error(err) return } h.g.recoverEconomy() loaded, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Error(err) return } if loaded.Credits != 105 || countChips(loaded) != 0 { t.Errorf("settled surrender recovered %d credits / %d chips; want 105 / 0", loaded.Credits, countChips(loaded)) } }) } // TestRecoverySaveFailureRetriesOnNextStartup: a TRANSIENT save failure // during recovery must not re-anchor the owed record to unreadable // checkpoints — the record keeps its original checkpoints and a later // recovery (after the disk is fixed) re-applies the owed payout. func TestRecoverySaveFailureRetriesOnNextStartup(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") charDir := filepath.Join(h.dir, "players", "characters") t.Cleanup(func() { os.Chmod(charDir, 0755) }) h.g.RunOnOwner(func() { p.Credits = 90 if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Error(err) return } if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { t.Error(err) return } if err := os.Chmod(charDir, 0500); err != nil { t.Error(err) return } h.g.recoverEconomy() // every save fails if err := os.Chmod(charDir, 0755); err != nil { t.Error(err) return } h.g.recoverEconomy() // retry with a healthy disk loaded, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Error(err) return } if loaded.Credits != 110 { t.Errorf("after fixing the disk and retrying: %d credits, want 110; open=%+v", loaded.Credits, h.g.Economy.mustLoadOpen(t)) } }) } // TestResolvedStakeEvidenceSurvivesJournalFailure: a resolution marker // that cannot be journaled (persistent append failure) leaves the stake // OPEN — never silently forgotten — and records the resolution in the // character file as durable evidence. Recovery then settles the stake // WITHOUT refunding it: the round genuinely resolved, and the file is the // proof. When the file save also fails, no evidence exists and recovery // applies the documented refund-on-lost-state policy instead. func TestResolvedStakeEvidenceSurvivesJournalFailure(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} if _, err := w.Wager(10); err != nil { t.Error(err) return } path := h.g.Economy.path h.g.Economy.path = h.dir // persistent append failure w.ResolveStakes() h.g.Economy.path = path if open := h.g.Economy.openStakes(economyKey(p)); len(open) != 1 { t.Errorf("unresolvable stake was silently dropped: open=%d", len(open)) } // The resolution is recorded durably in the character file. loaded, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Error(err) return } if len(loaded.ResolvedStakes) != 1 { t.Errorf("resolved-stake evidence not recorded: %+v", loaded.ResolvedStakes) return } h.g.recoverEconomy() loaded, err = h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Error(err) return } if loaded.Credits != 90 { t.Errorf("after recovery: credits=%d, want 90 (round resolved; evidence prevents a refund)", loaded.Credits) } if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Errorf("records left open after recovery: %+v", open) } if len(loaded.ResolvedStakes) != 0 { t.Errorf("stale resolved-stake evidence not pruned: %+v", loaded.ResolvedStakes) } }) } // TestUnjournalablePayoutPaidThroughCharacterFile: a payout that cannot be // journaled (persistent append failure) is still made durable through the // character file — the save is the commit point, so the win is real and // announced. The resolved stake is recorded as file evidence so recovery // neither refunds the stake (double pay) nor drops the round. func TestUnjournalablePayoutPaidThroughCharacterFile(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} if _, err := w.Wager(10); err != nil { t.Error(err) return } path := h.g.Economy.path h.g.Economy.path = h.dir err := w.PayCredits(20) h.g.Economy.path = path if err != nil { t.Errorf("character-file fallback failed: %v", err) return } if p.Credits != 110 { t.Errorf("fallback payout did not land: credits=%d, want 110", p.Credits) } // As the games do after a successful settlement: resolve the stake. w.ResolveStakes() h.g.recoverEconomy() loaded, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Error(err) return } if loaded.Credits != 110 { t.Errorf("after recovery: credits=%d, want 110 (win durable once; no stake refund on top)", loaded.Credits) } if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Errorf("open records: %+v", open) } }) }