aboutsummaryrefslogtreecommitdiff
path: root/internal/game/cmd_room_txn_regression_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-19 03:52:04 -0400
committerhistoria <[not public]>2026-09-19 03:52:04 -0400
commitbab5dd653039d026eb3d4e46da7aafef7137fe7c (patch)
tree9a1d8686dbcef8c145e3593511153768ae08962c /internal/game/cmd_room_txn_regression_test.go
parent0e60567a7a0addb9421769ee7be7853bfa8788cb (diff)
downloadthehouseoficarus-bab5dd653039d026eb3d4e46da7aafef7137fe7c.tar.gz
fix: journal in-game room insert/remove, rewrite non-room room refs on rename/swap, seed admin grid from edited room, type action switches
Diffstat (limited to 'internal/game/cmd_room_txn_regression_test.go')
-rw-r--r--internal/game/cmd_room_txn_regression_test.go119
1 files changed, 119 insertions, 0 deletions
diff --git a/internal/game/cmd_room_txn_regression_test.go b/internal/game/cmd_room_txn_regression_test.go
new file mode 100644
index 0000000..3b5e864
--- /dev/null
+++ b/internal/game/cmd_room_txn_regression_test.go
@@ -0,0 +1,119 @@
+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)
+ }
+}