aboutsummaryrefslogtreecommitdiff
path: root/internal/behavior
diff options
context:
space:
mode:
Diffstat (limited to 'internal/behavior')
-rw-r--r--internal/behavior/roomrefs.go231
-rw-r--r--internal/behavior/roomrefs_test.go130
2 files changed, 361 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
+}
diff --git a/internal/behavior/roomrefs_test.go b/internal/behavior/roomrefs_test.go
new file mode 100644
index 0000000..9651b17
--- /dev/null
+++ b/internal/behavior/roomrefs_test.go
@@ -0,0 +1,130 @@
+package behavior
+
+// Structural room-reference remapping for non-room content files. The walker
+// must rewrite only known room-ID keys and must not touch author values that
+// merely happen to equal a room ID.
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+func decode(t *testing.T, src string) any {
+ t.Helper()
+ var v any
+ if err := yaml.Unmarshal([]byte(src), &v); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ return v
+}
+
+func TestRemapRoomRefsRewritesStructuralKeys(t *testing.T) {
+ src := `
+destination: 10
+on_fail: 11
+room_id: 12
+start_room: 13
+wander_rooms: [14, 15]
+despawn_rooms: [16]
+teleport: 17
+nested:
+ room: 18
+ condition:
+ room: 19
+`
+ v := decode(t, src)
+ idMap := map[int]int{
+ 10: 110, 11: 111, 12: 112, 13: 113, 14: 114, 15: 115,
+ 16: 116, 17: 117, 18: 118, 19: 119,
+ }
+ if !RemapRoomRefs(v, idMap) {
+ t.Fatal("expected changed=true")
+ }
+ out, err := MarshalYAMLMap(v.(map[string]any))
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := string(out)
+ for _, want := range []string{
+ "destination: 110", "on_fail: 111", "room_id: 112", "start_room: 113",
+ "teleport: 117",
+ } {
+ if !strings.Contains(s, want) {
+ t.Errorf("missing rewritten %q in:\n%s", want, s)
+ }
+ }
+ // Lists are rewritten in place.
+ for _, want := range []string{"- 114", "- 115", "- 116"} {
+ if !strings.Contains(s, want) {
+ t.Errorf("missing rewritten list entry %q in:\n%s", want, s)
+ }
+ }
+ for _, want := range []string{"room: 118", "room: 119"} {
+ if !strings.Contains(s, want) {
+ t.Errorf("missing nested rewritten %q in:\n%s", want, s)
+ }
+ }
+}
+
+func TestRemapRoomRefsLeavesAuthorValues(t *testing.T) {
+ // A delay, quantity, color index, and author flag value that equal room
+ // IDs must never be rewritten: only structural keys are room references.
+ src := `
+value: 10
+delay: 11
+quantity: 12
+color: "13"
+set_global_flags:
+ last_room: 14
+steps:
+ - wait: 15
+ credits: 16
+`
+ v := decode(t, src)
+ idMap := map[int]int{10: 110, 11: 111, 12: 112, 13: 113, 14: 114, 15: 115, 16: 116}
+ if RemapRoomRefs(v, idMap) {
+ t.Fatal("author values were remapped; only structural room keys may change")
+ }
+}
+
+func TestRewriteRoomRefsInDir(t *testing.T) {
+ dir := t.TempDir()
+ write := func(name, body string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("course.yaml", "start_room: 5\nobstacles:\n - room_id: 6\n")
+ write("unrelated.yaml", "value: 5\nname: keeps its value\n")
+
+ changes, err := RewriteRoomRefsInDir(dir, map[int]int{5: 50, 6: 60})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(changes) != 1 {
+ t.Fatalf("changed %d files, want 1: %+v", len(changes), changes)
+ }
+ if !strings.Contains(string(changes[0].Content), "start_room: 50") ||
+ !strings.Contains(string(changes[0].Content), "room_id: 60") {
+ t.Fatalf("course not rewritten: %s", changes[0].Content)
+ }
+ if changes[0].Old == nil {
+ t.Fatal("before-image not captured for undo")
+ }
+}
+
+// Missing content directories are not an error (a family may be absent).
+func TestRewriteRoomRefsInDirMissingDir(t *testing.T) {
+ changes, err := RewriteRoomRefsInDir(t.TempDir()+"/nope", map[int]int{1: 2})
+ if err != nil {
+ t.Fatalf("missing dir must not error: %v", err)
+ }
+ if len(changes) != 0 {
+ t.Fatalf("expected no changes, got %+v", changes)
+ }
+}