package game // Regression: the in-game `room insert`/`room remove` commands must commit all // their file changes as one journaled transaction. A failure partway through // (or a crash) must leave the world exactly as before, with no dangling exit // and no half-deleted room. import ( "os" "path/filepath" "testing" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) func TestRoomInsertCommitsTransactionally(t *testing.T) { dir := t.TempDir() writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n") writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n west: {room: 1}\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomInsert(sess, []string{"east", "New Room"}) // A now leads east to the new room, which leads east to B and west to A; // B's reciprocal west exit points at the new room. a := reloadRoom(t, g, 1) newID := a.Exits[world.East].Room if newID == 2 { t.Fatalf("insert did not create a new room: A.east=%d", newID) } newRoom := reloadRoom(t, g, newID) if newRoom.Exits[world.East].Room != 2 || newRoom.Exits[world.West].Room != 1 { t.Fatalf("new room exits wrong: %+v", newRoom.Exits) } b := reloadRoom(t, g, 2) if b.Exits[world.West].Room != newID { t.Fatalf("B.west = %d, want %d", b.Exits[world.West].Room, newID) } // A committed transaction leaves no journal behind. if entries, err := os.ReadDir(filepath.Join(dir, ".journal")); err == nil { for _, e := range entries { t.Fatalf("leftover journal after insert: %s", e.Name()) } } } // A failure committing the current-room rewrite must roll back the already // staged new room, leaving no dangling exit and no orphan file. func TestRoomInsertRollsBackOnCommitFailure(t *testing.T) { dir := t.TempDir() writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n") writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n west: {room: 1}\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) // Make the current room file unwritable by replacing it with a directory: // the transaction's replace of curPath fails during apply and rolls back. curPath := filepath.Join(dir, "rooms", "1.yaml") if err := os.Remove(curPath); err != nil { t.Fatal(err) } if err := os.Mkdir(curPath, 0o755); err != nil { t.Fatal(err) } g.roomInsert(sess, []string{"east", "New Room"}) // The insert picks the first unused ID (3). The rolled-back insert must // not leave that file behind. if _, err := os.Stat(filepath.Join(dir, "rooms", "3.yaml")); !os.IsNotExist(err) { t.Fatalf("rolled-back insert left the new room file (err=%v)", err) } // B's reciprocal exit must be unchanged (no dangling reference to a // room that was rolled back). b := reloadRoom(t, g, 2) if b.Exits[world.West].Room != 1 { t.Fatalf("B.west = %d, want 1 after rollback", b.Exits[world.West].Room) } } func TestRoomRemoveRollsBackOnCommitFailure(t *testing.T) { dir := t.TempDir() writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n") writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n") writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: {room: 2}\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) // Replace C's file with a directory so the transaction's replace of C fails // AFTER A has been staged. The old sequential code wrote A, only warned on // the C failure, and still deleted B — leaving A pointing at C while C did // not point back. The transaction must roll A back and keep B. cPath := filepath.Join(dir, "rooms", "3.yaml") if err := os.Remove(cPath); err != nil { t.Fatal(err) } if err := os.Mkdir(cPath, 0o755); err != nil { t.Fatal(err) } g.roomRemove(sess, []string{"east"}) // B must still exist: the remove must not have half-applied. if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil { t.Fatalf("rolled-back remove deleted B: %v", err) } // A must still point at B, not at the C rewrite that was rolled back. a := reloadRoom(t, g, 1) if a.Exits[world.East].Room != 2 { t.Fatalf("A.east = %d, want 2 after rollback", a.Exits[world.East].Room) } b := reloadRoom(t, g, 2) if b.Exits[world.West].Room != 1 || b.Exits[world.East].Room != 3 { t.Fatalf("B exits changed after rollback: %+v", b.Exits) } }