From bab5dd653039d026eb3d4e46da7aafef7137fe7c Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 19 Sep 2026 03:52:04 -0400 Subject: fix: journal in-game room insert/remove, rewrite non-room room refs on rename/swap, seed admin grid from edited room, type action switches --- internal/behavior/roomrefs.go | 231 +++++++++++++++++++++++++++++++++++++ internal/behavior/roomrefs_test.go | 130 +++++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 internal/behavior/roomrefs.go create mode 100644 internal/behavior/roomrefs_test.go (limited to 'internal/behavior') 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) + } +} -- cgit v1.2.3