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 }