From 0b0b25222b3131ada79ff78c28eb8bf07d977deb Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 15 Sep 2026 15:35:52 -0400 Subject: feat: single-owner game state, transactional lifecycle, durable money on server stop --- internal/game/lifecycle_txn_test.go | 160 ++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 internal/game/lifecycle_txn_test.go (limited to 'internal/game/lifecycle_txn_test.go') diff --git a/internal/game/lifecycle_txn_test.go b/internal/game/lifecycle_txn_test.go new file mode 100644 index 0000000..f575298 --- /dev/null +++ b/internal/game/lifecycle_txn_test.go @@ -0,0 +1,160 @@ +package game + +// Account lifecycle transaction regressions: same-account races, stale +// session state, and injected filesystem failures must preserve +// identity/membership with no orphaned or duplicated characters. + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "thehouseoficarus/internal/net" +) + +// lifecycle setup: account "acct" with two characters and two menu-state +// sessions authenticated to it. +func setupAcct(t *testing.T, h *harness) (*net.Session, *net.Session) { + t.Helper() + h.seedAccount("acct", "pw", false) + h.seedCharacter("alice", "acct") + h.seedCharacter("bob", "acct") + s1 := h.connectMenu("acct") + s2 := h.connectMenu("acct") + return s1, s2 +} + +func assertCharFiles(t *testing.T, h *harness, want ...string) { + t.Helper() + names, err := h.g.AccountStore.ListCharacterNames() + if err != nil { + t.Fatalf("list characters: %v", err) + } + have := map[string]bool{} + for _, n := range names { + have[n] = true + } + for _, w := range want { + if !have[w] { + t.Fatalf("expected character file %s to exist; files: %v", w, names) + } + } + if len(names) != len(want) { + t.Fatalf("unexpected character files: got %v, want %v", names, want) + } +} + +// TestLifecycleConcurrentRenameNoOrphan: two sessions of the same account +// rename the same character at once. Exactly one succeeds; files and the +// account list stay consistent (no orphaned characters, no duplicate list +// entries). +func TestLifecycleConcurrentRenameNoOrphan(t *testing.T) { + h := newHarness(t) + s1, s2 := setupAcct(t, h) + + h.g.RunOnOwner(func() { h.g.handleRenameChar(s1, "alice") }) + h.g.RunOnOwner(func() { h.g.handleRenameChar(s2, "alice") }) + + // Both sessions now hold a pending rename of alice; drive them to + // submit new names concurrently. + h.g.RunOnOwner(func() { h.g.handleRenameCharName(s1, "alicia") }) + h.g.RunOnOwner(func() { h.g.handleRenameCharName(s2, "alicia") }) + + h.barrier() + // Alicia exists exactly once, alice does not. + assertCharFiles(t, h, "alicia", "bob") + if !h.g.AccountStore.CharacterExists("alicia") { + t.Fatal("renamed character missing") + } +} + +// TestLifecycleStaleMenuAfterDelete: deleting a character from one session +// refreshes the other session's pending selection (it cannot confirm-delete +// a stale name afterwards). +func TestLifecycleStaleMenuAfterDelete(t *testing.T) { + h := newHarness(t) + s1, s2 := setupAcct(t, h) + + // Session 2 selects alice for deletion (pending). + h.g.RunOnOwner(func() { + h.g.handleDeleteChar(s2, "alice") // asks for confirmation + }) + if s2.PendingChar != "alice" { + t.Fatalf("expected pending delete of alice, got %q", s2.PendingChar) + } + + // Session 1 confirms the delete first (its own pending selection is + // "alice", mirroring the same menu state). + h.g.RunOnOwner(func() { + s1.PendingChar = "alice" + h.g.handleDeleteChar(s1, "DELETE alice") + }) + h.barrier() + + // Session 2's pending selection must have been invalidated. + if s2.PendingChar == "alice" { + t.Fatalf("stale pending delete survived the delete from another session") + } + if s2.Account != nil { + for _, c := range s2.Account.Characters { + if strings.EqualFold(c, "alice") { + t.Fatalf("deleted character still in another session's account list") + } + } + } +} + +// TestLifecycleRenameBeatsConnect: a character cannot be renamed while it +// is connected (the online check holds and no stale-name rename goes +// through). +func TestLifecycleRenameBeatsConnect(t *testing.T) { + h := newHarness(t) + h.seedAccount("carl", "pw", false) + h.seedCharacter("carl", "carl") + sess, conn := h.connect("carl") + + h.g.RunOnOwner(func() { + // Drive the rename menu directly while the character is online. + sess.PendingChar = "carl" + sess.State = net.StateRenameCharName + h.g.handleRenameCharName(sess, "carla") + }) + h.barrier() + if h.g.AccountStore.CharacterExists("carla") { + t.Fatalf("rename succeeded while character was online") + } + if !h.g.AccountStore.CharacterExists("carl") { + t.Fatalf("original character file vanished") + } + _ = conn +} + +// TestLifecycleInjectedFailureRollsBack: a failure mid-transaction (the +// account file cannot be written because its directory is read-only... the +// journal lives elsewhere, so use a permissions failure on the account dir) +// must leave both files untouched. +func TestLifecycleInjectedFailureRollsBack(t *testing.T) { + h := newHarness(t) + s1, _ := setupAcct(t, h) + + accDir := filepath.Join(h.dir, "players", "accounts") + if err := os.Chmod(accDir, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(accDir, 0700) }) + + // Rename via the transaction path (invoked from a menu session whose + // account dir is unwritable): the move succeeds on disk but the account + // list rewrite fails, so the whole transaction must roll back. + h.g.RunOnOwner(func() { + s1.PendingChar = "alice" + s1.State = net.StateRenameCharName + h.g.handleRenameCharName(s1, "alicia") + }) + h.barrier() + + os.Chmod(accDir, 0700) + // The character must be untouched: no alicia, alice still present. + assertCharFiles(t, h, "alice", "bob") +} -- cgit v1.2.3