From b3d4c616f59ad2519f3a0b77e3b47d6571cd2486 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 9 Jul 2026 19:29:17 -0400 Subject: feat: message actions are now sequences of messages with settable delays --- cmd/migrate-messages/main.go | 245 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 cmd/migrate-messages/main.go (limited to 'cmd/migrate-messages') diff --git a/cmd/migrate-messages/main.go b/cmd/migrate-messages/main.go new file mode 100644 index 0000000..81400a4 --- /dev/null +++ b/cmd/migrate-messages/main.go @@ -0,0 +1,245 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +func main() { + dataDir := os.Getenv("DATA_DIR") + if dataDir == "" { + dataDir = "data" + } + + var changed []string + err := filepath.WalkDir(dataDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || (!strings.HasSuffix(path, ".yaml") && !strings.HasSuffix(path, ".yml")) { + return nil + } + if migrated(path) { + changed = append(changed, path) + } + return nil + }) + if err != nil { + fmt.Fprintf(os.Stderr, "walk error: %v\n", err) + os.Exit(1) + } + + sort.Strings(changed) + if len(changed) == 0 { + fmt.Println("No files needed migration.") + } else { + fmt.Printf("Migrated %d file(s):\n", len(changed)) + for _, f := range changed { + fmt.Printf(" %s\n", f) + } + } +} + +func migrated(path string) bool { + data, err := os.ReadFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "read %s: %v\n", path, err) + return false + } + + var root any + if err := yaml.Unmarshal(data, &root); err != nil { + fmt.Fprintf(os.Stderr, "parse %s: %v\n", path, err) + return false + } + + modified := false + root = walk(root, "", &modified) + + if !modified { + return false + } + + out, err := yaml.Marshal(root) + if err != nil { + fmt.Fprintf(os.Stderr, "marshal %s: %v\n", path, err) + return false + } + if err := os.WriteFile(path, out, 0644); err != nil { + fmt.Fprintf(os.Stderr, "write %s: %v\n", path, err) + return false + } + return true +} + +// walk recursively walks a YAML tree with a parent-key context. +// The context tells us what array type we're inside (on_enter, on_use, sequences, etc.) +func walk(v any, parentKey string, modified *bool) any { + switch val := v.(type) { + case map[string]any: + return migrateMap(val, parentKey, modified) + case []any: + return migrateSlice(val, parentKey, modified) + } + return v +} + +func migrateSlice(arr []any, parentKey string, modified *bool) any { + for i, el := range arr { + arr[i] = walk(el, parentKey, modified) + } + return arr +} + +// stepActionKeys are fields unique to StepAction that are NOT found on +// ModTriggerStep or Interaction. A map with any of these is definitely a +// StepAction, regardless of context. +var stepActionKeys = map[string]bool{ + "broadcast": true, + "broadcast_global": true, + "spawn_mob": true, + "despawn_mob": true, + "give_item": true, + "take_item": true, + "teleport": true, + "heal": true, + "credits": true, + "aps_node": true, + "set_global_flags": true, + "set_player_flags": true, +} + +func hasStepActionKey(m map[string]any) bool { + for k := range m { + if stepActionKeys[k] { + return true + } + } + return false +} + +// contextIsStepAction returns true when the parent key indicates this array +// contains StepAction entries (as opposed to ModTriggerStep or Interaction). +func contextIsStepAction(parentKey string) bool { + switch parentKey { + case "on_enter": + return true + case "action": + return true + case "on_traverse": + // on_traverse entries have action: but entries themselves are Interactions. + // The action: inside them is a StepAction, handled by the "action" case above. + // The top-level entry message is handled by interaction logic. + return false + } + return false +} + +// isSequenceArray returns true for keys whose array entries are ModTriggerStep +// (which keeps the legacy message field — do not migrate). +func isSequenceArray(key string) bool { + return key == "sequence" +} + +// isInteractionArray returns true for keys that contain Interaction entries. +func isInteractionArray(key string) bool { + switch key { + case "on_use", "on_look", "on_kill", "on_traverse": + return true + } + return false +} + +func migrateMap(m map[string]any, parentKey string, modified *bool) any { + _, hasMsg := m["message"].(string) + + // --- Step 1: migrate legacy message: on StepAction maps --- + // A map is a StepAction if: + // a) It has a unique StepAction key (set_player_flags, broadcast, etc.), OR + // b) The parent array is on_enter or action (our context tells us) + // We exclude sequence arrays (ModTriggerStep keeps its message field). + if hasMsg && (hasStepActionKey(m) || contextIsStepAction(parentKey)) && !isSequenceArray(parentKey) { + m["messages"] = []any{map[string]any{"message": m["message"], "delay": 0}} + delete(m, "message") + *modified = true + } + + // --- Step 2: recurse into children --- + // For map values, recurse with the key as context. For arrays under a key, + // the child will use that key. + for k, v := range m { + m[k] = walk(v, k, modified) + } + + // --- Step 3: interaction-level migration --- + // If this is an interaction entry (identified by being an element of an + // on_use/on_look/on_kill/on_traverse array) that still has a top-level + // message: string, fold it into the action's messages list. + if hasMsg && isInteractionArray(parentKey) { + topMsg := m["message"].(string) + delete(m, "message") + *modified = true + + act, ok := m["action"].(map[string]any) + if !ok { + act = map[string]any{} + m["action"] = act + } + + var msgs []any + if existing, ok2 := act["messages"].([]any); ok2 { + msgs = existing + } else if existingMsg, ok2 := act["message"].(string); ok2 { + msgs = []any{map[string]any{"message": existingMsg, "delay": 0}} + delete(act, "message") + } + msgs = append([]any{map[string]any{"message": topMsg, "delay": 0}}, msgs...) + act["messages"] = msgs + *modified = true + } + + // --- Step 4: trigger steps under "steps" key --- + // TriggerDef.Steps is []StepAction. But the key "steps" could also be + // ItemDef.CraftStep (craft items). We detect trigger steps by looking for + // trigger-specific parent fields (on_player_flag, on_global_flag, room, id). + // Actually we already recursed into children and migrated individual entries + // if they had stepActionKeys. The remaining case: a trigger step with only + // delay+message. We detect this after the children walk: if we're under + // a "steps" key and the parent map has trigger-specific keys, migrate the + // step's message. Since we've already recursed into the array entries, + // this step handles the case where individual step entries had only + // delay+message and weren't caught by hasStepActionKey. + // We detect this case in step 1 already: contextIsStepAction returns false + // for "steps". But if the parent map itself has trigger-like fields, we + // should treat "steps" as StepAction context. + // This is handled below. + + // --- Step 5: "steps" key whose parent is a trigger --- + // If this map has a "steps" key AND has trigger-specific fields, re-walk + // the steps array with StepAction context. This is done via the recursive + // walk already — but contextIsStepAction("steps") returns false. We need + // to detect trigger-def parentage and treat steps as StepAction context. + // + // TriggerDef keys: id, on_player_flag, on_global_flag, value, room, steps + // CraftDef keys: type, level, skill, ingredients, success, output_qty, fail, + // success_message, fail_message, start_message, end_message, steps + // + // If this map has trigger-specific keys, treat "steps" as StepAction context. + if hasTriggerLikeKeys(m) { + if stepsArr, ok := m["steps"].([]any); ok { + m["steps"] = walk(stepsArr, "on_enter", modified) + } + } + + return m +} + +func hasTriggerLikeKeys(m map[string]any) bool { + return m["on_player_flag"] != nil || m["on_global_flag"] != nil || + m["room"] != nil +} -- cgit v1.2.3