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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
|
package game
import (
"fmt"
"os"
"strconv"
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/net"
)
func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string) {
if !g.checkAdmin(sess) {
sess.WriteLine("Unknown command.")
return
}
p := sess.Player
if p == nil {
return
}
var id1, id2 int
switch len(args) {
case 1:
id1 = p.RoomID
var err error
id2, err = strconv.Atoi(args[0])
if err != nil {
sess.WriteLine("Invalid room ID.")
return
}
case 2:
var err error
id1, err = strconv.Atoi(args[0])
if err != nil {
sess.WriteLine("Invalid room ID.")
return
}
id2, err = strconv.Atoi(args[1])
if err != nil {
sess.WriteLine("Invalid room ID.")
return
}
default:
sess.WriteLine("Usage: swapid <id> or swapid <id1> <id2>")
return
}
if id1 == id2 {
sess.WriteLine("Cannot swap a room with itself.")
return
}
path1, ok1 := g.World.GetRoomPath(id1)
path2, ok2 := g.World.GetRoomPath(id2)
if !ok1 {
sess.WriteLine(fmt.Sprintf("Room %d not found.", id1))
return
}
if !ok2 {
sess.WriteLine(fmt.Sprintf("Room %d not found.", id2))
return
}
// Refuse while non-admin players are online: swapping room IDs relocates
// every character whose state embeds those IDs, which is disruptive to
// normal play. Admins may stay online (their own state is migrated too).
for _, other := range g.Hub.AllSessions() {
if other == sess || other.Player == nil {
continue
}
if !g.checkAdmin(other) {
sess.WriteLine("Cannot swap room IDs while non-admin players are online.")
return
}
}
// Swap the two files' contents first. After this, path1 holds the old room
// id2's content and path2 holds the old room id1's content. Rebuilding the
// index then makes LoadRoom(id1)/LoadRoom(id2) return the swapped content,
// so the reference rewrite below operates on the correctly-positioned rooms
// and re-marshaling writes an `id:` field matching the new filename. The
// swap runs as one journal transaction (like handleRoomRename/the account
// lifecycle), so a crash mid-swap is rolled back at the next startup
// instead of leaving both rooms at wrong paths with no recovery record.
data1, rerr := os.ReadFile(path1)
if rerr != nil {
sess.WriteLine(fmt.Sprintf("Error reading room %d: %v", id1, rerr))
return
}
data2, rerr := os.ReadFile(path2)
if rerr != nil {
sess.WriteLine(fmt.Sprintf("Error reading room %d: %v", id2, rerr))
return
}
txn := g.newTxn()
if rerr := txn.Replace(path1, data2, 0644); rerr != nil {
sess.WriteLine(fmt.Sprintf("Error swapping rooms: %v", rerr))
return
}
if rerr := txn.Replace(path2, data1, 0644); rerr != nil {
sess.WriteLine(fmt.Sprintf("Error swapping rooms: %v", rerr))
return
}
if rerr := txn.Commit(); rerr != nil {
sess.WriteLine(fmt.Sprintf("Error swapping rooms: %v", rerr))
return
}
g.World.RebuildRoomIndex(g.DataDir)
// Remap every genuine room-ID reference (exit targets, trigger/on-enter
// room+teleport+despawn_rooms, mob wander_rooms) across all rooms. This
// replaces the old \b<id>\b regex, which also clobbered unrelated bare
// integers (delays, quantities, color indices) and author flag values.
// The two swapped rooms are always re-marshaled to correct their `id:`
// field; other rooms are re-marshaled only if a reference actually moved.
idMap := map[int]int{id1: id2, id2: id1}
// Rewrite every room that references the swapped IDs in ONE journal
// transaction. The old per-file writeRoomFile loop swallowed errors, so
// a crash or a failed write mid-loop left a half-migrated world with no
// recovery record.
rewriteTxn := g.newTxn()
updated := 0
for id := range g.World.RoomIndex() {
room, err := g.World.LoadRoom(id)
if err != nil {
continue
}
changed := room.RewriteRoomIDs(idMap)
if !changed && id != id1 && id != id2 {
continue
}
path, ok := g.World.GetRoomPath(id)
if !ok {
continue
}
data, merr := yaml.Marshal(room)
if merr != nil {
sess.WriteLine(fmt.Sprintf("Error rewriting room %d: %v", id, merr))
return
}
if rerr := rewriteTxn.Replace(path, data, 0644); rerr != nil {
sess.WriteLine(fmt.Sprintf("Error rewriting room %d: %v", id, rerr))
return
}
updated++
}
if rerr := rewriteTxn.Commit(); rerr != nil {
sess.WriteLine(fmt.Sprintf("Error rewriting room references: %v", rerr))
return
}
// Full runtime migration: Hub room buckets, casino room keys, world
// room-keyed state (ground items, seeded markers, object states), live
// mob instances, the room index, and every character's embedded room-ID
// state. Without this, a swap leaves live gameplay attached to the old
// locations until restart. This runs on the owner (swapid is executed
// from the owner command loop) and must not dispatch again.
g.MigrateRoomIDsOnOwner(idMap)
sess.WriteLine(fmt.Sprintf("Swapped room IDs %d and %d (%d files updated).", id1, id2, updated))
}
|