aboutsummaryrefslogtreecommitdiff
path: root/internal/game/cmd_room_txn_regression_test.go
blob: 3b5e864e32e9a5a37d12c851d25285b8d10ccc0b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
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)
	}
}