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
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
|
package behavior
import (
"bytes"
"os"
"gopkg.in/yaml.v3"
)
// Structural room-ID references in non-room content files.
//
// Rooms carry their room references in typed structs (world.Room.RewriteRoomIDs
// walks those). Content outside data/rooms — courses, modules, triggers, items,
// objects, mobs — carries the same references in YAML that the admin editor
// reads and writes as generic maps. This walker remaps those references by
// field name so a room rename/swap cannot leave a course obstacle, a transport
// destination, or a trigger teleport pointing at the old ID.
//
// It is deliberately key-aware: only the known structural keys are touched, so
// an author's flag value, delay, quantity, or color index that happens to equal
// a room ID is never rewritten. That is the same rule world.Room.RewriteRoomIDs
// applies to typed fields.
// scalarRoomRefKeys are keys whose value is a single room ID.
var scalarRoomRefKeys = map[string]bool{
"room": true, // world.ExitDef.Room, behavior.Condition.Room
"teleport": true, // behavior.Step.Teleport
"destination": true, // game.ModDef.Destination
"on_fail": true, // game.ObstacleDef.OnFailRoom
"room_id": true, // game.ObstacleDef.RoomID
"start_room": true, // game.CourseConfig.StartRoom
}
// listRoomRefKeys are keys whose value is a list of room IDs.
var listRoomRefKeys = map[string]bool{
"wander_rooms": true, // world.RoomMob/RoomObject.WanderRooms
"despawn_rooms": true, // behavior.SpawnMobConfig.DespawnRooms
}
// RemapRoomRefs remaps every structural room-ID reference in a decoded YAML
// value per idMap (oldID -> newID), recursing through maps and lists. It
// returns whether anything changed. idMap is assumed to be a bijection.
//
// The value is expected to be the result of yaml.Unmarshal into any (maps and
// slices of any); unknown shapes are ignored.
func RemapRoomRefs(v any, idMap map[int]int) bool {
if len(idMap) == 0 {
return false
}
return remapRoomRefs(v, idMap)
}
func remapRoomRefs(v any, idMap map[int]int) bool {
switch node := v.(type) {
case map[string]any:
changed := false
for key, val := range node {
if scalarRoomRefKeys[key] {
if n, ok := asInt(val); ok {
if newID, mapped := idMap[n]; mapped && newID != n {
node[key] = newID
changed = true
}
continue
}
}
if listRoomRefKeys[key] {
if list, ok := val.([]any); ok && remapRoomRefList(list, idMap) {
changed = true
}
continue
}
if remapRoomRefs(val, idMap) {
changed = true
}
}
return changed
case map[any]any:
// yaml.v3 decodes into map[string]any, but a caller that round-tripped
// through a different decoder can hand us map[any]any.
changed := false
for key, val := range node {
ks, ok := key.(string)
if !ok {
continue
}
if scalarRoomRefKeys[ks] {
if n, ok := asInt(val); ok {
if newID, mapped := idMap[n]; mapped && newID != n {
node[key] = newID
changed = true
}
continue
}
}
if listRoomRefKeys[ks] {
if list, ok := val.([]any); ok && remapRoomRefList(list, idMap) {
changed = true
}
continue
}
if remapRoomRefs(val, idMap) {
changed = true
}
}
return changed
case []any:
changed := false
for _, item := range node {
if remapRoomRefs(item, idMap) {
changed = true
}
}
return changed
default:
return false
}
}
func remapRoomRefList(list []any, idMap map[int]int) bool {
changed := false
for i, item := range list {
n, ok := asInt(item)
if !ok {
continue
}
if newID, mapped := idMap[n]; mapped && newID != n {
list[i] = newID
changed = true
}
}
return changed
}
// MarshalYAML encodes a value as indented YAML (2-space), the same settings
// the admin editor and room-ref writers use.
func MarshalYAML(data any) ([]byte, error) {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(data); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// MarshalYAMLMap encodes a decoded YAML map with the same settings the admin
// editor uses, so a rewritten content file matches what the editor would write
// and cannot drop a key a typed struct does not model.
func MarshalYAMLMap(m map[string]any) ([]byte, error) {
return MarshalYAML(m)
}
// asInt coerces the numeric types a YAML decoder (or a JSON round-trip) can
// produce for an integer field.
func asInt(v any) (int, bool) {
switch n := v.(type) {
case int:
return n, true
case int8:
return int(n), true
case int16:
return int(n), true
case int32:
return int(n), true
case int64:
return int(n), true
case uint:
return int(n), true
case uint8:
return int(n), true
case uint16:
return int(n), true
case uint32:
return int(n), true
case uint64:
return int(n), true
case float64:
return int(n), true
case float32:
return int(n), true
default:
return 0, false
}
}
// RoomRefContentDirs are the data subdirectories whose files can carry a
// structural room reference outside data/rooms. Directories not listed here
// (help, players) never do, and players are migrated separately by the game.
var RoomRefContentDirs = []string{"courses", "modules", "triggers", "items", "objects", "mobs"}
// RoomRefFileChange is a content file whose room references moved, with its
// original and rewritten content. Callers stage these into a journaled
// transaction (or write them) and record the before/after images for undo.
type RoomRefFileChange struct {
Path string
Old []byte
Content []byte
}
// RewriteRoomRefsInDir walks dir and returns every YAML file whose structural
// room references changed under idMap, re-encoded. A file that cannot be read
// or parsed is skipped: one malformed content file must not block a room
// rename, and the startup validator reports it independently. A missing dir is
// not an error (the content family may be absent).
func RewriteRoomRefsInDir(dir string, idMap map[int]int) ([]RoomRefFileChange, error) {
if len(idMap) == 0 {
return nil, nil
}
var changes []RoomRefFileChange
err := WalkYAMLDir(dir, func(path, id string, data []byte) error {
var v any
if err := yaml.Unmarshal(data, &v); err != nil {
return nil
}
if !RemapRoomRefs(v, idMap) {
return nil
}
m, ok := v.(map[string]any)
if !ok {
return nil
}
encoded, err := MarshalYAMLMap(m)
if err != nil {
return err
}
changes = append(changes, RoomRefFileChange{Path: path, Old: data, Content: encoded})
return nil
})
if os.IsNotExist(err) {
return nil, nil
}
return changes, err
}
|