aboutsummaryrefslogtreecommitdiff
path: root/internal/behavior/roomrefs.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/behavior/roomrefs.go')
-rw-r--r--internal/behavior/roomrefs.go231
1 files changed, 231 insertions, 0 deletions
diff --git a/internal/behavior/roomrefs.go b/internal/behavior/roomrefs.go
new file mode 100644
index 0000000..4953092
--- /dev/null
+++ b/internal/behavior/roomrefs.go
@@ -0,0 +1,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
+}