diff options
Diffstat (limited to 'internal/behavior/roomrefs.go')
| -rw-r--r-- | internal/behavior/roomrefs.go | 231 |
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 +} |
