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 +++++++++++++++++++++++ data/.admin_history.json | 1 + data/objects/000_test_object.yaml | 20 +- data/objects/anvil.yaml | 20 +- data/objects/aps_tower.yaml | 14 +- data/objects/iron_gate.yaml | 22 ++- data/objects/tutorial_lever.yaml | 44 +++-- data/rooms/intro/1001.yaml | 314 ++++++++++++++++-------------- data/rooms/intro/1002.yaml | 166 ++++++++-------- data/rooms/intro/1003.yaml | 42 ++-- internal/admin/static/admin.css | 4 + internal/admin/static/intereditor.js | 168 ++++++++++------ internal/behavior/behavior.go | 29 ++- internal/behavior/types.go | 8 +- internal/game/act.go | 4 +- internal/game/act_effects.go | 38 ++-- internal/game/act_interaction_seq.go | 97 +++++++++ internal/game/act_interaction_seq_test.go | 216 ++++++++++++++++++++ internal/game/act_room.go | 6 +- internal/game/act_state.go | 2 +- internal/game/act_use_interaction.go | 19 -- internal/game/cmd_move.go | 3 +- internal/game/cmd_use.go | 27 +-- internal/game/condition_test.go | 4 +- internal/validate/checks.go | 11 ++ migrate-messages | Bin 0 -> 3758740 bytes 26 files changed, 1087 insertions(+), 437 deletions(-) create mode 100644 cmd/migrate-messages/main.go create mode 100644 data/.admin_history.json create mode 100644 internal/game/act_interaction_seq.go create mode 100644 internal/game/act_interaction_seq_test.go delete mode 100644 internal/game/act_use_interaction.go create mode 100755 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 +} diff --git a/data/.admin_history.json b/data/.admin_history.json new file mode 100644 index 0000000..3cebbbd --- /dev/null +++ b/data/.admin_history.json @@ -0,0 +1 @@ +{"history":[{"time":"2026-07-09T19:27:54-04:00","description":"Update object 000_test_object","file_path":"data/objects/000_test_object.yaml","old_content":"color: C4\ndescription: A sinister altar of red biological interfaces. Place scrap here with a bio identifier to produce biojunk. Type 'id' to begin.\ngather:\n skill: mining\n success:\n base: 0\n cap: 1\n per_level: 0\n tools:\n - axe\nhidden: false\nid: 000_test_object\nname: 000 test object\non_use:\n - action:\n messages: {}\nsafespot:\n decay_chance: 0\n decay_ticks: 0\n max_occupants: 1\n respawn_on_hide: false\n respawn_ticks: 0\n tier: 1\n unsafe_chance: 0\n","new_content":"color: C4\ndescription: A sinister altar of red biological interfaces. Place scrap here with a bio identifier to produce biojunk. Type 'id' to begin.\ngather:\n skill: mining\n success:\n base: 0\n cap: 1\n per_level: 0\n tools:\n - axe\nhidden: false\nid: 000_test_object\nname: 000 test object\non_use:\n - action:\n messages:\n - delay: 5\n message: asdfdfs\n - delay: 3\n message: sadfdfsadfs\nsafespot:\n decay_chance: 0\n decay_ticks: 0\n max_occupants: 1\n respawn_on_hide: false\n respawn_ticks: 0\n tier: 1\n unsafe_chance: 0\n","is_delete":false,"is_create":false}],"redo":null} \ No newline at end of file diff --git a/data/objects/000_test_object.yaml b/data/objects/000_test_object.yaml index e465048..2f1ed41 100644 --- a/data/objects/000_test_object.yaml +++ b/data/objects/000_test_object.yaml @@ -1,28 +1,28 @@ color: C4 -description: -- text: A sinister altar of red biological interfaces. Place scrap here with a bio identifier to produce biojunk. Type 'id' to begin. +description: A sinister altar of red biological interfaces. Place scrap here with a bio identifier to produce biojunk. Type 'id' to begin. gather: - bait: "" - depleted_message: "" - exhausted_message: "" - fail_message: "" - gather_message: "" - respawn_broadcast: "" skill: mining success: base: 0 cap: 1 per_level: 0 tools: - - axe + - axe hidden: false id: 000_test_object name: 000 test object +on_use: + - action: + messages: + - delay: 5 + message: asdfdfs + - delay: 3 + message: sadfdfsadfs safespot: decay_chance: 0 decay_ticks: 0 - max_block_size: "" max_occupants: 1 + respawn_on_hide: false respawn_ticks: 0 tier: 1 unsafe_chance: 0 diff --git a/data/objects/anvil.yaml b/data/objects/anvil.yaml index 4c8bc40..efcb8f2 100644 --- a/data/objects/anvil.yaml +++ b/data/objects/anvil.yaml @@ -1,9 +1,15 @@ -name: anvil -color: "FA" +color: FA description: - - text: "A heavy iron anvil for hammering metal into shape." + - text: A heavy iron anvil for hammering metal into shape. +name: anvil on_use: - - item_id: silver_bar - message: "You should use this with a mold at a furnace." - - item_id: gold_bar - message: "You should use this with a mold at a furnace." + - action: + messages: + - delay: 0 + message: You should use this with a mold at a furnace. + item_id: silver_bar + - action: + messages: + - delay: 0 + message: You should use this with a mold at a furnace. + item_id: gold_bar diff --git a/data/objects/aps_tower.yaml b/data/objects/aps_tower.yaml index 7919a5b..4b54d9d 100644 --- a/data/objects/aps_tower.yaml +++ b/data/objects/aps_tower.yaml @@ -1,9 +1,11 @@ -name: APS Tower color: "21" description: - - text: "A tall, skeletal metal tower with a pulsing cyan beacon at its apex. A datapad sync panel glows softly at the base." -inroom_description: "An APS Tower looms here, its beacon pulsing steadily." + - text: A tall, skeletal metal tower with a pulsing cyan beacon at its apex. A datapad sync panel glows softly at the base. +inroom_description: An APS Tower looms here, its beacon pulsing steadily. +name: APS Tower on_use: - - message: "You connect your datapad to the APS Tower. Coordinates triangulated and stored." - action: - aps_node: true + - action: + aps_node: true + messages: + - delay: 0 + message: You connect your datapad to the APS Tower. Coordinates triangulated and stored. diff --git a/data/objects/iron_gate.yaml b/data/objects/iron_gate.yaml index 5d6d53e..c61eebd 100644 --- a/data/objects/iron_gate.yaml +++ b/data/objects/iron_gate.yaml @@ -1,14 +1,16 @@ -color: "FA" +color: FA description: -- text: A heavy iron gate. It looks like it can be pushed open. + - text: A heavy iron gate. It looks like it can be pushed open. hidden: true name: iron gate on_use: -- condition: - global_flag: gate_open - not: true - value: true - message: You push the heavy iron gate open. - action: - set_global_flags: - gate_open: true + - action: + messages: + - delay: 0 + message: You push the heavy iron gate open. + set_global_flags: + gate_open: true + condition: + global_flag: gate_open + not: true + value: true diff --git a/data/objects/tutorial_lever.yaml b/data/objects/tutorial_lever.yaml index 9c105ba..371e70b 100644 --- a/data/objects/tutorial_lever.yaml +++ b/data/objects/tutorial_lever.yaml @@ -1,22 +1,26 @@ -name: stone lever -color: "FA" -hidden: true -inroom_description: "A stone lever protrudes from the base of the rock formation." +color: FA description: - - text: "A weathered stone lever set into a metal bracket. It looks like it can be pulled." + - text: A weathered stone lever set into a metal bracket. It looks like it can be pulled. +hidden: true +inroom_description: A stone lever protrudes from the base of the rock formation. +name: stone lever on_use: - - condition: - player_flag: unlocked_rock_cover - value: 1 - not: true - message: "You pull the stone lever down. A mechanism clicks into place somewhere in the glade." - action: - set_player_flags: - unlocked_rock_cover: 1 - - condition: - player_flag: unlocked_rock_cover - value: 1 - message: "You push the stone lever back up. The mechanism disengages with a soft clunk." - action: - set_player_flags: - unlocked_rock_cover: 0 + - action: + messages: + - delay: 0 + message: You pull the stone lever down. A mechanism clicks into place somewhere in the glade. + set_player_flags: + unlocked_rock_cover: 1 + condition: + not: true + player_flag: unlocked_rock_cover + value: 1 + - action: + messages: + - delay: 0 + message: You push the stone lever back up. The mechanism disengages with a soft clunk. + set_player_flags: + unlocked_rock_cover: 0 + condition: + player_flag: unlocked_rock_cover + value: 1 diff --git a/data/rooms/intro/1001.yaml b/data/rooms/intro/1001.yaml index 8960ea5..13255ba 100644 --- a/data/rooms/intro/1001.yaml +++ b/data/rooms/intro/1001.yaml @@ -1,164 +1,176 @@ block_transport: true color: "" description: - - text: |- - A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. The piston-powered staircase is firmly closed at the rear of the craft. + - text: |- + A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. The piston-powered staircase is firmly closed at the rear of the craft. - A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large {11}framed sign{/} hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark. + A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large {11}framed sign{/} hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark. exits: - down: - blocked_message: The piston-powered staircase is firmly closed. - condition: - all_of: [] - any_of: [] - global_flag: "" - has_item: "" - min_credits: 0 - not: false - player_flag: 1001_touchdown - value: null - room: 1002 - east: - room: 2001 + down: + blocked_message: The piston-powered staircase is firmly closed. + condition: + all_of: [] + any_of: [] + global_flag: "" + has_item: "" + min_credits: 0 + not: false + player_flag: 1001_touchdown + value: null + room: 1002 + east: + room: 2001 hazard: "" id: 1001 item_spawns: [] mobs: - - id: flight_attendant + - id: flight_attendant name: Inside Transport Shuttle objects: - - description: - - text: "We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].\n\nWhen you arrive at your final destination, please be aware of the following basic commands:\n\n{0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}\n{0B bold}Look:{/} {0A}look{/}, {0A}look {/}, and {0A}map{/}\n{0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory\n {0A}get {/}, {0A}drop {/}, {0A}wear {/}, {0A}remove {/}\n {0A}use on {/}\n {0A}eq{/} for equiped gear\n{0B bold}Comms:{/} {0A}talk {/} for NPC chat\n {0A}say {/} and {0A}global {/} for human chat\n{0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats. \n{0B bold}Combat{/} {0A}attack {/}\n{0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}\n{0B bold}Options{/} {0A}options{/} for a list of account options\n {0A}options {/} to change an option\n\nType {0C bold}what{/} for a list of possible common actions\n\nMake sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!" - hidden: true - name: framed sign - on_look: - - set_player_flags: - 1001_look_sign: true - - description: - - text: A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level. - hidden: true - name: door - - description: - - text: A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments. - hidden: true - name: window - - aliases: - - cockpit - description: - - text: The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd. - hidden: true - name: instrument panel - - aliases: [] - description: - - text: That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously. - hidden: true - name: pilot + - description: + - text: "We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].\n\nWhen you arrive at your final destination, please be aware of the following basic commands:\n\n{0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}\n{0B bold}Look:{/} {0A}look{/}, {0A}look {/}, and {0A}map{/}\n{0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory\n {0A}get {/}, {0A}drop {/}, {0A}wear {/}, {0A}remove {/}\n {0A}use on {/}\n {0A}eq{/} for equiped gear\n{0B bold}Comms:{/} {0A}talk {/} for NPC chat\n {0A}say {/} and {0A}global {/} for human chat\n{0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats. \n{0B bold}Combat{/} {0A}attack {/}\n{0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}\n{0B bold}Options{/} {0A}options{/} for a list of account options\n {0A}options {/} to change an option\n\nType {0C bold}what{/} for a list of possible common actions\n\nMake sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!" + hidden: true + name: framed sign + on_look: + - set_player_flags: + 1001_look_sign: true + - description: + - text: A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level. + hidden: true + name: door + - description: + - text: A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments. + hidden: true + name: window + - aliases: + - cockpit + description: + - text: The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd. + hidden: true + name: instrument panel + - aliases: [] + description: + - text: That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously. + hidden: true + name: pilot on_enter: - - broadcast: "" - broadcast_global: "" - condition: - all_of: [] - any_of: [] - global_flag: "" - has_item: "" - min_credits: 0 - not: true - player_flag: 1001_welcome - value: null - delay: 5 - despawn_mob: "" - give_item: "" - heal: 0 - message: '{0B bold}Welcome to The House of Icarus{/}' - set_global_flags: {} - set_player_flags: {} - spawn_mob: null - take_item: "" - teleport: 0 - - broadcast: "" - broadcast_global: "" - condition: - all_of: [] - any_of: [] - global_flag: "" - has_item: "" - min_credits: 0 - not: true - player_flag: 1001_welcome - value: null - delay: 7 - despawn_mob: "" - give_item: "" - heal: 0 - message: '{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}' - set_global_flags: {} - set_player_flags: - 1001_welcome: true - spawn_mob: null - take_item: "" - teleport: 0 - - broadcast: "" - broadcast_global: "" - condition: - all_of: [] - any_of: [] - global_flag: "" - has_item: "" - min_credits: 0 - not: true - player_flag: 1001_welcome - value: null - delay: 7 - despawn_mob: "" - give_item: "" - heal: 0 - message: '{0B}Type{/} {0A}help newplayer{/} {0B}for the new player''s guide. That''s it. Explore and have fun.{/}' - set_global_flags: {} - set_player_flags: {} - spawn_mob: null - take_item: "" - teleport: 0 + - broadcast: "" + broadcast_global: "" + condition: + all_of: [] + any_of: [] + global_flag: "" + has_item: "" + min_credits: 0 + not: true + player_flag: 1001_welcome + value: null + delay: 5 + despawn_mob: "" + give_item: "" + heal: 0 + messages: + - delay: 0 + message: '{0B bold}Welcome to The House of Icarus{/}' + set_global_flags: {} + set_player_flags: {} + spawn_mob: null + take_item: "" + teleport: 0 + - broadcast: "" + broadcast_global: "" + condition: + all_of: [] + any_of: [] + global_flag: "" + has_item: "" + min_credits: 0 + not: true + player_flag: 1001_welcome + value: null + delay: 7 + despawn_mob: "" + give_item: "" + heal: 0 + messages: + - delay: 0 + message: '{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}' + set_global_flags: {} + set_player_flags: + 1001_welcome: true + spawn_mob: null + take_item: "" + teleport: 0 + - broadcast: "" + broadcast_global: "" + condition: + all_of: [] + any_of: [] + global_flag: "" + has_item: "" + min_credits: 0 + not: true + player_flag: 1001_welcome + value: null + delay: 7 + despawn_mob: "" + give_item: "" + heal: 0 + messages: + - delay: 0 + message: '{0B}Type{/} {0A}help newplayer{/} {0B}for the new player''s guide. That''s it. Explore and have fun.{/}' + set_global_flags: {} + set_player_flags: {} + spawn_mob: null + take_item: "" + teleport: 0 triggers: - - id: "" - on_global_flag: "" - on_player_flag: 1001_look_sign - room: 0 - steps: - - broadcast: "" - broadcast_global: "" - delay: 5 - despawn_mob: "" - give_item: "" - heal: 0 - message: The cabin shakes as the small craft touches down - set_global_flags: {} - set_player_flags: {} - spawn_mob: null - take_item: "" - teleport: 0 - - broadcast: "" - broadcast_global: "" - delay: 5 - despawn_mob: "" - give_item: "" - heal: 0 - message: The pistons hiss as the rear staircase opens - set_global_flags: {} - set_player_flags: {} - spawn_mob: null - take_item: "" - teleport: 0 - - broadcast: "" - broadcast_global: "" - delay: 0 - despawn_mob: "" - give_item: "" - heal: 0 - message: "" - set_global_flags: {} - set_player_flags: - 1001_touchdown: true - spawn_mob: null - take_item: "" - teleport: 0 - value: null + - id: "" + on_global_flag: "" + on_player_flag: 1001_look_sign + room: 0 + steps: + - broadcast: "" + broadcast_global: "" + delay: 5 + despawn_mob: "" + give_item: "" + heal: 0 + messages: + - delay: 0 + message: The cabin shakes as the small craft touches down + set_global_flags: {} + set_player_flags: {} + spawn_mob: null + take_item: "" + teleport: 0 + - broadcast: "" + broadcast_global: "" + delay: 5 + despawn_mob: "" + give_item: "" + heal: 0 + messages: + - delay: 0 + message: The pistons hiss as the rear staircase opens + set_global_flags: {} + set_player_flags: {} + spawn_mob: null + take_item: "" + teleport: 0 + - broadcast: "" + broadcast_global: "" + delay: 0 + despawn_mob: "" + give_item: "" + heal: 0 + messages: + - delay: 0 + message: "" + set_global_flags: {} + set_player_flags: + 1001_touchdown: true + spawn_mob: null + take_item: "" + teleport: 0 + value: null diff --git a/data/rooms/intro/1002.yaml b/data/rooms/intro/1002.yaml index ca34347..8863c60 100644 --- a/data/rooms/intro/1002.yaml +++ b/data/rooms/intro/1002.yaml @@ -1,80 +1,92 @@ -name: "Landing Pad" description: - - condition: - player_flag: 1002_intro_done - not: true - text: "A large, circular, concrete landing pad sits in the middle of a blue ocean with a transport shuttle taking up nearly all of the walkable area. A crowd of perhaps a dozen people are shambling out of a squat, doorless building to the north. A sign above the entryway reads \"Processing\"" - - text: "A large, circular, concrete landing pad sits empty in the middle of a blue ocean that stretches to the horizon. North is a concrete path through a squat, doorless building with sign above the entryway reading \"Processing\"" -objects: - - name: crowd of people - aliases: [line, group, passengers] - hidden: true - description: - - condition: - all_of: - - player_flag: 1002_intro_done - not: true - - player_flag: 1002_lined_up - text: "A mass of people still onto the crowded platform, blocking your exit!" - - condition: - all_of: - - player_flag: 1002_intro_done - not: true - - player_flag: 1002_lined_up - not: true - text: "A dozen or so passengers ready to leave, all with luggage. Their eyes are a mix of impatience, anxiety, and perhaps suspicion." - - name: landing craft - aliases: [shuttle, ship] - hidden: true - description: - - condition: - player_flag: 1002_intro_done - not: true - text: "The small white craft has short, cute wings, a wide snub-nosed cockpit, and the rear hatch opened into a staircase. The low hum of its power plant vibrates the platform as it idles. You might guess it's a 95 ton Type-QS Passenger Shuttle. Just a guess though." - - name: pilot - hidden: true - description: - - condition: - all_of: - - player_flag: 1002_intro_done - not: true - - player_flag: 1002_lined_up - text: "The pilot stands straight as if about to escort royalty looking down his nose a bit at the passengers. He is quickly and professionally checking tickets." - - name: building - aliases: [processing] - description: - - text: "A building with no door on this side that looks like a giant cup of tea spilled towards you. It's built from large chunks of gray rock that must've come from the surface of Vesta." - - name: sign - description: - - text: "The sign simply says 'Processing' in bold red letters. Beneath in smaller text it says 'Welcome to Vesta', seemingly added afterwards to try to give the sign some warmth." + - condition: + not: true + player_flag: 1002_intro_done + text: A large, circular, concrete landing pad sits in the middle of a blue ocean with a transport shuttle taking up nearly all of the walkable area. A crowd of perhaps a dozen people are shambling out of a squat, doorless building to the north. A sign above the entryway reads "Processing" + - text: A large, circular, concrete landing pad sits empty in the middle of a blue ocean that stretches to the horizon. North is a concrete path through a squat, doorless building with sign above the entryway reading "Processing" exits: - up: - room: 1001 - condition: - player_flag: always_false - blocked_message: "You have no ticket, or reason, to leave Vesta." - north: - room: 1003 - condition: - player_flag: 1002_lined_up - blocked_message: "A mass of people is still pouring out of the building!" - set_player_flags: - 1002_intro_done: true + north: + blocked_message: A mass of people is still pouring out of the building! + condition: + player_flag: 1002_lined_up + room: 1003 + set_player_flags: + 1002_intro_done: true + up: + blocked_message: You have no ticket, or reason, to leave Vesta. + condition: + player_flag: always_false + room: 1001 +name: Landing Pad +objects: + - aliases: + - line + - group + - passengers + description: + - condition: + all_of: + - not: true + player_flag: 1002_intro_done + - player_flag: 1002_lined_up + text: A mass of people still onto the crowded platform, blocking your exit! + - condition: + all_of: + - not: true + player_flag: 1002_intro_done + - not: true + player_flag: 1002_lined_up + text: A dozen or so passengers ready to leave, all with luggage. Their eyes are a mix of impatience, anxiety, and perhaps suspicion. + hidden: true + name: crowd of people + - aliases: + - shuttle + - ship + description: + - condition: + not: true + player_flag: 1002_intro_done + text: The small white craft has short, cute wings, a wide snub-nosed cockpit, and the rear hatch opened into a staircase. The low hum of its power plant vibrates the platform as it idles. You might guess it's a 95 ton Type-QS Passenger Shuttle. Just a guess though. + hidden: true + name: landing craft + - description: + - condition: + all_of: + - not: true + player_flag: 1002_intro_done + - player_flag: 1002_lined_up + text: The pilot stands straight as if about to escort royalty looking down his nose a bit at the passengers. He is quickly and professionally checking tickets. + hidden: true + name: pilot + - aliases: + - processing + description: + - text: A building with no door on this side that looks like a giant cup of tea spilled towards you. It's built from large chunks of gray rock that must've come from the surface of Vesta. + name: building + - description: + - text: The sign simply says 'Processing' in bold red letters. Beneath in smaller text it says 'Welcome to Vesta', seemingly added afterwards to try to give the sign some warmth. + name: sign on_enter: - - condition: - player_flag: 1002_lined_up - not: true - delay: 7 - message: "A young boy the crowd openly look you up and down a little bemused, as if you're heading the wrong direction." - - condition: - player_flag: 1002_lined_up - not: true - delay: 7 - message: "The pilot pops out of the hatch of the craft and calls out \"Shuttle to Passenger Barge Denali. Tickets out, please! Nice and orderly!\"" - - condition: - player_flag: 1002_lined_up - not: true - delay: 7 - message: "The people all shuffle into a line, dragging their luggage out of the path to clear the way for you." - set_player_flags: - 1002_lined_up: true + - condition: + not: true + player_flag: 1002_lined_up + delay: 7 + messages: + - delay: 0 + message: A young boy the crowd openly look you up and down a little bemused, as if you're heading the wrong direction. + - condition: + not: true + player_flag: 1002_lined_up + delay: 7 + messages: + - delay: 0 + message: The pilot pops out of the hatch of the craft and calls out "Shuttle to Passenger Barge Denali. Tickets out, please! Nice and orderly!" + - condition: + not: true + player_flag: 1002_lined_up + delay: 7 + messages: + - delay: 0 + message: The people all shuffle into a line, dragging their luggage out of the path to clear the way for you. + set_player_flags: + 1002_lined_up: true diff --git a/data/rooms/intro/1003.yaml b/data/rooms/intro/1003.yaml index c6d3d8a..5cd7d64 100644 --- a/data/rooms/intro/1003.yaml +++ b/data/rooms/intro/1003.yaml @@ -1,27 +1,29 @@ -id: 1003 -name: Processing Building +block_transport: false color: "" description: -- text: A short tunnel-like building with queue ropes and desks on each side, and signs directing "Departures" and "Arrivals" above each. An attendant stands doing paperwork on the Departures side. No one is on the Arrivals side. + - text: A short tunnel-like building with queue ropes and desks on each side, and signs directing "Departures" and "Arrivals" above each. An attendant stands doing paperwork on the Departures side. No one is on the Arrivals side. exits: - north: - room: 1004 - condition: - player_flag: 1003_talked - south: - room: 1002 -objects: [] + north: + condition: + player_flag: 1003_talked + room: 1004 + south: + room: 1002 +hazard: "" +id: 1003 item_spawns: [] mobs: -- id: spaceport_attendant + - id: spaceport_attendant +name: Processing Building +objects: [] on_enter: -- condition: - player_flag: 1003_first_time - not: true - delay: 5 - message: "{bold}The ground rumbles as the shuttle departs the pad behind you!{/}" - set_player_flags: - 1003_first_time: true -hazard: "" -block_transport: false + - condition: + not: true + player_flag: 1003_first_time + delay: 5 + messages: + - delay: 0 + message: '{bold}The ground rumbles as the shuttle departs the pad behind you!{/}' + set_player_flags: + 1003_first_time: true triggers: [] diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index 393fafd..b35d5e2 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -400,6 +400,7 @@ g.ud-hover:hover text{font-weight:bold} .ie-cond-leaf input[type="number"], .ie-cond-val{font-size:11px;padding:3px 6px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;font-family:monospace;flex:1;min-width:80px} .ie-cond-subval{font-size:11px;padding:3px 6px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;font-family:monospace;width:100px} +.ie-cond-eq{font-size:11px;color:#888;font-weight:bold;white-space:nowrap;flex-shrink:0} .ie-cond-rm{flex-shrink:0} .ie-cond-grp-rm{margin-left:auto} .ie-cond-addbar{display:flex;gap:3px;flex-wrap:wrap;margin-top:4px} @@ -419,6 +420,9 @@ g.ud-hover:hover text{font-weight:bold} .ie-kv-row{display:flex;gap:4px;align-items:center} .ie-kv-key,.ie-kv-val{font-size:11px;padding:2px 4px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;font-family:monospace;width:120px} .ie-kv-val{width:80px} +.ie-act-msg-delay,.ie-act-msg{font-size:11px;padding:3px 6px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace} +.ie-act-msg-delay{width:60px} +.ie-act-msg{flex:1} /* Interaction row layout: column. The single flex header row holds the +Add Required Item / +Add Condition / +Add Action buttons alongside the diff --git a/internal/admin/static/intereditor.js b/internal/admin/static/intereditor.js index b46c456..5cf39a1 100644 --- a/internal/admin/static/intereditor.js +++ b/internal/admin/static/intereditor.js @@ -5,7 +5,7 @@ // ── Condition leaf types ── var IE_COND_TYPES = [ - {type: 'global_flag', label: 'Global Flag', hint: 'world flag name'}, + {type: 'global_flag', label: 'Global Flag', hint: 'global flag name'}, {type: 'player_flag', label: 'Player Flag', hint: 'player flag name'}, {type: 'has_item', label: 'Has Item', hint: 'item ID'}, {type: 'min_credits', label: 'Min Credits', hint: 'amount'}, @@ -218,11 +218,18 @@ function ie_collectAct(rootEl) { if (!rootEl) return null; var r = {}; - // Message (rolled into action) - var msgEl = rootEl.querySelector('.ie-act-msg'); - if (msgEl) { - var msg = msgEl.value.trim(); - r.message = msg; + // Messages — collected from the extendable delay+message rows. + var msgsKv = rootEl.querySelector('.ie-act-kv[data-ie-act-key="messages"]'); + if (msgsKv) { + var list = []; + msgsKv.querySelectorAll('.ie-kv-row').forEach(function(row) { + var delayEl = row.querySelector('.ie-act-msg-delay'); + var msgEl = row.querySelector('.ie-act-msg'); + var d = delayEl ? parseInt(delayEl.value) || 0 : 0; + var m = msgEl ? msgEl.value.trim() : ''; + list.push({ message: m, delay: d }); + }); + r.messages = list; } // KV-type effects — include the map even when empty (as long as the @@ -233,6 +240,7 @@ function ie_collectAct(rootEl) { rootEl.querySelectorAll('.ie-act-kv').forEach(function(kvEl) { var key = kvEl.getAttribute('data-ie-act-key'); if (!key) return; + if (key === 'messages') return; var map = {}; kvEl.querySelectorAll('.ie-kv-row').forEach(function(row) { var kEl = row.querySelector('.ie-kv-key'); @@ -291,8 +299,6 @@ function ie_syncAllInteractions(data, secs) { var act = ie_collectAct(actRoot); if (act) data[p][idx].action = act; else delete data[p][idx].action; - // Clear any legacy top-level message on sync. - delete data[p][idx].message; } }); }); @@ -410,7 +416,7 @@ function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gp var h = '
'; h += '' + esc(label) + ''; if (isSearch) { - h += '
'; + h += ''; @@ -420,7 +426,8 @@ function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gp h += ''; } if (leafType === 'player_flag' || leafType === 'global_flag') { - h += ''; + h += '='; + h += ''; } h += ''; h += ''; @@ -434,14 +441,32 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) { var hasAny = false; var rows = ''; - // Message - var msg = (actionObj && actionObj.message !== undefined && actionObj.message !== null) ? String(actionObj.message) : ''; - var msgPresent = actionObj && Object.prototype.hasOwnProperty.call(actionObj, 'message'); - if (msgPresent) { + // Messages — extendable list of {delay, message} entries. Mirrors the KV-section + // pattern: when the 'messages' key is present we render the section with rows + // and an internal + Add; when it goes away (last row removed) the section + // disappears and the addbar + Add Message button reappears. There is no + // section-level X. + var msgsPresent = actionObj && Object.prototype.hasOwnProperty.call(actionObj, 'messages') && Array.isArray(actionObj.messages); + if (msgsPresent) { + var msgs = actionObj.messages; hasAny = true; - rows += '
Message'; - rows += ''; - rows += ''; + var rmFn = 'ie_removeMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ')'; + rows += '
'; + rows += '
Messages
'; + rows += '
'; + if (msgs.length === 0) { + rows += '
'; + rows += ''; + rows += '
'; + } else { + msgs.forEach(function(m, mi) { + rows += '
'; + rows += ''; + rows += '
'; + }); + } + rows += '
'; + rows += ''; rows += '
'; } @@ -477,12 +502,12 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) { rows += '
'; } else if (at.kind === 'checkbox') { rows += '
' + esc(at.label) + ''; - rows += ''; + rows += ''; rows += ''; rows += '
'; } else if (at.kind === 'search') { rows += '
' + esc(at.label) + ''; - rows += '
'; + rows += ''; rows += ''; rows += '
'; } else { @@ -503,7 +528,7 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) { if (at.kind === 'search') { rows += '
' + esc(at.label) + ''; - rows += '
'; + rows += ''; rows += ''; rows += '
'; } else { @@ -517,8 +542,8 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) { // Add effect buttons var addbar = '
'; - if (!msgPresent) { - addbar += ''; + if (!msgsPresent) { + addbar += ''; } IE_ACT_TYPES.forEach(function(at) { if (actionObj && Object.prototype.hasOwnProperty.call(actionObj, at.key)) { @@ -754,18 +779,6 @@ function ie_toggleCondNot(secId, secPath, idx, groupPath) { var ed = window._editorData; if (!ed) return; ie_syncCondEntry(ed, secId, secPath, idx); - var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null; - var group = ie_condAt(cond, groupPath); - if (!group) return; - - if (group.all_of || group.any_of) { - group.not = !group.not; - if (!group.not) delete group.not; - var nc = ie_condReplace(cond, groupPath, group); - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (nc) ed[secPath][idx].condition = nc; - else delete ed[secPath][idx].condition; - } ced_syncDOMToData(ed); window._ieRenderOnly(); } @@ -774,26 +787,6 @@ function ie_toggleNotLeaf(secId, secPath, idx, groupPath, ci) { var ed = window._editorData; if (!ed) return; ie_syncCondEntry(ed, secId, secPath, idx); - var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null; - var parent = ie_condAt(cond, groupPath); - if (!parent) return; - - var leaf; - if (ci === IE_ROOT_LEAF) { - leaf = parent; - } else { - if (!parent.all_of && !parent.any_of) { - leaf = parent; - } else { - var children = parent.all_of || parent.any_of; - if (typeof ci !== 'number' || ci < 0 || ci >= children.length) return; - leaf = children[ci]; - } - } - - leaf.not = !leaf.not; - if (!leaf.not) delete leaf.not; - ed[secPath][idx].condition = cond; ced_syncDOMToData(ed); window._ieRenderOnly(); } @@ -809,8 +802,8 @@ function ie_addActEffect(secId, secPath, idx, effectKey, scope) { if (!ed[secPath][idx].action) ed[secPath][idx].action = {}; var act = ed[secPath][idx].action; - if (effectKey === 'message') { - act.message = ''; + if (effectKey === 'messages') { + act.messages = []; } else { var at = IE_ACT_TYPES.find(function(a) { return a.key === effectKey; }); var at2 = IE_ACT_TYPES_SEQ.find(function(a) { return a.key === effectKey; }); @@ -898,6 +891,67 @@ function ie_removeKVRow(rowEl, secId, secPath, idx, effectKey) { window._ieRenderOnly(); } +// ── Add/Remove: Messages list row ── + +// ie_addMessage appends a blank delay+message row to the DOM (no data change +// yet — it joins the list on the next sync, the same way typing into an +// existing row does). The Messages section must already be present (created +// via + Add Message in the addbar). Focuses the new row's message input. +function ie_addMessage(secId, secPath, idx) { + var ed = window._editorData; + if (!ed) return; + ie_syncActEntry(ed, secId, secPath, idx); + var card = document.querySelector('.obj-card[data-card="' + secId + '"]'); + if (!card) return; + var rows = card.querySelectorAll('.obj-sub-row'); + var row = rows[idx]; + if (!row) return; + var list = row.querySelector('.ie-act-kv[data-ie-act-key="messages"] .ie-kv-list'); + if (!list) return; + var rowEl = document.createElement('div'); + rowEl.className = 'ie-kv-row'; + var rmFn = 'ie_removeMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ')'; + rowEl.innerHTML = ''; + list.appendChild(rowEl); + rowEl.querySelector('.ie-act-msg').focus(); +} + +// ie_removeMessage removes a message entry from the action's messages list. If +// the list becomes empty the entire messages key is dropped (and the section +// disappears on re-render). rowEl may be the X button itself. +function ie_removeMessage(rowEl, secId, secPath, idx) { + var ed = window._editorData; + if (!ed) return; + if (rowEl && rowEl.closest) { + var kvRow = rowEl.closest('.ie-kv-row'); + if (kvRow) rowEl = kvRow; + } + ie_syncActEntry(ed, secId, secPath, idx); + if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].action) return; + var act = ed[secPath][idx].action; + if (!act.hasOwnProperty('messages') || !Array.isArray(act.messages)) return; + + // Find which row index to remove by locating our row in the list. + var card = document.querySelector('.obj-card[data-card="' + secId + '"]'); + var subRows = card ? card.querySelectorAll('.obj-sub-row') : []; + var row = subRows[idx]; + if (row) { + var kvRows = row.querySelectorAll('.ie-act-kv[data-ie-act-key="messages"] .ie-kv-row'); + for (var ri = 0; ri < kvRows.length; ri++) { + if (kvRows[ri] === rowEl || kvRows[ri].contains(rowEl)) { + act.messages.splice(ri, 1); + break; + } + } + } + if (act.messages.length === 0) { + delete act.messages; + if (Object.keys(act).length === 0) delete ed[secPath][idx].action; + } + ced_syncDOMToData(ed); + window._ieRenderOnly(); +} + // ── Pre-render sync helpers ── function ie_syncCondEntry(ed, secId, secPath, idx) { @@ -940,8 +994,6 @@ function ie_syncActEntry(ed, secId, secPath, idx) { } else { delete ed[secPath][idx].action; } - // Clear any legacy top-level message on sync. - delete ed[secPath][idx].message; } // ── Shared interaction-style array-section renderer ── diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go index 58b5e6b..12eef0b 100644 --- a/internal/behavior/behavior.go +++ b/internal/behavior/behavior.go @@ -58,6 +58,16 @@ type NodeAction struct { ApsNode bool `yaml:"aps_node,omitempty"` } +// DelayedMessage is a single message with a delay (in ticks) to wait before +// showing it. Used by the interaction sequencer (on_use / on_look / on_kill / +// on_traverse); on_enter and trigger steps emit their Messages list +// immediately when the step fires (use the step's own Delay for inter-step +// spacing). +type DelayedMessage struct { + Message string `yaml:"message,omitempty"` + Delay int `yaml:"delay,omitempty"` +} + // Interaction is one conditional trigger entry shared by on_use, on_look // (objects) and on_kill (mobs). Item filters by the appropriate held/weapon // item depending on the interaction kind (see itemMatch on applyInteraction). @@ -70,7 +80,6 @@ type NodeAction struct { type Interaction struct { Item string `yaml:"item_id,omitempty"` Condition *Condition `yaml:"condition,omitempty"` - Message string `yaml:"message,omitempty"` Action *StepAction `yaml:"action,omitempty"` } @@ -78,25 +87,25 @@ type Interaction struct { // across all effect executors (on_enter steps, room/global triggers, talk // nodes, use/look/kill interactions, exit traversal). The embedded // NodeAction holds the inline-only effects (flags/items/teleport/heal/credits/ -// aps_node); the additional fields are the sequence-only effects (message, +// aps_node); the additional fields are the sequence-only effects (messages, // broadcasts, mob spawn/despawn, delay). Condition is evaluated per-step by // the on_enter / trigger schedulers; inline callers ignore it (the gating // happens at the parent Interaction.ExitDef level instead). type StepAction struct { NodeAction `yaml:",inline"` - Condition *Condition `yaml:"condition,omitempty"` - Message string `yaml:"message,omitempty"` - Broadcast string `yaml:"broadcast,omitempty"` - BroadcastGlobal string `yaml:"broadcast_global,omitempty"` - SpawnMob *SpawnMobConfig `yaml:"spawn_mob,omitempty"` - DespawnMob string `yaml:"despawn_mob,omitempty"` - Delay int `yaml:"delay,omitempty"` + Condition *Condition `yaml:"condition,omitempty"` + Messages []DelayedMessage `yaml:"messages,omitempty"` + Broadcast string `yaml:"broadcast,omitempty"` + BroadcastGlobal string `yaml:"broadcast_global,omitempty"` + SpawnMob *SpawnMobConfig `yaml:"spawn_mob,omitempty"` + DespawnMob string `yaml:"despawn_mob,omitempty"` + Delay int `yaml:"delay,omitempty"` } // IsTimed reports whether the step carries sequence semantics (any non-zero // field means it needs per-tick scheduling rather than synchronous printing). func (s StepAction) IsTimed() bool { - return s.Delay > 0 || s.Message != "" || s.Broadcast != "" || s.BroadcastGlobal != "" || + return s.Delay > 0 || len(s.Messages) > 0 || s.Broadcast != "" || s.BroadcastGlobal != "" || s.SpawnMob != nil || s.DespawnMob != "" || len(s.SetGlobalFlags) > 0 || len(s.SetPlayerFlags) > 0 || s.GiveItem != "" || s.TakeItem != "" || s.Teleport != 0 || s.Heal != 0 || diff --git a/internal/behavior/types.go b/internal/behavior/types.go index 521867a..f451400 100644 --- a/internal/behavior/types.go +++ b/internal/behavior/types.go @@ -51,7 +51,7 @@ const ( TypeMix ActionType = "mix" TypeConstruct ActionType = "construct" TypeTriggerModule ActionType = "trigger_module" - TypeUseInteraction ActionType = "use_interaction" + TypeInteractionSeq ActionType = "interaction_seq" ) type Action struct { @@ -117,12 +117,6 @@ type TalkData struct { PendingChosen bool } -type UseInteractionData struct { - Message string - Action *StepAction - ObjName string -} - type BurnData struct { ItemID string ToolSpeed float64 diff --git a/internal/game/act.go b/internal/game/act.go index 91f05a8..1fc2b1c 100644 --- a/internal/game/act.go +++ b/internal/game/act.go @@ -235,8 +235,8 @@ func (g *Game) AdvanceActions() { g.advanceTalk(sess, p) case behavior.TypeTriggerModule: g.advanceTriggerModule(sess, p) - case behavior.TypeUseInteraction: - g.advanceUseInteraction(sess, p) + case behavior.TypeInteractionSeq: + g.advanceInteractionSeq(sess, p) case behavior.TypeCombine: g.advanceCombine(sess, p) default: diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go index 82d5022..9fce264 100644 --- a/internal/game/act_effects.go +++ b/internal/game/act_effects.go @@ -46,7 +46,7 @@ const ( // scopeGlobal ignores p entirely. // // The fixed effect order (matching the original executors, normalized): -// 1. message (player/seq scopes — skipped for scopeGlobal) +// 1. messages (player/seq scopes — skipped for scopeGlobal) // 2. broadcast → all in room (seq/global scopes) // 3. broadcast_global → all online (seq/global scopes) // 4. set_global_flags (scopeGlobal only; player scopes handle globals in step 5 @@ -64,9 +64,8 @@ const ( // writePlayerMessage expands the message template (player name, flag value), // colorizes inline {spec}text{/} tags under the caller's color mode, and writes // it to sess. It is the single path used by every per-player message emission -// in the interaction system (applyStepAction step 1, applyInteraction's -// Interaction.Message, completeMove's on_traverse Message, and -// advanceUseInteraction's d.Message) so color tags render consistently. +// in the interaction system (applyStepAction step 1, the interaction sequencer, +// and the advance handlers) so color tags render consistently. func (g *Game) writePlayerMessage(sess *net.Session, msg string, playerName string, flagValue any) { if sess == nil || msg == "" { return @@ -88,10 +87,14 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi playerName = p.Name } - // 1. message — direct to the triggering session. Skipped for scopeGlobal - // (no per-player session is the target). + // 1. messages — direct to the triggering session. Skipped for scopeGlobal + // (no per-player session is the target). All Messages in the step emit in + // order; per-message delays are ignored here (they're honored by the + // interaction sequencer or handled as inter-step delays by on_enter/triggers). if sc != scopeGlobal { - g.writePlayerMessage(sess, step.Message, playerName, flagValue) + for _, msg := range step.Messages { + g.writePlayerMessage(sess, msg.Message, playerName, flagValue) + } } // 2 & 3. broadcast / broadcast_global — re-render color tags per recipient @@ -238,9 +241,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) { // applyInteraction fires the first matching entry in a list of conditional // interactions (on_use, on_look, on_kill, exit traversal). Returns true when // an entry matched and fired. The action runs at scopePlayer (or scopePlayerSeq -// if allowSeq is set, for on_kill which may broadcast/spawn). The -// interaction's Message is written to the player first (if non-empty), then -// the Action fires via applyStepAction. +// if allowSeq is set, for on_kill which may broadcast/spawn). // // itemMatch (optional) gates entries that carry an item_id: for on_look it // requires the player to have the item in inventory; for on_kill it requires @@ -260,9 +261,22 @@ func (g *Game) applyInteraction(sess *net.Session, p *player.Player, list []beha if it.Condition != nil && !g.checkCondition(sess, it.Condition) { continue } - g.writePlayerMessage(sess, it.Message, p.Name, nil) - g.applyStepAction(sess, p, it.Action, roomID, sc, nil) + g.runInteraction(sess, p, it.Action, roomID, sc, nil) return true } return false +} + +// runInteraction fires a single interaction's Action. If the action carries +// delayed Messages the player is locked for the duration (see the interaction +// sequencer); otherwise the effects apply synchronously. +func (g *Game) runInteraction(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) { + if step == nil { + return + } + if len(step.Messages) > 0 { + g.startInteractionSeq(sess, p, step, roomID, sc, flagValue) + return + } + g.applyStepAction(sess, p, step, roomID, sc, flagValue) } \ No newline at end of file diff --git a/internal/game/act_interaction_seq.go b/internal/game/act_interaction_seq.go new file mode 100644 index 0000000..8b515c4 --- /dev/null +++ b/internal/game/act_interaction_seq.go @@ -0,0 +1,97 @@ +package game + +import ( + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +// interactionSeqData is the payload carried by p.Action.Data for an in-flight +// delayed-message interaction sequence (on_use, on_look, on_kill, or +// on_traverse). Messages are emitted one-by-one with per-message delays; the +// trailing StepAction effects fire only after every message has been shown. If +// the player is interrupted (move, quit, new action, combat) cancelAction +// clears the pending Action and the trailing effects never run. +type interactionSeqData struct { + Msgs []behavior.DelayedMessage + Idx int + Step *behavior.StepAction + RoomID int + Scope effectScope + FlagVal any +} + +// startInteractionSeq fires the first delayed message (on the next tick, +// matching the trigger scheduler's "delay = ticks to wait before this step +// fires" rule with a floor of 1 so the first message ALWAYS arrives next tick +// even when delay is 0). The trailing non-message effects run once the last +// message has been emitted. +func (g *Game) startInteractionSeq(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) { + g.cancelAction(p) + + firstDelay := 0 + if len(step.Messages) > 0 { + firstDelay = step.Messages[0].Delay + } + if firstDelay < 1 { + firstDelay = 1 + } + + // Strip Messages from step before stashing — applyStepAction would otherwise + // re-emit them when the trailing effects run. + clone := *step + clone.Messages = nil + + p.Action = &behavior.Action{ + Type: behavior.TypeInteractionSeq, + WaitLeft: firstDelay, + Data: &interactionSeqData{ + Msgs: step.Messages, + Idx: 0, + Step: &clone, + RoomID: roomID, + Scope: sc, + FlagVal: flagValue, + }, + } +} + +// advanceInteractionSeq is the per-tick handler for TypeInteractionSeq +// actions. Each invocation drains any zero-delay messages that are ready, emits +// the current delayed message, then sets the wait for the next message (if any). +// After the last message the trailing effects fire and the action is cancelled. +func (g *Game) advanceInteractionSeq(sess *net.Session, p *player.Player) { + d, ok := p.Action.Data.(*interactionSeqData) + if !ok { + g.cancelAction(p) + return + } + + var playerName string + if p != nil { + playerName = p.Name + } + + // Drain zero-delay messages that have accumulated, then emit the current + // (delayed) one. Matches the trigger scheduler's drain-zero-delay pattern. + for d.Idx < len(d.Msgs) { + msg := d.Msgs[d.Idx] + d.Idx++ + g.writePlayerMessage(sess, msg.Message, playerName, d.FlagVal) + + if d.Idx >= len(d.Msgs) { + break + } + next := d.Msgs[d.Idx].Delay + if next > 0 { + p.Action.WaitLeft = next + return + } + } + + // All messages emitted — fire trailing effects. + if d.Step != nil { + g.applyStepAction(sess, p, d.Step, d.RoomID, d.Scope, d.FlagVal) + } + g.cancelAction(p) +} diff --git a/internal/game/act_interaction_seq_test.go b/internal/game/act_interaction_seq_test.go new file mode 100644 index 0000000..c3fc8fa --- /dev/null +++ b/internal/game/act_interaction_seq_test.go @@ -0,0 +1,216 @@ +package game + +import ( + "io" + "testing" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +type nopConn struct{} + +func (nopConn) ReadMessage() (string, error) { return "", io.EOF } +func (nopConn) Write(p []byte) (int, error) { return len(p), nil } +func (nopConn) Close() error { return nil } +func (nopConn) SetEcho(bool) error { return nil } + +func newTestGame() *Game { + return &Game{ + GlobalFlags: NewGlobalFlagStore(), + Combat: combat.NewTracker(), + } +} + +func newTestGameWithStore(t *testing.T) *Game { + g := newTestGame() + g.AccountStore = player.NewAccountStore(t.TempDir()) + return g +} + +func newTestSession(p *player.Player, acc *player.Account) *net.Session { + if p.Options == nil { + p.Options = make(map[string]any) + } + return &net.Session{Player: p, Conn: nopConn{}, State: net.StateGame, Account: acc} +} + +func newTestPlayer(name string) *player.Player { + return &player.Player{ + Name: name, + RoomID: 100, + Skills: map[player.SkillName]int{player.Hitpoints: player.XPForLevel(99)}, + HP: 50, + Options: make(map[string]any), + } +} + +func TestRunInteractionNoMessagesAppliesInstantly(t *testing.T) { + g := newTestGameWithStore(t) + p := newTestPlayer("test") + sess := newTestSession(p, nil) + + step := &behavior.StepAction{ + NodeAction: behavior.NodeAction{Heal: 10}, + } + + g.runInteraction(sess, p, step, 100, scopePlayer, nil) + + if p.Action != nil { + t.Error("expected no pending action for message-less interaction") + } + if p.HP != 60 { + t.Errorf("expected HP 60 after heal, got %d", p.HP) + } +} + +func TestRunInteractionWithMessagesStartsSequence(t *testing.T) { + g := newTestGame() + p := newTestPlayer("test") + p.Credits = 100 + sess := newTestSession(p, nil) + + step := &behavior.StepAction{ + NodeAction: behavior.NodeAction{Credits: 50}, + Messages: []behavior.DelayedMessage{ + {Message: "You feel a strange energy.", Delay: 3}, + }, + } + + g.runInteraction(sess, p, step, 100, scopePlayer, nil) + + if p.Action == nil { + t.Fatal("expected pending action for message-bearing interaction") + } + if p.Action.Type != behavior.TypeInteractionSeq { + t.Errorf("expected TypeInteractionSeq, got %v", p.Action.Type) + } + if p.Action.WaitLeft != 3 { + t.Errorf("expected WaitLeft 3, got %d", p.Action.WaitLeft) + } + if p.Credits != 100 { + t.Errorf("expected credits unchanged (100), got %d", p.Credits) + } +} + +func TestAdvanceInteractionSeqDrainsZeroDelay(t *testing.T) { + g := newTestGameWithStore(t) + p := newTestPlayer("test") + sess := newTestSession(p, nil) + + step := &behavior.StepAction{ + NodeAction: behavior.NodeAction{Heal: 10}, + Messages: []behavior.DelayedMessage{ + {Message: "First.", Delay: 0}, + {Message: "Second.", Delay: 0}, + {Message: "Third.", Delay: 0}, + }, + } + + g.startInteractionSeq(sess, p, step, 100, scopePlayer, nil) + + if p.Action == nil || p.Action.WaitLeft != 1 { + t.Fatalf("expected WaitLeft 1 (delay 0 floored), got Action:%v", p.Action) + } + + g.advanceInteractionSeq(sess, p) + + if p.Action != nil { + t.Error("expected action cancelled after draining all messages") + } + if p.HP != 60 { + t.Errorf("expected HP 60 after heal, got %d", p.HP) + } +} + +func TestAdvanceInteractionSeqHonorsDelay(t *testing.T) { + g := newTestGameWithStore(t) + p := newTestPlayer("test") + sess := newTestSession(p, nil) + + step := &behavior.StepAction{ + NodeAction: behavior.NodeAction{Heal: 10}, + Messages: []behavior.DelayedMessage{ + {Message: "First.", Delay: 2}, + {Message: "Second.", Delay: 3}, + }, + } + + g.startInteractionSeq(sess, p, step, 100, scopePlayer, nil) + + if p.Action == nil || p.Action.WaitLeft != 2 { + t.Fatalf("expected WaitLeft 2, got %v", p.Action) + } + p.Action.WaitLeft = 1 + + g.advanceInteractionSeq(sess, p) + + if p.Action == nil { + t.Fatal("expected action still running (second message pending)") + } + if p.Action.WaitLeft != 3 { + t.Errorf("expected WaitLeft 3 (second message delay), got %d", p.Action.WaitLeft) + } + if p.HP != 50 { + t.Error("heal should not have fired yet") + } + + g.advanceInteractionSeq(sess, p) + + if p.Action != nil { + t.Error("expected action cancelled after last message + heal") + } + if p.HP != 60 { + t.Errorf("expected HP 60 after heal, got %d", p.HP) + } +} + +func TestInteractionSeqCancelPreventsTrailingEffects(t *testing.T) { + g := newTestGame() + p := newTestPlayer("test") + sess := newTestSession(p, nil) + + step := &behavior.StepAction{ + NodeAction: behavior.NodeAction{Heal: 10}, + Messages: []behavior.DelayedMessage{ + {Message: "Hello.", Delay: 5}, + }, + } + + g.startInteractionSeq(sess, p, step, 100, scopePlayer, nil) + + if p.Action == nil { + t.Fatal("expected pending action") + } + + g.cancelAction(p) + + if p.Action != nil { + t.Error("expected action to be cancelled") + } + if p.HP != 50 { + t.Error("heal should not have fired after cancel") + } +} + +func TestApplyStepActionEmitsAllMessages(t *testing.T) { + g := newTestGameWithStore(t) + p := newTestPlayer("test") + sess := newTestSession(p, nil) + + step := &behavior.StepAction{ + NodeAction: behavior.NodeAction{Heal: 10}, + Messages: []behavior.DelayedMessage{ + {Message: "A.", Delay: 99}, + {Message: "B.", Delay: 99}, + }, + } + + g.applyStepAction(sess, p, step, 100, scopePlayer, nil) + + if p.HP != 60 { + t.Errorf("expected HP 60 after heal, got %d", p.HP) + } +} diff --git a/internal/game/act_room.go b/internal/game/act_room.go index 2b08bfa..a6acc21 100644 --- a/internal/game/act_room.go +++ b/internal/game/act_room.go @@ -52,8 +52,10 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) { if !sequenced { // Pure message-only steps — print synchronously, no side effects. for _, step := range steps { - if step.Message != "" { - sess.WriteLine(step.Message) + for _, msg := range step.Messages { + if msg.Message != "" { + sess.WriteLine(msg.Message) + } } } return diff --git a/internal/game/act_state.go b/internal/game/act_state.go index 3840c63..130b088 100644 --- a/internal/game/act_state.go +++ b/internal/game/act_state.go @@ -81,7 +81,7 @@ func (g *Game) playerActionDisplay(p *player.Player) string { return "constructing some " + a.TargetName case behavior.TypeTriggerModule: return "triggering " + a.TargetName - case behavior.TypeUseInteraction: + case behavior.TypeInteractionSeq: return "using " + a.TargetName } } diff --git a/internal/game/act_use_interaction.go b/internal/game/act_use_interaction.go deleted file mode 100644 index 9c64b56..0000000 --- a/internal/game/act_use_interaction.go +++ /dev/null @@ -1,19 +0,0 @@ -package game - -import ( - "thehouseoficarus/internal/behavior" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) advanceUseInteraction(sess *net.Session, p *player.Player) { - d, ok := p.Action.Data.(*behavior.UseInteractionData) - if !ok { - g.cancelAction(p) - return - } - g.writePlayerMessage(sess, d.Message, p.Name, nil) - g.applyStepAction(sess, p, d.Action, p.RoomID, scopePlayer, nil) - g.broadcastAction(sess, "%s uses the %s.", p.Name, d.ObjName) - g.cancelAction(p) -} diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index d05146a..4391b1f 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -186,8 +186,7 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) { // time) AFTER p.RoomID is set to the new room, so effect fields like // aps_node / teleport operate on the destination as their reference frame. if pendingInteraction != nil { - g.writePlayerMessage(sess, pendingInteraction.Message, p.Name, nil) - g.applyStepAction(sess, p, pendingInteraction.Action, p.RoomID, scopePlayer, nil) + g.runInteraction(sess, p, pendingInteraction.Action, p.RoomID, scopePlayer, nil) } g.AccountStore.SaveCharacter(p) diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index 322e5fc..a2cb135 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" @@ -99,16 +98,9 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string) continue } g.cancelAction(p) - p.Action = &behavior.Action{ - Type: behavior.TypeUseInteraction, - TargetName: def.Name, - WaitLeft: 1, - Data: &behavior.UseInteractionData{ - Message: ui.Message, - Action: ui.Action, - ObjName: def.Name, - }, - } + name := def.Name + g.broadcastAction(sess, "%s uses the %s.", p.Name, name) + g.runInteraction(sess, p, ui.Action, p.RoomID, scopePlayer, nil) return true } if len(def.OnUse) > 0 { @@ -292,16 +284,9 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, continue } g.cancelAction(p) - p.Action = &behavior.Action{ - Type: behavior.TypeUseInteraction, - TargetName: def.Name, - WaitLeft: 1, - Data: &behavior.UseInteractionData{ - Message: ui.Message, - Action: ui.Action, - ObjName: def.Name, - }, - } + name := def.Name + g.broadcastAction(sess, "%s uses the %s.", p.Name, name) + g.runInteraction(sess, p, ui.Action, p.RoomID, scopePlayer, nil) return } diff --git a/internal/game/condition_test.go b/internal/game/condition_test.go index f7a5dc6..59b0895 100644 --- a/internal/game/condition_test.go +++ b/internal/game/condition_test.go @@ -96,10 +96,10 @@ func TestCheckConditionGlobalFlag(t *testing.T) { t.Error("global flag truthy check should pass") } if g.checkCondition(sess, &behavior.Condition{GlobalFlag: "gate_open", Not: true}) { - t.Error("negated world flag should fail when set") + t.Error("negated global flag should fail when set") } if g.checkCondition(sess, &behavior.Condition{GlobalFlag: "missing"}) { - t.Error("missing world flag should not pass") + t.Error("missing global flag should not pass") } } diff --git a/internal/validate/checks.go b/internal/validate/checks.go index 47bbd1a..2e70323 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -1244,6 +1244,17 @@ func validateStepAction(prefix string, step *behavior.StepAction, itemIDs map[st return issues } + for i, msg := range step.Messages { + if msg.Delay < 0 { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: messages[%d].delay is negative (%d)", + prefix, i, msg.Delay), + }) + } + } + // Validate the embedded NodeAction subset. if step.GiveItem != "" && !itemIDs[step.GiveItem] { issues = append(issues, Issue{ diff --git a/migrate-messages b/migrate-messages new file mode 100755 index 0000000..21f3fc1 Binary files /dev/null and b/migrate-messages differ -- cgit v1.2.3