aboutsummaryrefslogtreecommitdiff
path: root/internal/behavior/roomrefs.go
blob: 49530926dbaa707375483efb7aed799e1ce06f87 (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
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
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
}

// 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) {
	var buf bytes.Buffer
	enc := yaml.NewEncoder(&buf)
	enc.SetIndent(2)
	if err := enc.Encode(m); err != nil {
		return nil, err
	}
	if err := enc.Close(); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

// 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
}