diff options
| author | historia <[not public]> | 2026-07-09 22:15:54 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-09 22:15:54 -0400 |
| commit | ecba7f726f70b37126d852c38c7e3eec7b04d730 (patch) | |
| tree | 9129215c6e5015336fde1116395336d82bb952d1 | |
| parent | b3d4c616f59ad2519f3a0b77e3b47d6571cd2486 (diff) | |
| download | thehouseoficarus-ecba7f726f70b37126d852c38c7e3eec7b04d730.tar.gz | |
feat: old standalone trigger systems completely unified into trigger->condition->action system
75 files changed, 3144 insertions, 2989 deletions
diff --git a/building_guide/behaviors.md b/building_guide/behaviors.md index f358a33..3063677 100644 --- a/building_guide/behaviors.md +++ b/building_guide/behaviors.md @@ -478,25 +478,38 @@ talk: ### Interaction Reference -The `Interaction` struct is the shared shape used by `on_use`, `on_look`, `on_kill`, -`on_traverse`, on_enter steps, and trigger steps. When a list of interactions is evaluated, -the first whose condition passes wins and fires. +The unified **Trigger** shape is the shared wrapper used by every event block: `on_use`, +`on_look`, `on_kill`, `on_traverse`, `on_enter`, `on_exit`, `on_flag_change`, +`on_global_flag_change`, and global trigger files (`data/triggers/*.yaml`). When a block's +list of Triggers is evaluated, the first whose filters pass fires and its `steps` run as a +scripted sequence (first-match-wins). + +| Field | Scope | Description | +|-------|-------|-------------| +| `lock` | all | `true` = atomic (player can only `quit`) + resumable on reconnect. Default `false` = interruptable, not persisted. | +| `item_id` | on_use/on_look/on_kill | `on_use`: required item (`use <item> on <obj>`); empty = bare `use <obj>`. `on_look`: only fires if carrying this item. `on_kill`: only fires if wielding this weapon. | +| `condition` | all | Optional Trigger-level gate (Condition struct). | +| `steps` | all | Ordered list of Step entries run as a scripted sequence. | +| `on_player_flag` | on_flag_change only | Player flag this trigger watches. | +| `on_global_flag` | on_global_flag_change only | Global flag this trigger watches. | +| `value` | flag triggers | Optional value-match filter. | + +Talk node/option actions still use the NodeAction shape (`action:` with the effect fields +inline) — those have not changed. + +#### Step vocabulary + +Each Step can carry a `wait` (ticks to pause before the step fires) and any combination of +the effects below. All effects on a step fire together when the wait elapses. Messages are +plain strings — neither `{message, delay}` maps nor the old `delay` field exist. | Field | Type | Description | |-------|------|-------------| -| `item_id` | string | `on_use`: required item (empty = bare `use <obj>`). `on_look`: only fires if carrying this item. `on_kill`: only fires if wielding this weapon. | -| `condition` | Condition | Optional gate | -| `message` | string | Message shown to the player when this entry fires | -| `action` | StepAction | Optional effects (see below) | - -#### StepAction / NodeAction vocabulary - -The universal effect vocabulary used by all interaction types, talk node actions, on_enter -steps, and trigger steps: - -| Field | Type | Description | -|-------|------|-------------| -| `set_global_flags` | map[string]any | Set global flags (cascades other triggers) | +| `wait` | int | Ticks to wait before this step fires. `0`/omitted = next tick. | +| `messages` | []string | Plain strings sent to the triggering player only. Support `%p`/`%v` templates. | +| `broadcast` | string | Announce to all players in the room. | +| `broadcast_global` | string | Announce to all online players. | +| `set_global_flags` | map[string]any | Set global flags (shared by all players; cascades other triggers) | | `set_player_flags` | map[string]any | Set player flags (per-character, saved to YAML) | | `give_item` | string | Give one unit of an item to inventory | | `take_item` | string | Remove one unit of an item from inventory | @@ -504,13 +517,36 @@ steps, and trigger steps: | `heal` | int | Restore hitpoints (clamped to MaxHP) | | `credits` | int | Add (positive) or deduct (negative) credits | | `aps_node` | bool | Mark current room as discovered APS node | -| `message` | string | Direct message to the player (`%p`/`%v` supported) | -| `broadcast` | string | Announce to all in the room | -| `broadcast_global` | string | Announce to all online | -| `spawn_mob` | string/map | Spawn a transient mob | -| `despawn_mob` | string | Despawn all transient mobs of this id | -| `delay` | int | Ticks to wait (on_enter/triggers only) | -| `condition` | Condition | Per-step gate (on_enter/trigger steps only) | +| `spawn_mob` | string/map | Spawn a transient mob (see SpawnMobConfig below) | +| `despawn_mob` | string | Despawn all trigger-spawned mobs of this id | +| `condition` | Condition | Per-step gate; evaluated once when the sequence reaches this step | + +#### Condition vocabulary + +Conditions gate a Trigger (Trigger-level `condition`) or an individual step. A bare +`global_flag` / `player_flag` check passes when the flag is set to a truthy value; +`not: true` inverts. + +| Field | Description | +|-------|-------------| +| `global_flag` | Passes when the named global flag is truthy | +| `player_flag` | Passes when the named player flag is truthy | +| `value` | Match a specific (non-boolean) value | +| `not` | `true` inverts the entire condition | +| `has_item` | Passes when the player carries this item | +| `min_credits` | Passes when the player has at least this many credits | +| `room` | **New.** Passes when the triggering player is currently in this room — use to scope flag-change triggers | +| `all_of` | List of sub-conditions; all must pass | +| `any_of` | List of sub-conditions; any one must pass | + +#### Lock and first-match-wins + +- **First-match-wins:** entries in a block are walked top-to-bottom; the first whose + `item_id` and `condition` pass fires and runs its whole `steps` sequence. Other entries + are skipped. +- **Lock:** `lock: true` makes the sequence atomic (the player can only `quit` until it + finishes) and resumable across disconnect. Default `false` is interruptable by any verb + and not persisted. #### SpawnMobConfig @@ -538,9 +574,9 @@ Full config: ### On Kill — Mob Interactions -Mobs can define `on_kill` — fires when the mob is defeated. Standard loot `drops` hit the -ground first, then the first matching interaction fires. An entry with `item_id` only fires -if the player wields that weapon: +Mobs can define `on_kill` — a `[]Trigger` list that fires when the mob is defeated. Standard +loot `drops` hit the ground first, then the first matching Trigger fires. An entry with +`item_id` only fires if the player wields that weapon: ```yaml name: boss @@ -548,17 +584,17 @@ combat: ... on_kill: - condition: global_flag: boss_quest_active - message: "The boss crumbles to dust!" - action: - set_global_flags: - boss_slain: true - broadcast_global: "%p has slain the World Boss!" - spawn_mob: boss_add + steps: + - messages: ["The boss crumbles to dust!"] + set_global_flags: + boss_slain: true + broadcast_global: "%p has slain the World Boss!" + spawn_mob: boss_add - item_id: dragon_slayer - action: - give_item: boss_heart - set_global_flags: - dragon_slain: true + steps: + - give_item: boss_heart + set_global_flags: + dragon_slain: true ``` Task mob `on_kill` works the same way — fires when the work is completed: @@ -567,10 +603,10 @@ Task mob `on_kill` works the same way — fires when the work is completed: name: reactor panel task: ... on_kill: - - message: "The panel snaps into place." - action: - set_global_flags: - reactor_repaired: true - set_player_flags: - repaired_reactor: true + - steps: + - messages: ["The panel snaps into place."] + set_global_flags: + reactor_repaired: true + set_player_flags: + repaired_reactor: true ``` diff --git a/building_guide/courses.md b/building_guide/courses.md index e799002..d7d2877 100644 --- a/building_guide/courses.md +++ b/building_guide/courses.md @@ -91,7 +91,8 @@ Each obstacle room should have an `on_enter` message telling the player which ve name: "Ventilation Shaft - Corroded Wall" description: "A towering wall of corroded ventilation panels..." on_enter: - - message: "Type 'scramble' to climb the wall." + - steps: + - messages: ["Type 'scramble' to climb the wall."] exits: down: room: 200 diff --git a/building_guide/mobs.md b/building_guide/mobs.md index e7790c0..833fbfd 100644 --- a/building_guide/mobs.md +++ b/building_guide/mobs.md @@ -210,10 +210,11 @@ is per-instance in the room YAML (see Rooms > Mobs section above). ### On Kill Interactions -Mobs can define `on_kill` — fires when the mob is defeated. Standard loot `drops` hit the -ground first, then the first matching interaction fires. An entry with `item_id` only fires -if the player is wielding that weapon. See the [On Kill section of -behaviors](behaviors.md#on-kill--mob-interactions) for full examples. +Mobs can define `on_kill` — a `[]Trigger` list that fires when the mob is defeated. Standard +loot `drops` hit the ground first, then the first matching Trigger fires (first-match-wins). +An entry with `item_id` only fires if the player is wielding that weapon. See the +[On Kill section of behaviors](behaviors.md#on-kill--mob-interactions) for full examples, +and `triggers.md` for the complete Trigger/Step/Condition reference. ### Stealable Mobs diff --git a/building_guide/objects.md b/building_guide/objects.md index cb38e0c..958938d 100644 --- a/building_guide/objects.md +++ b/building_guide/objects.md @@ -39,8 +39,9 @@ objects: inroom_description: "A large framed sign is mounted near the cockpit door." description: "Safety instructions are printed in bold lettering." on_look: - - set_player_flags: - 1001_look_sign: true + - steps: + - set_player_flags: + 1001_look_sign: true ``` - **Identity comes from `name`.** Internally the object's id is the name, normalized @@ -52,7 +53,8 @@ objects: - **Partial-name siblings are fine.** `rusty sign` and `shiny sign` can coexist; `look sign` matches both with "which one?", while `look rusty sign` resolves directly. - Local objects support only the **passive subset**: `name`, `aliases`, `color`, `hidden`, - `inroom_description`, `description` (including conditional variants), and `on_look`. + `inroom_description`, `description` (including conditional variants), and `on_look` (a + `[]Trigger` list). - Interactable behavior (`gather`, `talk`, `use`, `safespot`, `steal`, `guard_mob`, `removal_item`, `on_use`, craft stations) **must** be standalone files; startup validation errors if those appear locally. @@ -77,10 +79,10 @@ on_use: - condition: global_flag: secret_passage_open not: true - message: "You pull the lever. Grinding echoes from the east." - action: - set_global_flags: - secret_passage_open: true + steps: + - messages: ["You pull the lever. Grinding echoes from the east."] + set_global_flags: + secret_passage_open: true ``` Room description hints at it: @@ -186,9 +188,10 @@ actions, and linear auto-advance. ### Interactions — on_use / on_look Objects can define `on_use` and/or `on_look` to react to player actions. Both take a list of -interactions; entries are checked top-to-bottom, the first whose `condition` passes wins. -The full `Interaction` shape (condition, message, action with the entire StepAction -vocabulary) is documented in the [Interaction Reference](behaviors.md#interaction-reference). +**Triggers**; entries are checked top-to-bottom, the first whose `item_id` and `condition` +pass fires, and its `steps` run as a scripted sequence. See `triggers.md` for the full +Trigger/Step/Condition reference, and `behaviors.md` for the Interaction/Step reference at a +glance. #### on_use — using items on objects @@ -203,25 +206,50 @@ on_use: - condition: global_flag: gate_open not: true - message: "You push the heavy iron gate open." - action: - set_global_flags: - gate_open: true + steps: + - messages: ["You push the heavy iron gate open."] + set_global_flags: + gate_open: true ``` -Item-specific: +A two-state object with first-match-wins and value-match conditions (from +`data/objects/tutorial_lever.yaml`): + +```yaml +name: stone lever +on_use: + - condition: + not: true + player_flag: unlocked_rock_cover + value: 1 + steps: + - messages: + - You pull the stone lever down. A mechanism clicks into place somewhere in the glade. + set_player_flags: + unlocked_rock_cover: 1 + - condition: + player_flag: unlocked_rock_cover + value: 1 + steps: + - messages: + - You push the stone lever back up. The mechanism disengages with a soft clunk. + set_player_flags: + unlocked_rock_cover: 0 +``` + +Item-specific (`use <item> on <obj>`): ```yaml name: bookshelf on_use: - item_id: dusty_tome - message: "The bookshelf slides aside, revealing a secret passage!" - action: - set_global_flags: - secret_passage_open: true + steps: + - messages: ["The bookshelf slides aside, revealing a secret passage!"] + set_global_flags: + secret_passage_open: true ``` -Puzzle interaction: +Puzzle interaction (first matching entry wins): ```yaml name: crystal slot @@ -229,13 +257,14 @@ on_use: - item_id: crystal_key condition: global_flag: crystal_inserted - message: "The crystal key is already in the slot." + steps: + - messages: ["The crystal key is already in the slot."] - item_id: crystal_key - message: "You insert the crystal key. It clicks into place." - action: - take_item: crystal_key - set_global_flags: - crystal_inserted: true + steps: + - messages: ["You insert the crystal key. It clicks into place."] + take_item: crystal_key + set_global_flags: + crystal_inserted: true ``` Quest item exchange: @@ -245,25 +274,26 @@ on_use: - item_id: ancient_scroll condition: player_flag: quest_started - message: "You place the scroll on the pedestal. The door rumbles open!" - action: - take_item: ancient_scroll - set_player_flags: - quest_complete: true - set_global_flags: - temple_door_open: true + steps: + - messages: ["You place the scroll on the pedestal. The door rumbles open!"] + take_item: ancient_scroll + set_player_flags: + quest_complete: true + set_global_flags: + temple_door_open: true ``` #### on_look — actions when examining an object `on_look` fires AFTER the object's description is shown. An entry with `item_id` only fires -if the player carries that item. Supports the full Interaction shape: +if the player carries that item. Same Trigger shape: ```yaml name: sign on_look: - - set_player_flags: - read_sign: true + - steps: + - set_player_flags: + read_sign: true ``` --- diff --git a/building_guide/rooms.md b/building_guide/rooms.md index 3834a56..602e449 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -111,9 +111,9 @@ exits: #### Exit on_traverse — actions when walked through -An exit can carry an `on_traverse` list. Interactions fire when the player moves through the -exit (not when it's blocked). The first whose condition passes wins. Supports the full -`Interaction` shape — condition, message, and action: +An exit can carry an `on_traverse` list of Triggers. The first whose filters pass fires when +the player moves through the exit (not when it's blocked). Supports the full Trigger shape — +`condition`, `item_id`, and a `steps` list. See `triggers.md` for the reference. ```yaml exits: @@ -123,8 +123,9 @@ exits: player_flag: lined_up blocked_message: "You're not in line yet." on_traverse: - - set_player_flags: - boarded_shuttle: true + - steps: + - set_player_flags: + boarded_shuttle: true ``` #### Hidden exits @@ -252,8 +253,9 @@ objects: inroom_description: "A large framed sign is mounted near the cockpit door." description: "Safety instructions are printed in bold lettering." on_look: - - set_player_flags: - 1001_look_sign: true + - steps: + - set_player_flags: + 1001_look_sign: true ``` See `objects.md` for the full object reference — local objects, file objects, behaviors, @@ -263,82 +265,128 @@ interactions, safespots, stealing, and hidden objects. ### On-enter scripts -Each `on_enter` step shows a `message`, optionally gated by a `condition`. Steps whose -condition fails are skipped. +`on_enter` is a `[]Trigger` list. The first entry whose `condition` (and `item_id`, if +present) passes fires, and its `steps` run as a scripted sequence (see `triggers.md` for the +full Trigger/Step reference). The classic welcome sequence in room 1001 is a single entry +whose steps are each gated by their own `condition`: ```yaml on_enter: - - message: "The guard barks: \"State your business!\"" - condition: - player_flag: talked_to_guard - not: true # only the first visit + - steps: + - condition: + not: true + player_flag: 1001_welcome + messages: + - "{0B bold}Welcome to The House of Icarus{/}" + wait: 5 + - condition: + not: true + player_flag: 1001_welcome + messages: + - "{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}" + set_player_flags: + 1001_welcome: true + wait: 7 + - condition: + not: true + player_flag: 1001_welcome + messages: + - "{0B}Type{/} {0A}help newplayer{/} {0B}for the new player's guide.{/}" + wait: 7 +``` + +Room 1002's on_enter is the same shape — condition per step, plain-string `messages`, and a +`wait` to pace the lines: - - message: "The guard nods. \"Back again?\"" - condition: - player_flag: talked_to_guard # subsequent visits +```yaml +on_enter: + - steps: + - condition: + not: true + player_flag: 1002_lined_up + messages: + - A young boy the crowd openly look you up and down a little bemused, as if you're heading the wrong direction. + wait: 7 + - condition: + not: true + player_flag: 1002_lined_up + messages: + - The pilot pops out of the hatch of the craft and calls out "Shuttle to Passenger Barge Denali. Tickets out, please! Nice and orderly!" + wait: 7 + - condition: + not: true + player_flag: 1002_lined_up + messages: + - 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 + wait: 7 ``` #### Timed sequences -A step may carry a `delay` (ticks to wait before firing) and/or set flags. If any step has a -delay or sets a flag, the whole sequence runs as a scheduled enter sequence; plain -message-only scripts print instantly. - -```yaml -on_enter: - - condition: { player_flag: boarded, not: true } - delay: 5 - message: "Some of the crowd look you up and down." - - condition: { player_flag: boarded, not: true } - delay: 5 - message: "The pilot calls out: \"Tickets, please!\"" - set_player_flags: - lined_up: true # opens an exit, flips a description, etc. - - condition: { player_flag: boarded, not: true } - message: "The crowd forms a single-file line." -``` +`wait: N` on a step is the number of ticks to pause **before** that step fires (replaces the +old `delay`). Messages are plain strings; effects on a step fire together when the wait +elapses. A step with no `wait` fires on the next tick. #### Step actions -On-enter steps support all the same actions as triggers: `broadcast`, `broadcast_global`, -`spawn_mob`, `despawn_mob`, `give_item`, `take_item`, `teleport`, `heal`, `credits`, -`aps_node`, `set_global_flags`, and `set_player_flags`. See the -[trigger step actions](triggers.md#step-actions) table. +On-enter steps support the full Step vocabulary: `messages`, `broadcast`, +`broadcast_global`, `spawn_mob`, `despawn_mob`, `give_item`, `take_item`, `teleport`, +`heal`, `credits`, `aps_node`, `set_global_flags`, `set_player_flags`, plus a per-step +`condition`. See the [Step vocabulary](triggers.md#step-vocabulary) table. ```yaml on_enter: - - condition: { player_flag: boss_summoned } - delay: 10 - broadcast: "The ground trembles..." - - condition: { player_flag: boss_summoned } - delay: 20 - broadcast: "A massive guardian emerges from the shadows!" - spawn_mob: - id: altar_guardian - owner_only: true - despawn_on_leave: true + - condition: + player_flag: boss_summoned + steps: + - wait: 10 + broadcast: "The ground trembles..." + - wait: 20 + broadcast: "A massive guardian emerges from the shadows!" + spawn_mob: + id: altar_guardian + owner_only: true + despawn_on_leave: true ``` -#### Step-level conditions +#### First-match-wins and migration + +Entries in `on_enter` are walked top-to-bottom; the first whose `condition` passes fires +and its steps run. The old on_enter ran **every** matching step, so migration wraps the old +step list into a single Trigger entry to preserve behavior. For new content where steps +are mutually exclusive, use multiple entries with distinct conditions. + +Notes: +- `wait: 0` (or omitted) fires on the next tick. +- Gate a sequence on a flag the sequence itself sets so it doesn't replay on return visits. +- A `lock: true` Trigger resumes on reconnect; unlocked sequences are not persisted across + disconnect. +- Setting player flags from on_enter steps fires flag-change triggers watching those flags. + +--- + +### On-exit scripts -Each step can have a `condition:` that gates it individually. Unlike the step's action -fields, the condition is evaluated **once** on entry — a flag set by a later step won't -cancel an earlier step. +`on_exit` is the mirror of `on_enter`: a `[]Trigger` list that fires when a player **leaves** +the room. It runs **before** `RoomID` is updated to the destination, so broadcasts and +`spawn_mob` resolve against the room the player is leaving. ```yaml -on_enter: +on_exit: - condition: - player_flag: 1001_welcome - not: true - delay: 5 - message: "Welcome to The House of Icarus" + player_flag: boarded + steps: + - broadcast: "The shuttle hatch clanks shut behind the departing passenger." + - set_player_flags: + left_pad: true ``` -Notes: -- `delay: 0` (or omitted) fires on the next tick. -- Gate a sequence on a flag the sequence itself sets so it doesn't replay on return visits. -- If a player disconnects mid-sequence it resumes on reconnect. -- Setting player flags from on_enter steps fires room triggers watching those flags. +Use on_exit for parting messages, closing out a spawn when a player departs, or recording +that the player has passed through an area. It obeys the same first-match-wins rule and the +same Step vocabulary as on_enter. (New event block — did not exist before the Trigger +unification.) --- @@ -384,34 +432,38 @@ mobs: --- -### Room triggers +### Room flag triggers -A room can carry a `triggers:` block. Each trigger watches a player or global flag and fires -a sequence of timed steps when the flag's value changes. See `triggers.md` for the full -reference. +A room that needs to react when a flag changes uses the `on_flag_change:` / `on_global_flag_change:` +blocks (these replace the old `triggers:` block). Each entry watches one flag, runs a `steps` +sequence when that flag changes, and resolves broadcasts/spawns against the room. See +`triggers.md` for the full reference. ```yaml -triggers: - - on_player_flag: 1001_look_sign +on_flag_change: + - on_player_flag: lever_pulled + condition: + room: 1001 # opt-in: only fires if the player is in this room steps: - - delay: 5 - message: "The cabin shakes as the small craft touches down" - - delay: 5 - message: "The pistons hiss as the rear staircase opens" + - wait: 5 + messages: ["The cabin shakes as the small craft touches down"] + - wait: 5 + messages: ["The pistons hiss as the rear staircase opens"] - set_player_flags: 1001_touchdown: true ``` -Room triggers are scoped to the room — broadcasts go to that room, mobs spawn there, and the -trigger only fires when the flag-setting player is in that room. For server-wide events, use -global triggers in `data/triggers/` instead. +Flag triggers listen **globally** — they fire regardless of where the flag is set. The old +room `triggers:` block was implicitly room-scoped; migration injects `condition: { room: +<id> }` to preserve that. Delete the `room` condition to make the trigger fire wherever the +player is. For server-wide events use global trigger files in `data/triggers/` instead. --- ### Conditions Reference -Conditions are used in exit gates, on-enter steps, room descriptions, on_use/on_look/on_kill -interactions, talk option guards, talk node conditions, and trigger value matching. +Conditions are used in exit gates, Trigger and step gates, room descriptions, talk option +guards, and flag-trigger value matching. #### Simple conditions @@ -455,6 +507,10 @@ condition: # Player has enough credits condition: min_credits: 50 + +# Player is currently in a specific room (use for flag-trigger scoping) +condition: + room: 1001 ``` #### Compound conditions @@ -507,10 +563,10 @@ on_use: - condition: global_flag: secret_door_open not: true - message: "You press the stone button. You hear grinding stone in the distance." - action: - set_global_flags: - secret_door_open: true + steps: + - messages: ["You press the stone button. You hear grinding stone in the distance."] + set_global_flags: + secret_door_open: true ``` **Room 7** — contains the door: diff --git a/building_guide/triggers.md b/building_guide/triggers.md index 38dbda6..7ef9b4c 100644 --- a/building_guide/triggers.md +++ b/building_guide/triggers.md @@ -1,7 +1,9 @@ ## Triggers -Triggers let you script a sequence of timed events that fire when a flag changes -value. +A **Trigger** is the universal wrapper for all event-driven behavior — objects being +used or looked at, rooms being entered or exited, mobs being killed, exits being +walked through, and flags changing value. Every event block is a list of Triggers; +the first whose filters pass runs its `steps` as a scripted sequence. ### Flags: Global vs Player State @@ -15,184 +17,325 @@ code-set `int(3)`. progress, "has read the sign," "paid the toll" — these are different for each player. Saved to the character YAML and persist across logins. -**Any flag change** — world or player, set from on_look, on_use, on_kill, talk nodes, -on-enter steps, exit on_traverse, trigger steps, or admin commands — can activate a -trigger. This is how you turn "player looked at the sign" into "spaceship landed and stairs -opened." +Any flag change — world or player, set from on_look, on_use, on_kill, talk nodes, +on_enter/on_exit steps, on_traverse, flag-change triggers, or admin commands — can fire +other triggers watching that flag. + +### The Trigger shape -### Trigger flavors +Every event block on every entity is `[]Trigger`. A Trigger has filters and an ordered +`steps` list. The first Trigger in a block whose `item_id` and/or `condition` pass fires; +its steps run as a scripted sequence. -Triggers come in two flavors: +```yaml +# event block: on_use, on_look, on_kill, on_enter, on_exit, on_traverse, +# on_flag_change, on_global_flag_change +<event>: + - lock: false # atomic + resumable (see Lock below) + item_id: "" # on_use/on_look/on_kill only + condition: # optional gate; see Condition vocabulary + player_flag: quest_started + steps: + - wait: 5 # ticks before this step fires + messages: # plain strings sent to the player + - "The mechanism grinds..." + broadcast: "" # to everyone in the room + broadcast_global: "" # to everyone online + set_player_flags: {} + set_global_flags: {} + give_item: "" + take_item: "" + teleport: 0 + heal: 0 + credits: 0 + spawn_mob: null # SpawnMobConfig (or bare string) + despawn_mob: "" + aps_node: false + on_player_flag: "" # on_flag_change only + on_global_flag: "" # on_global_flag_change only + value: null # value-match filter for flag triggers +``` -- **Room triggers** — defined in room YAML under a `triggers:` block. The room is - the action context (broadcasts go to that room, mobs spawn there). -- **Global triggers** — defined in `data/triggers/<id>.yaml`. These fire regardless - of where the flag-setting player is, and can broadcast to all online players. +Field meanings: -A trigger watches one flag (`on_player_flag` or `on_global_flag`). When that flag's value -actually changes (from unset/falsy to a truthy/new value), the trigger's steps -begin executing. Setting a flag to the same value it already has does **not** re-fire -the trigger. +| Field | Applies to | Description | +|---|---|---| +| `lock` | all | `true` = atomic + resumable (player can only `quit`; resumes on reconnect). Default `false` = interruptable by any verb, not persisted. | +| `item_id` | on_use/on_look/on_kill | `on_use`: required item (`use <item> on <obj>`); empty = bare `use <obj>`. `on_look`: only fires if carrying this item. `on_kill`: only fires if wielding this weapon. | +| `condition` | all | Optional gate evaluated when the event fires. See Condition vocabulary. | +| `steps` | all | Ordered list of Step entries run as a scripted sequence. | +| `on_player_flag` | on_flag_change only | Player flag this trigger watches. | +| `on_global_flag` | on_global_flag_change only | Global flag this trigger watches. | +| `value` | flag triggers | Optional value-match filter; trigger only fires when the flag changes to this value. | -### Quick Start: Landing Sequence +### Step vocabulary -A player looks at a sign, setting `1001_look_sign: true`. A room trigger in the -same room watches that flag and plays a landing sequence: +Each step in a `steps:` list can carry a `wait` (ticks to pause before this step fires) and +any combination of the effects below. All effects on a step fire simultaneously when the +wait elapses. + +| Field | Description | +|---|---| +| `wait` | Ticks to wait before this step fires. `0` or omitted = next tick. | +| `messages` | List of plain strings sent to the triggering player only. Supports `%p` (player name) and `%v` (flag value) templates. | +| `broadcast` | Text sent to everyone in the room. Inline color tags work; `\n` prefix is added automatically. | +| `broadcast_global` | Text sent to every online player. Useful for server-wide announcements. | +| `set_global_flags` | Map of global flags to set (shared by all players, cascades other triggers). | +| `set_player_flags` | Map of player flags to set (per-character, saved to YAML). | +| `spawn_mob` | Spawns a **transient** mob from a mob definition. String or SpawnMobConfig — see below. | +| `despawn_mob` | Removes all trigger-spawned mobs matching the given mob ID (and optionally owner). | +| `give_item` | Gives an item to the player's inventory. | +| `take_item` | Removes an item from the player's inventory. | +| `teleport` | Moves the player to a room ID. | +| `heal` | Restores hitpoints (clamped to MaxHP). | +| `credits` | Positive = award, negative = deduct credits. | +| `aps_node` | Marks this room's APS node as unlocked. | +| `condition` | Per-step gate (evaluated once when the sequence reaches this step). | + +Messages are **plain strings**, no longer `{message, delay}` maps. The `delay` field is +gone — use `wait` instead. + +### Condition vocabulary + +Conditions gate a Trigger (or an individual step). A bare `global_flag` / `player_flag` +check passes when the flag is **set to a truthy value**. `not: true` inverts any check. + +| Field | Description | +|---|---| +| `global_flag` | Passes when the named global flag is truthy. | +| `player_flag` | Passes when the named player flag is truthy. | +| `value` | Match a specific (non-boolean) value. | +| `not` | `true` inverts the entire condition. | +| `has_item` | Passes when the player carries this item. | +| `min_credits` | Passes when the player has at least this many credits. | +| `room` | **New.** Passes when the triggering player is currently in this room. Use it to scope flag-change triggers to a location. | +| `all_of` | List of sub-conditions; all must pass. | +| `any_of` | List of sub-conditions; any one must pass. | ```yaml -# data/rooms/intro/1001.yaml -triggers: - - on_player_flag: 1001_look_sign +condition: + all_of: + - global_flag: gate_open + - player_flag: paid_toll + - has_item: pass_stub + - not: true + player_flag: finished_quest + - room: 601 # only fires if the player is in room 601 +``` + +### First-match-wins + +Entries in an event block are walked top-to-bottom. The first whose `item_id` filter and +`condition` pass fires; its entire `steps` list runs and no further entries in that block +are evaluated for that event. This gates puzzle interactions cleanly: + +```yaml +on_use: + - item_id: crystal_key + condition: + global_flag: crystal_inserted steps: - - delay: 5 - message: "The cabin shakes as the small craft touches down" - - delay: 5 - message: "The pistons hiss as the rear staircase opens" - - set_player_flags: - 1001_touchdown: true + - messages: ["The crystal key is already in the slot."] + - item_id: crystal_key + steps: + - messages: ["You insert the crystal key. It clicks into place."] + take_item: crystal_key + set_global_flags: + crystal_inserted: true ``` -The sign object itself is unchanged — its `on_look` simply sets the flag. The trigger -and the object are cleanly separated. The sign is a local object in room `1001`: +For `on_enter` specifically, the old flat step list ran **every** matching step. Migration +wraps the old steps into a single Trigger entry, so all the old steps still run as one +sequence. New `on_enter` content should use multiple entries with distinct conditions when +the steps are mutually exclusive. + +### Lock + +```yaml +on_use: + - lock: true + condition: + global_flag: ritual_started + steps: + - messages: ["You begin the lock sequence. Typing anything except 'quit' is blocked until it finishes."] + - wait: 10 + spawn_mob: ritual_guardian + - wait: 10 + broadcast: "The ritual completes." +``` + +- `lock: true` — the sequence is **atomic** (the player can only `quit` while it runs) and + **resumable** (if they disconnect, the sequence resumes on reconnect). +- `lock: false` (default) — the sequence is **interruptable** by any verb the player types + and **not persisted** across disconnect. + +--- + +### Event blocks + +Every entity carries its event blocks as `[]Trigger`. Available blocks depend on the +entity: + +| Entity | Blocks | +|---|---| +| Object | `on_use`, `on_look` | +| Mob | `on_kill` | +| Exit (room YAML) | `on_traverse` | +| Room | `on_enter`, `on_exit` (NEW), `on_flag_change`, `on_global_flag_change` | +| Global file (`data/triggers/*.yaml`) | one Trigger per file (flag triggers) | + +- **on_use / on_look** — see `objects.md`. `item_id` filters which item triggers the entry. +- **on_kill** — see `mobs.md`. `item_id` filters by wielded weapon. +- **on_traverse** — on an exit definition; fires when the player moves through the exit + (not when it's blocked). The exit's own `condition:` / `blocked_message:` belong to the + exit gate, not the Trigger list. +- **on_enter** — fires after the player enters the room. +- **on_exit** (NEW) — fires when a player leaves the room, **before** `RoomID` is updated + to the destination. Broadcasts/spawn resolve against the old (departed) room. Mirror of + on_enter, useful for parting messages, closing spawns, or recording departures. +- **on_flag_change** / **on_global_flag_change** — flag-watch triggers, replacing the old + room `triggers:` block. See Flag triggers below. + +### Quick Start: Landing Sequence + +Room 1001 has a local `framed sign` object whose `on_look` plays the landing sequence +directly — there is no separate `triggers:` block. The object's trigger sets the initial +flag, waits, and lands the shuttle: ```yaml # data/rooms/intro/1001.yaml objects: - - name: sign + - name: framed sign + hidden: true on_look: - - set_player_flags: - 1001_look_sign: true + - steps: + - set_player_flags: + 1001_look_sign: true + - wait: 5 + - wait: 5 + - set_player_flags: + 1001_touchdown: true ``` -This separation is deliberate. The object sets flags. The trigger watches flags. -Any system that sets a player flag (`on_look`, `on_use`, `on_kill`, talk nodes, -on-enter steps, exit `on_traverse`) can activate a trigger watching that flag. +(Run off-screen filler steps omitted here for brevity; see the file for the full sequence.) +Setting `1001_touchdown` opens the `down` exit (gated by `player_flag: 1001_touchdown`). +The `on_enter` block of room 1001 is a separate scripted welcome: ---- +```yaml +# data/rooms/intro/1001.yaml +on_enter: + - steps: + - condition: + not: true + player_flag: 1001_welcome + messages: + - "{0B bold}Welcome to The House of Icarus{/}" + wait: 5 + - condition: + not: true + player_flag: 1001_welcome + messages: + - "{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}" + set_player_flags: + 1001_welcome: true + wait: 7 + - condition: + not: true + player_flag: 1001_welcome + messages: + - "{0B}Type{/} {0A}help newplayer{/} {0B}for the new player's guide...{/}" + wait: 7 +``` + +Both the sign's on_look and the room's on_enter are the same Trigger shape — a top-level +`steps` list under the event block. Because each is wrapped in a single Trigger entry, the +whole sequence runs. -### Step Actions - -Each step in a trigger's `steps:` list can carry a `delay` (ticks to wait) and -one or more actions. All actions on a step fire simultaneously when the delay -elapses. - -| Field | Scope | Description | -| ------------------ | ------ | ----------------------------------------------------------------------------------------------------- | -| `delay` | both | Ticks to wait before this step fires. `0` or omitted = fire on the reach tick. | -| `message` | player | Text sent to the triggering player only. Supports `%p` (player name) and `%v` (flag value) templates. | -| `broadcast` | room | Text sent to everyone in the room. Inline color tags work; `\n` prefix is added automatically. | -| `broadcast_global` | world | Text sent to every online player. Useful for server-wide announcements. | -| `set_global_flags` | global | Mutates global flags (shared by all players). | -| `set_player_flags` | player | Mutates player flags (per-character). Only valid for `on_player_flag` triggers. | -| `spawn_mob` | room | Spawns a **transient** mob from a mob definition. See Transient Mobs below. | -| `despawn_mob` | room | Removes all trigger-spawned mobs matching the given mob ID (and optionally owner). | -| `give_item` | player | Gives an item to the player's inventory. | -| `take_item` | player | Removes an item from the player's inventory. | -| `teleport` | player | Moves the player to a room ID. | -| `heal` | player | Restores hitpoints. | +Any event that sets a player flag — `on_look`, `on_use`, `on_kill`, talk nodes, +on_enter/on_exit steps, on_traverse, other trigger steps — can in turn fire flag-change +triggers watching that flag. --- -### Room-Level Triggers +### Flag triggers -Room triggers go in the room YAML under a `triggers:` list. The room is -automatically the context for broadcasts and mob spawns. +`on_flag_change` and `on_global_flag_change` replace the old room `triggers:` block. Each +entry carries the flag it watches (`on_player_flag` or `on_global_flag`), an optional +`value` filter, an optional `condition`, and a `steps` list. -#### Example 1: Boss arena — puzzle unlocks a boss +Key behaviors: -Player activates an altar (sets a player flag), triggering a boss spawn: +- **Listen globally.** A flag-change trigger fires regardless of where the flag is set. + The event block lives in a room (for broadcasts/spawns to use that room) or in a global + file (see Global trigger files). +- **Room scoping is opt-in.** Use `condition: { room: <id> }` to restrict the trigger to + players currently in that room. Migration injects `{ room: <roomID> }` into migrated + room triggers to preserve the old room-scoped behavior; authors can delete it to make the + trigger fire globally. +- **Fires on the non-existent → existent transition.** Setting a flag that didn't exist + before counts as a change and fires the trigger. Setting a flag to the same value it + already has does **not** re-fire. +- **Triggers activate on becoming truthy.** Setting `true → false` does **not** fire. ```yaml -# data/rooms/dungeon/boss_chamber.yaml -triggers: - - on_player_flag: activated_altar +# A room watches a player flag and plays a sequence for that player +on_flag_change: + - on_player_flag: lever_pulled steps: - - broadcast: "The altar glows with an eerie light..." - - delay: 10 - broadcast: "The ground trembles beneath your feet." - - delay: 20 - broadcast: "A massive guardian emerges from the shadows!" - - spawn_mob: - id: altar_guardian - owner_only: true - despawn_on_leave: true - despawn_rooms: [450, 451, 452] + - broadcast: "The lever snaps back into place with a loud clunk." + - set_player_flags: + lever_pulled: false # reset so next pull re-fires ``` -The boss is `owner_only` — only the player who triggered it can interact with it. -It won't despawn as long as the owner stays in rooms 450, 451, or 452 (a 3-room -boss arena). When the owner leaves those rooms, the boss begins despawning. +To migrate the old room `triggers:` block, replace `triggers:` with `on_flag_change:` (for +`on_player_flag`) or `on_global_flag_change:` (for `on_global_flag`) and add +`condition: { room: <id> }` if you want the old room-scoped behavior. -#### Example 2: Story beat — timed cutscene after NPC conversation +### Value-matching flag triggers -An NPC conversation node sets `quest_ritual: started`. A room trigger plays a -dramatic sequence for that player: +By default a flag trigger fires when the watched flag becomes truthy. Add `value:` to +require a specific value — enabling multi-stage quests off one numeric flag: ```yaml -triggers: - - on_player_flag: quest_ritual +on_flag_change: + - on_player_flag: quest_stage + value: 1 steps: - - delay: 8 - message: "The elder begins to chant in a language you don't recognize." - - delay: 6 - message: "Wisps of light swirl around the altar." - - delay: 4 - message: "The ground beneath you shudders as the ritual reaches its peak." - - delay: 6 - broadcast: "A blinding flash fills the chamber!" - - teleport: 601 - message: "You open your eyes. You're somewhere else entirely." + - messages: ["Quest started — find the crystal shard."] + - on_player_flag: quest_stage + value: 2 + steps: + - messages: ["You found the shard — return to the elder."] + - on_player_flag: quest_stage + value: 3 + steps: + - messages: ["The ritual begins..."] + - wait: 10 + broadcast: "The temple hums with ancient power!" + - spawn_mob: crystal_guardian ``` -The final step uses both `broadcast` (everyone in the ritual room sees the flash) -and `teleport` + `message` (the triggering player is moved and sees a personal -message). +### Global flag trigger -#### Example 3: Global-flag room trigger — shared environmental event - -A player pulls a lever (sets global flag `floodgate_open`). The room trigger -broadcasts to everyone in the dam control room: +A room can watch a global flag and fire once globally (not per-player). Everyone in the +room sees the broadcast: ```yaml -# data/rooms/wilderness/dam_control.yaml -triggers: +on_global_flag_change: - on_global_flag: floodgate_open steps: - broadcast: "Ancient gears grind as the floodgate slowly opens..." - - delay: 15 + - wait: 15 broadcast: "Water thunders through the opening!" - set_global_flags: valley_flooded: true ``` -Because this watches a **global** flag (`on_global_flag`), it fires once globally when -the flag is first set — not per-player. Everyone in the room sees the messages. - -#### Example 4: "Push button" — trigger that resets itself +### Global trigger files -A player pulls a lever. The trigger fires, then clears the flag so it can be -pulled again. This simulates a toggle without needing a separate mechanism: - -```yaml -triggers: - - on_player_flag: lever_pulled - steps: - - broadcast: "The lever snaps back into place with a loud clunk." - - set_player_flags: - lever_pulled: false # reset so next pull re-fires -``` - ---- - -### Global Triggers - -Global triggers live in `data/triggers/`. They fire regardless of where the -flag-setting player is located. Use them for server-wide events. - -#### Example 5: Level-up announcement - -When a player reaches level 99 in any skill, announce it server-wide. -The game code sets `announce_99_skill` = the skill name (e.g. `"attack"`): +Global triggers live one-per-file in `data/triggers/`. They fire regardless of where the +flag-setting player is. Use them for server-wide events. The filename stem is the trigger +ID (must be globally unique). One Trigger per file — the block fields sit at the top level: ```yaml # data/triggers/announce_99.yaml @@ -201,17 +344,14 @@ steps: - broadcast_global: "%p has reached level 99 %v!" ``` -`%p` expands to the player's name, `%v` expands to the flag value (`"attack"`, -`"mining"`, etc.): +The game code sets `announce_99_skill` to the skill name (e.g. `"attack"`) when a player +hits level 99. `%p` expands to the player's name; `%v` expands to the flag value. Renders: ``` PlayerName has reached level 99 attack! ``` -#### Example 6: World-first boss kill — global broadcast - -A boss mob's death sets global flag `world_boss_slain`. A global trigger -announces it to everyone: +A world-first boss kill: ```yaml # data/triggers/world_boss_slain.yaml @@ -222,31 +362,67 @@ steps: ancient_lands_access: true # opens a zone for everyone ``` -#### Example 7: Global trigger with room context +--- + +### Boss arena — puzzle unlocks a boss -A global trigger can specify a `room` for broadcasts and mob spawns. This is -useful when a global flag should trigger effects in a specific location: +Player activates an altar (on a separate object) setting a player flag, and this room's +`on_flag_change` triggers the boss spawn: ```yaml -# data/triggers/obelisk_activated.yaml -on_global_flag: desert_obelisk_charged -room: 1200 -steps: - - broadcast: "The obelisk hums with stored power." - - delay: 30 - broadcast: "A beam of light shoots from the obelisk into the sky!" - - spawn_mob: obelisk_guardian +# data/rooms/dungeon/boss_chamber.yaml +on_flag_change: + - on_player_flag: activated_altar + steps: + - broadcast: "The altar glows with an eerie light..." + - wait: 10 + broadcast: "The ground trembles beneath your feet." + - wait: 20 + broadcast: "A massive guardian emerges from the shadows!" + - spawn_mob: + id: altar_guardian + owner_only: true + despawn_on_leave: true + despawn_rooms: [450, 451, 452] +``` + +The boss is `owner_only` — only the player who triggered it can interact with it. It +won't despawn as long as the owner stays in rooms 450, 451, or 452 (a 3-room boss arena). +When the owner leaves those rooms, the boss begins despawning. + +### Story beat — timed cutscene after NPC conversation + +An NPC conversation node sets `quest_ritual: started`. A room trigger plays a dramatic +sequence for that player: + +```yaml +on_flag_change: + - on_player_flag: quest_ritual + steps: + - wait: 8 + messages: ["The elder begins to chant in a language you don't recognize."] + - wait: 6 + messages: ["Wisps of light swirl around the altar."] + - wait: 4 + messages: ["The ground beneath you shudders as the ritual reaches its peak."] + - wait: 6 + broadcast: "A blinding flash fills the chamber!" + - teleport: 601 + messages: ["You open your eyes. You're somewhere else entirely."] ``` +The final step uses `teleport` and `messages` (the triggering player is moved and sees a +personal message), while the previous step broadcasts to the ritual room. + --- -### Cascading Triggers +### Cascading triggers -When a trigger step sets a flag, any triggers watching that flag fire immediately -(on the next tick). This lets you chain sequences together: +When a step sets a flag, any triggers watching that flag fire immediately (on the next +tick). This lets you chain sequences: ```yaml -triggers: +on_flag_change: - on_player_flag: phase_1_done steps: - broadcast: "The first seal cracks." @@ -255,30 +431,30 @@ triggers: - on_player_flag: phase_2_started steps: - - delay: 10 + - wait: 10 broadcast: "The second seal glows brighter..." - - delay: 10 + - wait: 10 spawn_mob: phase_2_adds ``` -Both sequences run concurrently — phase 2's delay countdown starts on the same -tick phase 1 completes. +Both sequences run concurrently — phase 2's wait countdown starts on the same tick phase +1 completes. -**Self-re-triggering is prevented.** A trigger can't fire itself again while -its sequence is already in progress (tracked per-player per-trigger-ID). Two -different triggers watching the same flag both fire independently. +**Self-re-triggering is prevented.** A trigger can't fire itself again while its sequence +is already in progress (tracked per-player per-trigger-ID). Two different triggers +watching the same flag both fire independently. --- ### Transient Mobs -Mobs spawned via `spawn_mob` in a trigger step are **transient** — they exist -until killed or despawned, but do **not** respawn. They behave exactly like -regular mobs: they can be attacked, talked to, examined, and stolen from. +Mobs spawned via `spawn_mob` are **transient** — they exist until killed or despawned, +but do **not** respawn. They behave exactly like regular mobs: they can be attacked, +talked to, examined, and stolen from. #### SpawnMobConfig -`spawn_mob` accepts either a simple string (mob ID only) or a full config map: +`spawn_mob` accepts either a bare string (mob ID) or a full config map: ```yaml spawn_mob: altar_guardian @@ -290,25 +466,26 @@ spawn_mob: Full config: -| Field | Default | Description | -| ------------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | *(required)* | Mob definition ID from `data/mobs/`. | -| `owner_only` | `false` | Only the triggering player can interact (attack, talk, steal). Non-owners see "They don't seem interested in you." Everyone can still `look`. | -| `despawn_on_leave` | `false` | If `true`, the mob despawns when the owner leaves the allowed rooms. | -| `despawn_rooms` | spawn room | Rooms the owner can be in without triggering despawn. Empty = the spawn room only. | -| `despawn_ticks` | `0` | Tick countdown after owner leaves despawn_rooms. `0` = immediate removal. Owner returning resets the countdown. | +| Field | Default | Description | +|---|---|---| +| `id` | *(required)* | Mob definition ID from `data/mobs/`. | +| `owner_only` | `false` | Only the triggering player can interact (attack, talk, steal). Non-owners see "They don't seem interested in you." Everyone can still `look`. | +| `despawn_on_leave` | `false` | If `true`, the mob despawns when the owner leaves the allowed rooms. | +| `despawn_rooms` | spawn room | Rooms the owner can be in without triggering despawn. Empty = the spawn room only. | +| `despawn_ticks` | `0` | Tick countdown after owner leaves despawn_rooms. `0` = immediate removal. Owner returning resets the countdown. | -#### Example 8: Personal boss — despawns if you leave the arena +#### Personal boss — despawns if you leave the arena ```yaml -spawn_mob: - id: arena_champion - owner_only: true - despawn_on_leave: true - despawn_ticks: 60 # 60-tick grace period if you step out +steps: + - spawn_mob: + id: arena_champion + owner_only: true + despawn_on_leave: true + despawn_ticks: 60 # 60-tick grace period if you step out ``` -#### Example 9: Persistent NPC — stays until killed +#### Persistent NPC — stays until killed ```yaml spawn_mob: @@ -316,24 +493,24 @@ spawn_mob: # despawn_on_leave defaults to false — stays forever ``` -#### Example 10: Despawning via trigger step +#### Despawning via a step Use `despawn_mob` in a step to remove previously spawned mobs: ```yaml -triggers: +on_flag_change: - on_player_flag: puzzle_solved steps: - despawn_mob: puzzle_guardian # remove the puzzle mob - - delay: 10 + - wait: 10 broadcast: "The guardian dissolves into mist." - spawn_mob: boss_guardian # spawn the real boss ``` -`despawn_mob` with an owner only removes mobs spawned by that player. Without an -owner (in global-flag triggers), it removes all matching mobs. +`despawn_mob` with an owner only removes mobs spawned by that player. Without an owner (in +global-flag triggers), it removes all matching mobs. -#### Example 11: Fixed-lifetime mob +#### Fixed-lifetime mob A mob that exists for exactly 2 minutes regardless of owner location: @@ -342,7 +519,7 @@ steps: - spawn_mob: id: timed_challenge_mob owner_only: true - - delay: 200 # 200 ticks = 2 minutes + - wait: 200 # 200 ticks = 2 minutes despawn_mob: timed_challenge_mob broadcast: "The challenge ends. The mob vanishes." ``` @@ -355,7 +532,7 @@ Steps that send text support these variables: | Variable | Expands to | |---|---| -| `%p` | Player name (only available for `on_player_flag` triggers) | +| `%p` | Player name (only available for player-flag triggers) | | `%v` | Flag value at the time the trigger fired (e.g. the skill name for level-up announcements) | ```yaml @@ -366,43 +543,46 @@ steps: --- -### Value-Matching Triggers +### How Flags Get Set -By default a trigger fires when the watched flag becomes truthy. Add `value:` to -require a specific value: +Triggers fire whenever a flag changes — it doesn't matter *how* the flag was set. All of +these paths activate triggers: -```yaml -# Only fires when quest_stage reaches exactly 3 -triggers: - - on_player_flag: quest_stage - value: 3 - steps: - - message: "Stage 3 begins — the temple doors swing open." - - set_global_flags: - temple_open: true -``` +| Source | Example | +|---|---| +| Object `on_look` / `on_use` | `look sign` sets `1001_look_sign: true` | +| Mob `on_kill` | Killing a boss sets `boss_slain: true` | +| Talk node actions | NPC sets `quest_started: true` after accepting | +| Talk option actions | Player selects a choice that sets a flag | +| On-enter / on-exit steps | Room entry sets `1001_welcome: true` | +| Exit `on_traverse` | Walking through an exit sets `boarded_shuttle: true` | +| Other trigger steps | One trigger chain-sets a flag for another trigger | -This enables multi-stage quests where the same flag advances through numeric -stages, and different triggers fire at different values. +--- -```yaml -triggers: - - on_player_flag: quest_stage - value: 1 - steps: - - message: "Quest started — find the crystal shard." - - on_player_flag: quest_stage - value: 2 - steps: - - message: "You found the shard — return to the elder." - - on_player_flag: quest_stage - value: 3 - steps: - - message: "The ritual begins..." - - delay: 10 - broadcast: "The temple hums with ancient power!" - - spawn_mob: crystal_guardian -``` +### Trigger Firing Rules + +1. **Flag triggers fire on actual value change.** Setting `true → true` is a no-op. + Setting `1 → 2` fires if a trigger watches that flag. Setting `nil → true` counts as a + change and fires. Triggers activate on becoming truthy, not on becoming falsy — setting + `true → false` does **not** fire. + +2. **First-match-wins for event blocks.** The first Trigger in a block whose `item_id` + and `condition` pass fires; its entire `steps` sequence runs. Other entries are skipped + for that event. + +3. **Per-player per-trigger-ID.** The same player can't have two instances of the same + trigger running simultaneously. Starting a new one replaces the old. + +4. **Player must be online.** Player-flag triggers only fire for connected players. + Global-flag triggers fire regardless. + +5. **Flag triggers listen globally; scoping is opt-in.** `on_flag_change` / + `on_global_flag_change` fire regardless of where the flag is set. Add + `condition: { room: <id> }` to restrict to players currently in a room. + +6. **Cascading triggers run concurrently.** If trigger A sets a flag that activates trigger + B, both sequences advance on each tick independently. --- @@ -417,47 +597,55 @@ advances a numeric flag through stages. The last stage spawns a boss. # data/objects/unique/5001_body.yaml name: body on_look: - - set_player_flags: - investigation: 1 + - steps: + - set_player_flags: + investigation: 1 ``` -**Step 2: Blood trail in room 5002** — room trigger fires on value 1 +**Step 2: Blood trail in room 5002** — flag trigger fires on value 1 ```yaml # data/rooms/city/5002_alley.yaml -triggers: +on_flag_change: - on_player_flag: investigation value: 1 + condition: + room: 5002 steps: - - delay: 3 - message: "You notice a trail of blood leading east..." + - wait: 3 + messages: ["You notice a trail of blood leading east..."] - set_player_flags: investigation: 2 ``` -**Step 3: Broken dagger in room 5003** — room trigger fires on value 2 +**Step 3: Broken dagger in room 5003** — flag trigger fires on value 2 ```yaml # data/rooms/city/5003_warehouse.yaml -triggers: +on_flag_change: - on_player_flag: investigation value: 2 + condition: + room: 5003 steps: - - delay: 3 - message: "A glint of metal catches your eye under a crate." + - wait: 3 + messages: ["A glint of metal catches your eye under a crate."] ``` -**Step 4: Confrontation** — room trigger fires on value 3 (set by a separate talk node), spawns boss +**Step 4: Confrontation** — flag trigger fires on value 3 (set by a separate talk node), +spawns boss ```yaml # data/rooms/city/5004_docks.yaml -triggers: +on_flag_change: - on_player_flag: investigation value: 3 + condition: + room: 5004 steps: - - delay: 5 + - wait: 5 broadcast: "A shadow detaches itself from the warehouse wall..." - - delay: 5 + - wait: 5 broadcast: "The assassin steps into the light, blade drawn." - spawn_mob: id: assassin_boss @@ -466,16 +654,16 @@ triggers: despawn_rooms: [5004] ``` -The player must stay in room 5004 to fight the boss. If they flee, the boss -despawns and the flag stays at 3 — they can't re-trigger anything because the -flag is already at that value. +The player must stay in room 5004 to fight the boss. If they flee, the boss despawns and +the flag stays at 3 — they can't re-trigger anything because the flag is already at that +value. --- ### Full Scenario: Server-Wide World Event -A server event progresses through stages. A global trigger chains global flags to -advance the event for everyone. +A server event progresses through stages using global triggers. Each global trigger +chain-sets the next global flag. **Phase 1 trigger:** @@ -484,7 +672,7 @@ advance the event for everyone. on_global_flag: event_phase1_start steps: - broadcast_global: "The sky darkens as an eclipse begins..." - - delay: 100 + - wait: 100 set_global_flags: event_phase2_start: true ``` @@ -496,7 +684,7 @@ steps: on_global_flag: event_phase2_start steps: - broadcast_global: "Monsters pour from the shadows across the land!" - - delay: 300 + - wait: 300 set_global_flags: event_phase3_start: true ``` @@ -508,54 +696,13 @@ steps: on_global_flag: event_phase3_start steps: - broadcast_global: "The eclipse passes. The monsters retreat." - - delay: 50 + - wait: 50 set_global_flags: event_active: false ``` -GM commands or admin tools set `event_phase1_start: true` to kick things off. -The cascade handles the rest. - ---- - -### How Flags Get Set - -Triggers fire whenever a flag changes — it doesn't matter *how* the flag was set. -All of these paths activate triggers: - -| Source | Example | -|---|---| -| Object `on_look` / `on_use` | `look sign` sets `1001_look_sign: true` | -| Mob `on_kill` | Killing a boss sets `boss_slain: true` | -| Talk node actions | NPC sets `quest_started: true` after accepting | -| Talk option actions | Player selects a choice that sets a flag | -| On-enter steps | Room entry sets `1001_welcome: true` | -| Exit `on_traverse` | Walking through an exit sets `boarded_shuttle: true` | -| Trigger steps | One trigger chain-sets a flag for another trigger | - ---- - -### Trigger Firing Rules - -1. **Only on actual value change.** Setting `true` → `true` is a no-op. Setting - `1` → `2` fires if a trigger watches that flag. Setting `nil` → `true` fires. - Setting `true` → `false` does **not** fire (triggers activate on becoming - truthy, not on becoming falsy). - -2. **Conditions are not re-evaluated.** A trigger's steps always run once the - trigger fires. There is no per-step condition checking (unlike `on_enter`). - -3. **Per-player per-trigger-ID.** The same player can't have two instances of - the same trigger running simultaneously. Starting a new one replaces the old. - -4. **Player must be online.** Player-flag triggers only fire for connected - players. Global-flag triggers fire regardless. - -5. **Room triggers check room.** Room-level triggers only fire when the - flag-setting player is in that room. This keeps local events local. - -6. **Cascading triggers run concurrently.** If trigger A sets a flag that - activates trigger B, both sequences advance on each tick independently. +GM commands or admin tools set `event_phase1_start: true` to kick things off. The cascade +handles the rest. --- @@ -567,4 +714,4 @@ definition, `give_item`/`take_item` reference existing items, `teleport` and Validation does NOT check that the watched flags (`on_player_flag` / `on_global_flag`) are ever set — those are dynamic, set by runtime gameplay, and can't be statically -verified. +verified.
\ No newline at end of file diff --git a/cmd/migrate-messages/main.go b/cmd/migrate-messages/main.go deleted file mode 100644 index 81400a4..0000000 --- a/cmd/migrate-messages/main.go +++ /dev/null @@ -1,245 +0,0 @@ -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/cmd/thoi/main.go b/cmd/thoi/main.go index b0b25c0..e5f44f7 100644 --- a/cmd/thoi/main.go +++ b/cmd/thoi/main.go @@ -48,8 +48,7 @@ func main() { g.Ticks.Subscribe(1, func() bool { g.MoveTick() g.ProcessQueuedCommands() - g.EnterSeqTick() - g.TriggerSeqTick() + g.SequenceTick() g.TransientMobTick() g.World.Tick() g.MobStore.Tick() diff --git a/data/.admin_history.json b/data/.admin_history.json index 3cebbbd..df6291b 100644 --- a/data/.admin_history.json +++ b/data/.admin_history.json @@ -1 +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 +{"history":[{"time":"2026-07-09T22:10:38-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: 'Room #1007'\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - steps: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:10:48-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - steps: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - steps: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:10:49-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - steps: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - steps: []\n - steps: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:10:52-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - steps: []\n - steps: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - steps: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:10:54-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - steps: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:10:56-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - steps: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:10:57-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - steps: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - condition:\n all_of: []\n steps: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:11:01-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - condition:\n all_of: []\n steps: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - steps: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:11:02-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit:\n - steps: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:11:08-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\non_flag_change:\n - on_player_flag: \"\"\n steps: []\non_global_flag_change: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:11:18-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\non_flag_change:\n - on_player_flag: \"\"\n steps: []\non_global_flag_change: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\non_flag_change:\n - condition:\n all_of: []\n on_player_flag: \"\"\n steps: []\non_global_flag_change: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:11:26-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\non_flag_change:\n - condition:\n all_of: []\n on_player_flag: \"\"\n steps: []\non_global_flag_change: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\non_flag_change:\n - on_player_flag: \"\"\n steps: []\non_global_flag_change: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:11:27-04:00","description":"update room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\non_flag_change:\n - on_player_flag: \"\"\n steps: []\non_global_flag_change: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\non_flag_change: []\non_global_flag_change: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-09T22:12:46-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 - steps:\n - messages:\n - asdfdfs\n wait: 5\n - messages:\n - sadfdfsadfs\n wait: 3\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 - condition:\n all_of:\n - global_flag: one\n - player_flag: two\n - all_of:\n - player_flag: three\n - global_flag: four\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 2f1ed41..7bd79b7 100644 --- a/data/objects/000_test_object.yaml +++ b/data/objects/000_test_object.yaml @@ -12,12 +12,13 @@ hidden: false id: 000_test_object name: 000 test object on_use: - - action: - messages: - - delay: 5 - message: asdfdfs - - delay: 3 - message: sadfdfsadfs + - condition: + all_of: + - global_flag: one + - player_flag: two + - all_of: + - player_flag: three + - global_flag: four safespot: decay_chance: 0 decay_ticks: 0 diff --git a/data/objects/anvil.yaml b/data/objects/anvil.yaml index efcb8f2..e58efef 100644 --- a/data/objects/anvil.yaml +++ b/data/objects/anvil.yaml @@ -3,13 +3,11 @@ description: - text: A heavy iron anvil for hammering metal into shape. name: anvil on_use: - - 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 + - item_id: silver_bar + steps: + - messages: + - You should use this with a mold at a furnace. + - item_id: gold_bar + steps: + - messages: + - You should use this with a mold at a furnace. diff --git a/data/objects/aps_tower.yaml b/data/objects/aps_tower.yaml index 4b54d9d..b872cae 100644 --- a/data/objects/aps_tower.yaml +++ b/data/objects/aps_tower.yaml @@ -4,8 +4,7 @@ description: inroom_description: An APS Tower looms here, its beacon pulsing steadily. name: APS Tower on_use: - - action: - aps_node: true - messages: - - delay: 0 - message: You connect your datapad to the APS Tower. Coordinates triangulated and stored. + - steps: + - aps_node: true + messages: + - 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 c61eebd..bded3b3 100644 --- a/data/objects/iron_gate.yaml +++ b/data/objects/iron_gate.yaml @@ -4,13 +4,12 @@ description: hidden: true name: iron gate on_use: - - action: - messages: - - delay: 0 - message: You push the heavy iron gate open. - set_global_flags: - gate_open: true - condition: + - condition: global_flag: gate_open not: true value: true + steps: + - messages: + - You push the heavy iron gate open. + set_global_flags: + gate_open: true diff --git a/data/objects/tutorial_lever.yaml b/data/objects/tutorial_lever.yaml index 371e70b..51960d0 100644 --- a/data/objects/tutorial_lever.yaml +++ b/data/objects/tutorial_lever.yaml @@ -5,22 +5,20 @@ hidden: true inroom_description: A stone lever protrudes from the base of the rock formation. name: stone lever on_use: - - 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: + - 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: + steps: + - messages: + - You pull the stone lever down. A mechanism clicks into place somewhere in the glade. + set_player_flags: + unlocked_rock_cover: 1 + - condition: player_flag: unlocked_rock_cover value: 1 + steps: + - messages: + - You push the stone lever back up. The mechanism disengages with a soft clunk. + set_player_flags: + unlocked_rock_cover: 0 diff --git a/data/rooms/intro/1001.yaml b/data/rooms/intro/1001.yaml index 13255ba..5a61bba 100644 --- a/data/rooms/intro/1001.yaml +++ b/data/rooms/intro/1001.yaml @@ -32,8 +32,42 @@ objects: hidden: true name: framed sign on_look: - - set_player_flags: - 1001_look_sign: true + - steps: + - set_player_flags: + 1001_look_sign: true + - broadcast: "" + broadcast_global: "" + despawn_mob: "" + give_item: "" + heal: 0 + set_global_flags: {} + set_player_flags: {} + spawn_mob: null + take_item: "" + teleport: 0 + wait: 5 + - broadcast: "" + broadcast_global: "" + despawn_mob: "" + give_item: "" + heal: 0 + set_global_flags: {} + set_player_flags: {} + spawn_mob: null + take_item: "" + teleport: 0 + wait: 5 + - broadcast: "" + broadcast_global: "" + despawn_mob: "" + give_item: "" + heal: 0 + set_global_flags: {} + set_player_flags: + 1001_touchdown: true + spawn_mob: null + take_item: "" + teleport: 0 - 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 @@ -54,123 +88,71 @@ objects: 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 - 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: + - steps: - broadcast: "" broadcast_global: "" - delay: 5 + condition: + all_of: [] + any_of: [] + global_flag: "" + has_item: "" + min_credits: 0 + not: true + player_flag: 1001_welcome + value: null despawn_mob: "" give_item: "" heal: 0 messages: - - delay: 0 - message: The cabin shakes as the small craft touches down + - '{0B bold}Welcome to The House of Icarus{/}' set_global_flags: {} set_player_flags: {} spawn_mob: null take_item: "" teleport: 0 + wait: 5 - broadcast: "" broadcast_global: "" - delay: 5 + condition: + all_of: [] + any_of: [] + global_flag: "" + has_item: "" + min_credits: 0 + not: true + player_flag: 1001_welcome + value: null despawn_mob: "" give_item: "" heal: 0 messages: - - delay: 0 - message: The pistons hiss as the rear staircase opens + - '{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}' set_global_flags: {} - set_player_flags: {} + set_player_flags: + 1001_welcome: true spawn_mob: null take_item: "" teleport: 0 + wait: 7 - broadcast: "" broadcast_global: "" - delay: 0 + condition: + all_of: [] + any_of: [] + global_flag: "" + has_item: "" + min_credits: 0 + not: true + player_flag: 1001_welcome + value: null 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: - 1001_touchdown: true + set_player_flags: {} spawn_mob: null take_item: "" teleport: 0 - value: null + wait: 7 diff --git a/data/rooms/intro/1002.yaml b/data/rooms/intro/1002.yaml index 8863c60..3abfffd 100644 --- a/data/rooms/intro/1002.yaml +++ b/data/rooms/intro/1002.yaml @@ -67,26 +67,24 @@ objects: - 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: - 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 + - steps: + - condition: + not: true + player_flag: 1002_lined_up + messages: + - A young boy the crowd openly look you up and down a little bemused, as if you're heading the wrong direction. + wait: 7 + - condition: + not: true + player_flag: 1002_lined_up + messages: + - The pilot pops out of the hatch of the craft and calls out "Shuttle to Passenger Barge Denali. Tickets out, please! Nice and orderly!" + wait: 7 + - condition: + not: true + player_flag: 1002_lined_up + messages: + - 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 + wait: 7 diff --git a/data/rooms/intro/1003.yaml b/data/rooms/intro/1003.yaml index 5cd7d64..05f8c56 100644 --- a/data/rooms/intro/1003.yaml +++ b/data/rooms/intro/1003.yaml @@ -17,13 +17,13 @@ mobs: name: Processing Building objects: [] on_enter: - - 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 + - steps: + - condition: + not: true + player_flag: 1003_first_time + messages: + - '{bold}The ground rumbles as the shuttle departs the pad behind you!{/}' + set_player_flags: + 1003_first_time: true + wait: 5 triggers: [] diff --git a/data/rooms/intro/1007.yaml b/data/rooms/intro/1007.yaml index bc05da9..1d02bfe 100644 --- a/data/rooms/intro/1007.yaml +++ b/data/rooms/intro/1007.yaml @@ -1,5 +1,4 @@ -id: 1007 -name: 'Room #1007' +block_transport: false color: "" description: [] exits: @@ -9,10 +8,14 @@ exits: room: 1008 south: room: 1005 -objects: [] +hazard: "" +id: 1007 item_spawns: [] mobs: [] +name: 'Room #1007' +objects: [] on_enter: [] -hazard: "" -block_transport: false +on_exit: [] +on_flag_change: [] +on_global_flag_change: [] triggers: [] diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go index 6e62d9e..5b0464d 100644 --- a/internal/admin/api_map.go +++ b/internal/admin/api_map.go @@ -280,27 +280,27 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { continue } - bidirectional := false - if targetRoom, ok := roomData[target]; ok { - if oppExit, ok := targetRoom.Exits[world.OppositeExit[dir]]; ok && oppExit.Room == rid && !oppExit.Hidden && !oppExit.AlwaysBlocked { - bidirectional = true + bidirectional := false + if targetRoom, ok := roomData[target]; ok { + if oppExit, ok := targetRoom.Exits[world.OppositeExit[dir]]; ok && oppExit.Room == rid && !oppExit.Hidden && !oppExit.AlwaysBlocked { + bidirectional = true + } } - } - key := linkKey(rid, target) - if bidirectional && seenLinks[key] { - continue - } - seenLinks[key] = true - - links = append(links, LinkEntry{ - From: rid, - To: target, - Dir: string(dir), - Bidirectional: bidirectional, - Hidden: exit.Hidden, - AlwaysBlocked: exit.AlwaysBlocked, - }) + key := linkKey(rid, target) + if bidirectional && seenLinks[key] { + continue + } + seenLinks[key] = true + + links = append(links, LinkEntry{ + From: rid, + To: target, + Dir: string(dir), + Bidirectional: bidirectional, + Hidden: exit.Hidden, + AlwaysBlocked: exit.AlwaysBlocked, + }) } } diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go index e4cf849..097b9e8 100644 --- a/internal/admin/api_room_courses.go +++ b/internal/admin/api_room_courses.go @@ -290,15 +290,15 @@ func (s *AdminServer) handleRoomCourse(w http.ResponseWriter, r *http.Request, r m["id"] = id delete(m, "_raw") writeJSON(w, map[string]any{ - "course": m, - "course_id": id, - "course_name": m["name"], - "obstacle_index": idx, - "total_obstacles": len(obstacles), - "obstacle": obstacles[idx], - "obstacles": obstacles, - "_raw": string(raw), - "room_id": roomID, + "course": m, + "course_id": id, + "course_name": m["name"], + "obstacle_index": idx, + "total_obstacles": len(obstacles), + "obstacle": obstacles[idx], + "obstacles": obstacles, + "_raw": string(raw), + "room_id": roomID, }) return } @@ -565,8 +565,6 @@ func exitIsAlwaysBlocked(v any) bool { return false } - - // isHorizontalDir returns whether d is one of the 8 horizontal directions. func isHorizontalDir(d string) bool { for _, h := range horizontalExitDirs { @@ -577,8 +575,6 @@ func isHorizontalDir(d string) bool { return false } - - // reconcileExitOnRoom validates that roomID has an exit in dir. // If expectedTarget > 0, also checks the exit points to that room. // Returns an error string or empty string on success. @@ -627,4 +623,4 @@ func (s *AdminServer) reconcileCourseExits(m map[string]any) []string { } return warnings -}
\ No newline at end of file +} diff --git a/internal/admin/api_room_insert_remove_test.go b/internal/admin/api_room_insert_remove_test.go index 6d80595..f613313 100644 --- a/internal/admin/api_room_insert_remove_test.go +++ b/internal/admin/api_room_insert_remove_test.go @@ -31,9 +31,9 @@ func newTestAdminServer(t *testing.T, rooms map[int]string) *AdminServer { } } return &AdminServer{ - world: world.New(dir), - mobStore: world.NewMobStore(dir), - dataDir: dir, + world: world.New(dir), + mobStore: world.NewMobStore(dir), + dataDir: dir, undoStack: NewUndoStack(dir), } } @@ -428,9 +428,9 @@ func TestNextRoomIDNoCrossSubdirCollision(t *testing.T) { t.Fatal(err) } s := &AdminServer{ - world: world.New(dir), - mobStore: world.NewMobStore(dir), - dataDir: dir, + world: world.New(dir), + mobStore: world.NewMobStore(dir), + dataDir: dir, undoStack: NewUndoStack(dir), } // Allocating near room 1 (root) must skip 2 (which lives in zone/). diff --git a/internal/admin/api_rooms_bulk.go b/internal/admin/api_rooms_bulk.go index 34ca209..6283067 100644 --- a/internal/admin/api_rooms_bulk.go +++ b/internal/admin/api_rooms_bulk.go @@ -64,11 +64,11 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { } type roomWrite struct { - oldContent []byte - newContent []byte - oldPath string - newPath string - wasMoved bool + oldContent []byte + newContent []byte + oldPath string + newPath string + wasMoved bool } writes := make([]roomWrite, 0, len(req.IDs)) diff --git a/internal/admin/server.go b/internal/admin/server.go index 67de0ab..64c5592 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -19,8 +19,8 @@ import ( "math/big" "net" "net/http" - "path/filepath" "os" + "path/filepath" "strconv" "strings" "time" diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index b35d5e2..7c8f0a5 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -420,8 +420,7 @@ 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{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{flex:1} /* Interaction row layout: column. The single flex header row holds the @@ -439,3 +438,36 @@ g.ud-hover:hover text{font-weight:bold} .ie-inter-panel-row{display:flex;gap:6px;align-items:flex-start} .ie-inter-panel-body{flex:1;min-width:0} .ie-inter-panel-x{margin-top:4px;flex-shrink:0} + +/* ── Step list: boxed steps with right-justified move/remove controls ── */ +.ie-inter-steps{margin-top:4px} +.ie-step-list{display:flex;flex-direction:column;gap:4px} +.ie-step-row{background:rgba(100,160,255,.03);border:1px solid rgba(100,160,255,.1);border-radius:6px;padding:8px;display:flex;flex-direction:column;gap:4px} +.ie-step-header{display:flex;align-items:center;gap:6px} +.ie-step-label{font-size:10px;color:#6af;text-transform:uppercase;letter-spacing:.3px;font-weight:bold;white-space:nowrap} +.ie-step-addmore{color:#6af;border:1px solid rgba(100,160,255,.25);background:transparent;font-size:10px;padding:1px 6px;border-radius:3px;cursor:pointer;font-family:monospace;line-height:1} +.ie-step-addmore:hover{background:rgba(100,160,255,.1)} +.ie-step-spacer{flex:1} +.ie-step-up,.ie-step-dn{padding:2px 6px;font-size:9px} +.ie-step-cond-panel{margin:2px 0} +.ie-step-effects{margin:2px 0} +.ie-step-messages{margin:2px 0} +.ie-step-addbar{display:none;flex-wrap:wrap;gap:3px;margin-top:2px} +.ie-step-row.show-addbar .ie-step-addbar{display:flex} +.ie-step-addbar-bottom{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px} +.ie-step-addbar-bottom .btn{padding:4px 10px;font-size:11px} + +/* ── Message rows within a step ── */ +.ie-msg-row{display:flex;align-items:center;gap:4px} +.ie-msg-up,.ie-msg-dn{padding:1px 4px;font-size:9px;line-height:1} + +/* ── Lock player checkbox ── */ +.ie-lock-lbl{font-size:10px;color:var(--text);display:inline-flex;align-items:center;gap:3px;cursor:pointer;white-space:nowrap;margin-right:6px} +.ie-lock-cb{accent-color:var(--accent);margin:0} + +/* ── Trigger row move up/down ── */ +.ie-trig-up,.ie-trig-dn{padding:2px 6px;font-size:9px} + +/* ── Empty condition group placeholder ── */ +.ie-cond-empty{margin-bottom:2px} +.ie-cond-empty-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding-bottom:2px} diff --git a/internal/admin/static/intereditor.js b/internal/admin/static/intereditor.js index 5cf39a1..653bccf 100644 --- a/internal/admin/static/intereditor.js +++ b/internal/admin/static/intereditor.js @@ -1,38 +1,172 @@ -// intereditor.js — Structured condition & action editors for on_use/on_look/on_kill. -// Replaces free-form JSON textareas with button-driven pickers. -// Shared by objecteditor.js and mobeditor.js. +// intereditor.js — Structured condition & step editors for trigger blocks. +// Drives the unified Trigger model: +// Trigger = { lock, item_id?, condition?, steps[], on_player_flag?, on_global_flag?, value? } +// Step = { condition?, wait, messages[], broadcast, broadcast_global, +// spawn_mob, despawn_mob, set_global_flags, set_player_flags, +// give_item, take_item, teleport, heal, credits, aps_node } +// Shared by objecteditor.js, mobeditor.js, and map.js. // ── Condition leaf types ── var IE_COND_TYPES = [ {type: 'global_flag', label: 'Global Flag', hint: 'global flag name'}, {type: 'player_flag', label: 'Player Flag', hint: 'player flag name'}, + {type: 'room', label: 'In Room', hint: 'room ID'}, {type: 'has_item', label: 'Has Item', hint: 'item ID'}, {type: 'min_credits', label: 'Min Credits', hint: 'amount'}, ]; +// ── Effect field types ── +// +// Every block is sequence-scoped now: broadcast / broadcast_global / +// spawn_mob / despawn_mob are available everywhere. Messages and condition +// are handled separately (plain-string message rows; per-step condition). + var IE_ACT_TYPES = [ - {key: 'set_global_flags', label: 'Set Global Flags', kind: 'kv'}, - {key: 'set_player_flags', label: 'Set Player Flags', kind: 'kv'}, - {key: 'give_item', label: 'Give Item', kind: 'search', entity: 'items'}, - {key: 'take_item', label: 'Take Item', kind: 'search', entity: 'items'}, - {key: 'teleport', label: 'Teleport', kind: 'number', hint: 'room ID'}, - {key: 'heal', label: 'Heal', kind: 'number', hint: 'HP to restore'}, - {key: 'credits', label: 'Credits', kind: 'number', hint: 'amount (negative = cost)'}, - {key: 'aps_node', label: 'APS Node', kind: 'checkbox'}, + {key: 'wait', label: 'Wait', kind: 'number', hint: 'ticks before this step'}, + {key: 'broadcast', label: 'Broadcast', kind: 'text', hint: '%p = player'}, + {key: 'broadcast_global', label: 'Broadcast Global', kind: 'text', hint: '%p = player'}, + {key: 'spawn_mob', label: 'Spawn Mob', kind: 'search', entity: 'mobs'}, + {key: 'despawn_mob', label: 'Despawn Mob', kind: 'search', entity: 'mobs'}, + {key: 'set_global_flags', label: 'Set Global Flags', kind: 'kv'}, + {key: 'set_player_flags', label: 'Set Player Flags', kind: 'kv'}, + {key: 'give_item', label: 'Give Item', kind: 'search', entity: 'items'}, + {key: 'take_item', label: 'Take Item', kind: 'search', entity: 'items'}, + {key: 'teleport', label: 'Teleport', kind: 'number', hint: 'room ID'}, + {key: 'heal', label: 'Heal', kind: 'number', hint: 'HP to restore'}, + {key: 'credits', label: 'Credits', kind: 'number', hint: 'amount (negative = cost)'}, + {key: 'aps_node', label: 'APS Node', kind: 'checkbox'}, ]; -// Sequence-only effects (only shown for scope === 'seq'). -var IE_ACT_TYPES_SEQ = [ - {key: 'broadcast', label: 'Broadcast', kind: 'text', hint: '%p = player'}, - {key: 'broadcast_global', label: 'Broadcast Global', kind: 'text', hint: '%p = player'}, - {key: 'spawn_mob', label: 'Spawn Mob', kind: 'search', entity: 'mobs'}, - {key: 'despawn_mob', label: 'Despawn Mob', kind: 'search', entity: 'mobs'}, +// ── Step-kind buttons for the bottom add-bar (each click creates a new +// step initialised with that single action kind) and the per-step +// "+" toggle bar. messages is handled separately from IE_ACT_TYPES. ── + +var IE_STEP_KINDS = [ + {key: 'messages', label: 'Message', kind: 'messages'}, ]; +IE_ACT_TYPES.forEach(function(at) { + IE_STEP_KINDS.push(at); +}); + +// ── Rendering helpers ── + +// ie_renderStepAddbar: the per-step action add bar revealed by the "+" toggle. +// Only shows kinds not already present on this step. +function ie_renderStepAddbar(step, secId, idx, si, secPath) { + var h = ''; + IE_STEP_KINDS.forEach(function(sk) { + if (sk.key === 'messages') { + if (step && Array.isArray(step.messages)) return; + } else { + if (step && Object.prototype.hasOwnProperty.call(step, sk.key)) { + if (sk.kind === 'checkbox' && step[sk.key] !== true) { /* re-addable */ } + else return; + } + } + if (sk.key === 'messages') { + h += '<button class="btn btn-sm ie-add-btn" onclick="ie_addMessages(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ')">+ ' + esc(sk.label) + '</button>'; + } else { + h += '<button class="btn btn-sm ie-add-btn" onclick="ie_addActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',\'' + escAttr(sk.key) + '\')">+ ' + esc(sk.label) + '</button>'; + } + }); + return h; +} + +// ie_renderBottomAddbar: the always-visible bar at the foot of the step list. +// Each button creates a new step initialised with that single action kind. +function ie_renderBottomAddbar(secId, secPath, idx) { + var h = ''; + IE_STEP_KINDS.forEach(function(sk) { + h += '<button class="btn btn-sm ie-add-btn" onclick="ie_addStepKind(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(sk.key) + '\')">+ ' + esc(sk.label) + ' Step</button>'; + }); + return h; +} + +// ie_addStepKind creates a new step initialised with a single action kind. +function ie_addStepKind(secId, secPath, idx, kind) { + var ed = window._editorData; + if (!ed) return; + ced_syncDOMToData(ed); + if (window._ieSyncAll) window._ieSyncAll(ed); + if (!ed[secPath]) ed[secPath] = []; + if (!ed[secPath][idx]) ed[secPath][idx] = {}; + if (!ed[secPath][idx].steps || !Array.isArray(ed[secPath][idx].steps)) ed[secPath][idx].steps = []; + var step = {}; + if (kind === 'messages') { + step.messages = ['']; + } else if (kind === 'wait') { + step.wait = 1; + } else { + var at = IE_ACT_TYPES.find(function(a) { return a.key === kind; }); + if (!at) return; + if (at.kind === 'kv') step[kind] = {}; + else if (at.kind === 'checkbox') step[kind] = true; + else if (at.kind === 'number') step[kind] = 0; + else step[kind] = ''; + } + ed[secPath][idx].steps.push(step); + window._ieRenderCurrent(); +} // Sentinel child-index for a root-level single leaf (no parent group). var IE_ROOT_LEAF = -1; +// ── Condition lens helpers (entry vs step) ── +// +// kind is the string-encoded condition anchor: 'entry' for the trigger's own +// condition, or 'step-<n>' for the nth step's condition. Internally we parse +// it to the numeric step index (-1 = entry). + +function _ie_kindStep(kind) { + if (kind === undefined || kind === null || kind === 'entry') return -1; + if (typeof kind === 'string' && kind.indexOf('step-') === 0) { + var n = parseInt(kind.slice(5), 10); + return isNaN(n) ? -1 : n; + } + return -1; +} + +function _ie_getCond(ed, secPath, idx, kind) { + var si = _ie_kindStep(kind); + if (!ed[secPath] || !ed[secPath][idx]) return null; + if (si < 0) return ed[secPath][idx].condition || null; + if (!ed[secPath][idx].steps || !ed[secPath][idx].steps[si]) return null; + return ed[secPath][idx].steps[si].condition || null; +} + +function _ie_setCond(ed, secPath, idx, kind, newCond) { + var si = _ie_kindStep(kind); + if (!ed[secPath]) ed[secPath] = []; + if (!ed[secPath][idx]) ed[secPath][idx] = {}; + if (si < 0) { + if (newCond) ed[secPath][idx].condition = newCond; + else delete ed[secPath][idx].condition; + } else { + if (!ed[secPath][idx].steps) ed[secPath][idx].steps = []; + if (!ed[secPath][idx].steps[si]) ed[secPath][idx].steps[si] = {}; + if (newCond) ed[secPath][idx].steps[si].condition = newCond; + else delete ed[secPath][idx].steps[si].condition; + } +} + +function _ie_syncCond(ed, secId, secPath, idx, kind) { + var card = document.querySelector('.obj-card[data-card="' + secId + '"]'); + if (!card) return; + var si = _ie_kindStep(kind); + var container; + if (si < 0) { + container = card.querySelector('.obj-sub-row.ie-inter-row[data-idx="' + idx + '"]'); + } else { + container = card.querySelector('.obj-sub-row.ie-inter-row[data-idx="' + idx + '"] .ie-step-row[data-step="' + si + '"]'); + } + if (!container) return; + var condRoot = container.querySelector('.ie-cond-root'); + if (!condRoot) return; + var cond = ie_collectCond(condRoot); + _ie_setCond(ed, secPath, idx, kind, cond); +} + // ── Navigation in condition tree ── function ie_condAt(cond, path) { @@ -48,7 +182,6 @@ function ie_condAt(cond, path) { return cur; } -// Replaces the condition at path within the overall tree. Returns new root. function ie_condReplace(cond, path, newVal) { if (!path) return newVal; if (!cond) return cond; @@ -72,13 +205,7 @@ function ie_condReplace(cond, path, newVal) { } // ── Clean condition tree for save ── -// -// The live editor tree intentionally holds empty leaves/groups while the user -// is building up a condition (so clicking a second leaf button adds a sibling -// rather than replacing the first). This walker strips those in-progress -// artifacts before save: drops leaves whose value is missing/empty, drops -// 0-child groups, and collapses 1-child non-NOT groups to their child. min_credits -// keeps the value 0 (falsy in JS but a valid threshold). + function ie_cleanCondition(cond) { if (!cond || typeof cond !== 'object') return null; if (cond.all_of || cond.any_of) { @@ -91,7 +218,6 @@ function ie_cleanCondition(cond) { r[mode] = cleaned; return r; } - // Leaf var not = !!cond.not; var leaf = null; if (cond.global_flag !== undefined) { @@ -104,6 +230,8 @@ function ie_cleanCondition(cond) { leaf = { player_flag: cond.player_flag }; if (cond.value !== undefined) leaf.value = cond.value; } + } else if (cond.room !== undefined) { + if (cond.room) leaf = { room: parseInt(cond.room, 10) || 0 }; } else if (cond.has_item !== undefined) { if (cond.has_item) leaf = { has_item: cond.has_item }; } else if (cond.min_credits !== undefined) { @@ -120,12 +248,6 @@ function ie_cleanCondition(cond) { function ie_collectCond(rootEl) { if (!rootEl) return null; - // A .ie-cond-root wraps either a single group (.ie-cond) or a single bare - // leaf (.ie-cond-leaf). Unwrap it so sync is the pure inverse of render: - // cond-root > .ie-cond(group) -> ie_collectCond(.ie-cond) - // cond-root > .ie-cond-leaf -> ie_collectLeaf(leafEl) - // The old version recursed into the .ie-cond child and then wrapped the - // result, producing {all_of:[{all_of:[...]}]} for any group condition. if (rootEl.classList.contains('ie-cond-root')) { var groupEl = rootEl.querySelector(':scope > .ie-cond'); if (groupEl) return ie_collectCond(groupEl); @@ -134,7 +256,6 @@ function ie_collectCond(rootEl) { return null; } - // rootEl is a .ie-cond group box. var mode = 'all_of'; var modeSel = rootEl.querySelector(':scope > .ie-cond-modebar .ie-cond-modesel'); if (modeSel) mode = modeSel.value; @@ -155,13 +276,6 @@ function ie_collectCond(rootEl) { }); } - // Preserve empty groups (no modebar rendered for <2 children, so empty groups - // always serialize as {all_of:[]}). Without this, "+ Group" would vanish on - // the next sync before the user could add their first leaf, and the - // subsequent "+ leaf" would turn it into a bare leaf — silently dropping - // the user's group intent. Empty {all_of:[]} is harmless game-side - // (checkCondition returns true, equivalent to no condition) and - // ced_cleanOutput strips it from saved YAML. var result = {}; if (not) result.not = true; result[mode] = items; @@ -178,11 +292,6 @@ function ie_collectLeaf(el) { var valInput = el.querySelector('.ie-cond-val'); var val = valInput ? valInput.value.trim() : ''; - // Always return the leaf (even when val is empty) so the in-memory - // condition tree keeps "in-progress" leaves the user just added. Without - // this, clicking a second leaf button would sync, see null, and wipe the - // first leaf — breaking multi-add and nested-group building. ie_cleanCondition - // strips empty leaves at save time. switch (leafType) { case 'global_flag': result.global_flag = val; @@ -200,6 +309,9 @@ function ie_collectLeaf(el) { try { result.value = JSON.parse(sv); } catch(e) { result.value = sv; } } break; + case 'room': + result.room = parseInt(val) || 0; + break; case 'has_item': result.has_item = val; break; @@ -212,123 +324,30 @@ function ie_collectLeaf(el) { return result; } -// ── Collect action from DOM ── - -function ie_collectAct(rootEl) { - if (!rootEl) return null; - var r = {}; - - // 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 - // .ie-act-kv element exists in the DOM). This lets a just-added section - // (e.g. "+ Set Player Flags" → no rows yet) survive the next sync instead - // of being dropped on the next add-effect call. ced_cleanOutput strips - // empty maps at save time. - 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'); - var vEl = row.querySelector('.ie-kv-val'); - if (kEl && kEl.value.trim() && vEl) { - var v = vEl.value.trim(); - var num = parseFloat(v); - map[kEl.value.trim()] = v === 'true' ? true : (v === 'false' ? false : (!isNaN(num) ? num : v)); - } - }); - r[key] = map; - }); - - // Scalar effects — preserve empty strings so a present-but-unfilled input - // round-trips on every sync. - rootEl.querySelectorAll('.ie-act-input').forEach(function(el) { - var key = el.getAttribute('data-ie-act-key'); - if (!key) return; - var kind = el.getAttribute('data-ie-act-kind'); - if (kind === 'checkbox') { - r[key] = el.checked; - } else if (kind === 'number') { - var n = parseFloat(el.value); - if (isNaN(n)) return; - r[key] = n; - } else { - r[key] = el.value.trim(); - } - }); - - if (Object.keys(r).length === 0) return null; - return r; -} - -// ── Sync all interaction DOM back to data ── - -function ie_syncAllInteractions(data, secs) { - if (!data || !secs) return; - secs.forEach(function(sec) { - if (!sec.interactionStyle || !sec.isArray) return; - var p = sec.path; - if (!data[p]) return; - var card = document.querySelector('.obj-card[data-card="' + sec.id + '"]'); - if (!card) return; - var rows = card.querySelectorAll('.obj-sub-row'); - rows.forEach(function(row, idx) { - if (!data[p][idx]) data[p][idx] = {}; - var condRoot = row.querySelector('.ie-cond-root'); - if (condRoot) { - var cond = ie_collectCond(condRoot); - if (cond) data[p][idx].condition = cond; - else delete data[p][idx].condition; - } - var actRoot = row.querySelector('.ie-act-root'); - if (actRoot) { - var act = ie_collectAct(actRoot); - if (act) data[p][idx].action = act; - else delete data[p][idx].action; - } - }); - }); -} - // ── Render condition ── -function ie_renderCond(condObj, secId, idx, secPath) { +function ie_renderCond(condObj, secId, idx, secPath, kind) { + kind = kind || 'entry'; var h = ''; var isGroup = condObj && (condObj.all_of || condObj.any_of); if (condObj && typeof condObj === 'object') { h += '<div class="ie-cond-root">'; - h += ie_renderGroup(condObj, secId, secPath, idx, ''); + h += ie_renderGroup(condObj, secId, secPath, idx, '', kind); h += '</div>'; } - // For a group root, the group's own addbar (rendered inside _ie_renderGroupNode) - // already covers appending — don't emit a duplicate top-level addbar. var showAddbar = !isGroup; if (showAddbar) { h += '<div class="ie-cond-addbar">'; IE_COND_TYPES.forEach(function(ct) { - h += '<button class="btn btn-sm ie-add-btn ie-cond-add-leaf" onclick="ie_addLeaf(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'\',\'' + ct.type + '\')">+ ' + esc(ct.label) + '</button>'; + h += '<button class="btn btn-sm ie-add-btn ie-cond-add-leaf" onclick="ie_addLeaf(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(kind) + '\',\'\',\'' + ct.type + '\')">+ ' + esc(ct.label) + '</button>'; }); - h += '<button class="btn btn-sm ie-add-btn ie-cond-add-grp" onclick="ie_addGroup(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'\')">+ Group</button>'; + h += '<button class="btn btn-sm ie-add-btn ie-cond-add-grp" onclick="ie_addGroup(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(kind) + '\',\'\')">+ Group</button>'; h += '</div>'; } return h; } -function ie_renderGroup(cond, secId, secPath, idx, gpath) { +function ie_renderGroup(cond, secId, secPath, idx, gpath, kind) { var mode = null, children = null; if (cond.all_of) { mode = 'all_of'; children = cond.all_of; } else if (cond.any_of) { mode = 'any_of'; children = cond.any_of; } @@ -336,74 +355,74 @@ function ie_renderGroup(cond, secId, secPath, idx, gpath) { var not = !!cond.not; if (mode && children) { - return _ie_renderGroupNode(mode, children, not, secId, secPath, idx, gpath); + return _ie_renderGroupNode(mode, children, not, secId, secPath, idx, gpath, kind); } - // Leaf - if (cond.global_flag !== undefined) return _ie_renderLeaf('global_flag', cond.global_flag, cond.value, not, secId, secPath, idx, gpath, IE_ROOT_LEAF); - if (cond.player_flag !== undefined) return _ie_renderLeaf('player_flag', cond.player_flag, cond.value, not, secId, secPath, idx, gpath, IE_ROOT_LEAF); - if (cond.has_item !== undefined) return _ie_renderLeaf('has_item', cond.has_item, '', not, secId, secPath, idx, gpath, IE_ROOT_LEAF); - if (cond.min_credits !== undefined) return _ie_renderLeaf('min_credits', cond.min_credits, '', not, secId, secPath, idx, gpath, IE_ROOT_LEAF); + if (cond.global_flag !== undefined) return _ie_renderLeaf('global_flag', cond.global_flag, cond.value, not, secId, secPath, idx, gpath, IE_ROOT_LEAF, kind); + if (cond.player_flag !== undefined) return _ie_renderLeaf('player_flag', cond.player_flag, cond.value, not, secId, secPath, idx, gpath, IE_ROOT_LEAF, kind); + if (cond.room !== undefined) return _ie_renderLeaf('room', cond.room, '', not, secId, secPath, idx, gpath, IE_ROOT_LEAF, kind); + if (cond.has_item !== undefined) return _ie_renderLeaf('has_item', cond.has_item, '', not, secId, secPath, idx, gpath, IE_ROOT_LEAF, kind); + if (cond.min_credits !== undefined) return _ie_renderLeaf('min_credits', cond.min_credits, '', not, secId, secPath, idx, gpath, IE_ROOT_LEAF, kind); return ''; } -function _ie_renderGroupNode(mode, children, not, secId, secPath, idx, gpath) { +function _ie_renderGroupNode(mode, children, not, secId, secPath, idx, gpath, kind) { var h = ''; if (children.length === 0) { - // Empty group: single line — addbar buttons left, X right. h += '<div class="ie-cond ie-cond-empty" data-ie-gpath="' + escAttr(gpath) + '">'; h += '<div class="ie-cond-modebar ie-cond-empty-bar">'; IE_COND_TYPES.forEach(function(ct) { - h += '<button class="btn btn-sm ie-add-btn ie-cond-add-leaf" onclick="ie_addLeaf(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(gpath) + '\',\'' + ct.type + '\')">+ ' + esc(ct.label) + '</button>'; + h += '<button class="btn btn-sm ie-add-btn ie-cond-add-leaf" onclick="ie_addLeaf(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(kind) + '\',\'' + escAttr(gpath) + '\',\'' + ct.type + '\')">+ ' + esc(ct.label) + '</button>'; }); - h += '<button class="btn btn-sm ie-add-btn ie-cond-add-grp" onclick="ie_addGroup(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(gpath) + '\')">+ Group</button>'; - h += '<button class="btn btn-sm btn-danger ie-cond-grp-rm" onclick="ie_removeGroup(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(gpath) + '\')" title="Remove this group">X</button>'; + h += '<button class="btn btn-sm ie-add-btn ie-cond-add-grp" onclick="ie_addGroup(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(kind) + '\',\'' + escAttr(gpath) + '\')">+ Group</button>'; + h += '<button class="btn btn-sm btn-danger ie-cond-grp-rm" onclick="ie_removeGroup(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(kind) + '\',\'' + escAttr(gpath) + '\')" title="Remove this group">X</button>'; h += '</div>'; h += '</div>'; return h; } - // Non-empty group: modebar (no X) + children + addbar below. h += '<div class="ie-cond" data-ie-gpath="' + escAttr(gpath) + '">'; h += '<div class="ie-cond-modebar">'; if (children.length >= 2) { - h += '<select class="ie-cond-modesel" onchange="ie_toggleCondMode(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(gpath) + '\')">'; + h += '<select class="ie-cond-modesel" onchange="ie_toggleCondMode(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(kind) + '\',\'' + escAttr(gpath) + '\')">'; h += '<option value="all_of"' + (mode === 'all_of' ? ' selected' : '') + '>All of</option>'; h += '<option value="any_of"' + (mode === 'any_of' ? ' selected' : '') + '>Any of</option>'; h += '</select>'; } if (children.length >= 2 || not) { - h += '<label class="ie-cond-notlbl"><input type="checkbox" class="ie-cond-not" onchange="ie_toggleCondNot(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(gpath) + '\')"' + (not ? ' checked' : '') + '> NOT</label>'; + h += '<label class="ie-cond-notlbl"><input type="checkbox" class="ie-cond-not" onchange="ie_toggleCondNot(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(kind) + '\',\'' + escAttr(gpath) + '\')"' + (not ? ' checked' : '') + '> NOT</label>'; } h += '</div>'; h += '<div class="ie-cond-children">'; children.forEach(function(child, ci) { var cpath = gpath ? gpath + '-' + ci : String(ci); if (child.all_of || child.any_of) { - h += ie_renderGroup(child, secId, secPath, idx, cpath); + h += ie_renderGroup(child, secId, secPath, idx, cpath, kind); } else { var cval = '', csub = '', ctype = '', cnot = !!child.not; if (child.global_flag !== undefined) { ctype = 'global_flag'; cval = child.global_flag; csub = child.value; } else if (child.player_flag !== undefined) { ctype = 'player_flag'; cval = child.player_flag; csub = child.value; } + else if (child.room !== undefined) { ctype = 'room'; cval = child.room; } else if (child.has_item !== undefined) { ctype = 'has_item'; cval = child.has_item; } else if (child.min_credits !== undefined) { ctype = 'min_credits'; cval = child.min_credits; } - if (ctype) h += _ie_renderLeaf(ctype, cval, csub, cnot, secId, secPath, idx, gpath, ci); + if (ctype) h += _ie_renderLeaf(ctype, cval, csub, cnot, secId, secPath, idx, gpath, ci, kind); } }); h += '</div>'; h += '<div class="ie-cond-addbar" data-ie-gpath="' + escAttr(gpath) + '">'; IE_COND_TYPES.forEach(function(ct) { - h += '<button class="btn btn-sm ie-add-btn ie-cond-add-leaf" onclick="ie_addLeaf(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(gpath) + '\',\'' + ct.type + '\')">+ ' + esc(ct.label) + '</button>'; + h += '<button class="btn btn-sm ie-add-btn ie-cond-add-leaf" onclick="ie_addLeaf(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(kind) + '\',\'' + escAttr(gpath) + '\',\'' + ct.type + '\')">+ ' + esc(ct.label) + '</button>'; }); - h += '<button class="btn btn-sm ie-add-btn ie-cond-add-grp" onclick="ie_addGroup(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(gpath) + '\')">+ Group</button>'; + h += '<button class="btn btn-sm ie-add-btn ie-cond-add-grp" onclick="ie_addGroup(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(kind) + '\',\'' + escAttr(gpath) + '\')">+ Group</button>'; h += '</div>'; h += '</div>'; return h; } -function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gpath, ci) { +function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gpath, ci, kind) { + kind = kind || 'entry'; var label = ''; IE_COND_TYPES.forEach(function(ct) { if (ct.type === leafType) label = ct.label; }); var hint = ''; @@ -420,7 +439,7 @@ function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gp h += '<input class="ie-cond-val search-input" data-search-type="items" placeholder="' + escAttr(hint) + '" value="' + escAttr(sv) + '">'; h += '<div class="search-results" style="display:none"></div>'; h += '</div>'; - } else if (leafType === 'min_credits') { + } else if (leafType === 'min_credits' || leafType === 'room') { h += '<input class="ie-cond-val" placeholder="' + escAttr(hint) + '" value="' + escAttr(sv) + '" type="number">'; } else { h += '<input class="ie-cond-val" placeholder="' + escAttr(hint) + '" value="' + escAttr(sv) + '">'; @@ -429,64 +448,33 @@ function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gp h += '<span class="ie-cond-eq">=</span>'; h += '<input class="ie-cond-subval" placeholder="exists" value="' + escAttr(ssv) + '">'; } - h += '<label class="ie-cond-notlbl"><input type="checkbox" class="ie-cond-not" onchange="ie_toggleNotLeaf(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(gpath) + '\',' + ciToken + ')"' + (not ? ' checked' : '') + '> NOT</label>'; - h += '<button class="btn btn-sm btn-danger ie-cond-rm" onclick="ie_removeCond(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(gpath) + '\',' + ciToken + ')" title="Remove">X</button>'; + h += '<label class="ie-cond-notlbl"><input type="checkbox" class="ie-cond-not" onchange="ie_toggleNotLeaf(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(kind) + '\',\'' + escAttr(gpath) + '\',' + ciToken + ')"' + (not ? ' checked' : '') + '> NOT</label>'; + h += '<button class="btn btn-sm btn-danger ie-cond-rm" onclick="ie_removeCond(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(kind) + '\',\'' + escAttr(gpath) + '\',' + ciToken + ')" title="Remove">X</button>'; h += '</div>'; return h; } -// ── Render action ── +// ── Render step effects (everything except messages and condition). +// The add-bar is rendered separately via ie_renderStepAddbar. ── -function ie_renderAct(actionObj, scope, secId, idx, secPath) { +function ie_renderAct(stepObj, secId, idx, si, secPath) { var hasAny = false; var rows = ''; - // 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; - var rmFn = 'ie_removeMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ')'; - rows += '<div class="ie-act-kv" data-ie-act-key="messages">'; - rows += '<div class="ie-act-kv-header"><span>Messages</span></div>'; - rows += '<div class="ie-kv-list">'; - if (msgs.length === 0) { - rows += '<div class="ie-kv-row"><input type="number" class="ie-act-msg-delay" value="" placeholder="delay" style="width:60px" title="Ticks before showing">'; - rows += '<input class="ie-act-msg" data-ie-msg-idx="0" value="" placeholder="Shown to player" style="flex:1">'; - rows += '<button class="btn btn-sm btn-danger" onclick="' + rmFn + '">X</button></div>'; - } else { - msgs.forEach(function(m, mi) { - rows += '<div class="ie-kv-row"><input type="number" class="ie-act-msg-delay" value="' + escAttr(m.delay ? String(m.delay) : '') + '" placeholder="delay" style="width:60px" title="Ticks before showing">'; - rows += '<input class="ie-act-msg" data-ie-msg-idx="' + mi + '" value="' + escAttr(String(m.message || '')) + '" placeholder="Shown to player" style="flex:1">'; - rows += '<button class="btn btn-sm btn-danger" onclick="' + rmFn + '">X</button></div>'; - }); - } - rows += '</div>'; - rows += '<button class="btn btn-sm ie-add-btn" style="margin-top:2px" onclick="ie_addMessage(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ')">+ Add Message</button>'; - rows += '</div>'; - } - - // Standard effects IE_ACT_TYPES.forEach(function(at) { - if (!actionObj || !Object.prototype.hasOwnProperty.call(actionObj, at.key)) return; - var val = actionObj[at.key]; + if (!stepObj || !Object.prototype.hasOwnProperty.call(stepObj, at.key)) return; + var val = stepObj[at.key]; if (at.kind === 'checkbox' && val !== true) return; hasAny = true; if (at.kind === 'kv') { var kvMap = (val && typeof val === 'object') ? val : {}; var kvKeys = Object.keys(kvMap); - var kvRmAttr = 'ie_removeKVRow(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\')'; + var kvRmAttr = 'ie_removeKVRow(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',\'' + escAttr(at.key) + '\')'; rows += '<div class="ie-act-kv" data-ie-act-key="' + escAttr(at.key) + '">'; rows += '<div class="ie-act-kv-header"><span>' + esc(at.label) + '</span></div>'; rows += '<div class="ie-kv-list">'; if (kvKeys.length === 0) { - // Start with a blank row so there are input boxes immediately. The - // row's X acts as the section's only remove path (no header X). rows += '<div class="ie-kv-row"><input value="" placeholder="key" class="ie-kv-key">'; rows += '<input value="" placeholder="val" class="ie-kv-val">'; rows += '<button class="btn btn-sm btn-danger" onclick="' + kvRmAttr + '">X</button></div>'; @@ -498,92 +486,239 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) { }); } rows += '</div>'; - rows += '<button class="btn btn-sm ie-add-btn" style="margin-top:2px" onclick="ie_addKVRow(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\')">+ Add</button>'; + rows += '<button class="btn btn-sm ie-add-btn" style="margin-top:2px" onclick="ie_addKVRow(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',\'' + escAttr(at.key) + '\')">+ Add</button>'; rows += '</div>'; } else if (at.kind === 'checkbox') { rows += '<div class="ie-act-row"><span class="ie-act-label">' + esc(at.label) + '</span>'; rows += '<input type="checkbox" class="ie-act-input" data-ie-act-key="' + escAttr(at.key) + '" data-ie-act-kind="checkbox" checked hidden>'; - rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\')">X</button>'; + rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',\'' + escAttr(at.key) + '\')">X</button>'; rows += '</div>'; } else if (at.kind === 'search') { rows += '<div class="ie-act-row"><span class="ie-act-label">' + esc(at.label) + '</span>'; rows += '<div class="obj-search" style="position:relative;min-width:0;flex:1"><input class="ie-act-input search-input" data-ie-act-key="' + escAttr(at.key) + '" data-search-type="' + escAttr(at.entity) + '" placeholder="' + escAttr(at.hint || 'Search ' + at.entity + '...') + '" value="' + escAttr(String(val)) + '"><div class="search-results" style="display:none"></div></div>'; - rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\')">X</button>'; + rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',\'' + escAttr(at.key) + '\')">X</button>'; rows += '</div>'; } else { rows += '<div class="ie-act-row"><span class="ie-act-label">' + esc(at.label) + '</span>'; rows += '<input class="ie-act-input" data-ie-act-key="' + escAttr(at.key) + '"' + (at.kind === 'number' ? ' type="number"' : '') + ' data-ie-act-kind="' + escAttr(at.kind) + '" value="' + escAttr(String(val)) + '" placeholder="' + escAttr(at.hint || '') + '" style="flex:1">'; - rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\')">X</button>'; + rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',\'' + escAttr(at.key) + '\')">X</button>'; rows += '</div>'; } }); - // Seq-only effects - if (scope === 'seq') { - IE_ACT_TYPES_SEQ.forEach(function(at) { - if (!actionObj || !Object.prototype.hasOwnProperty.call(actionObj, at.key)) return; - var val = actionObj[at.key]; - if (val === undefined || val === null) return; - hasAny = true; - - if (at.kind === 'search') { - rows += '<div class="ie-act-row"><span class="ie-act-label">' + esc(at.label) + '</span>'; - rows += '<div class="obj-search" style="position:relative;min-width:0;flex:1"><input class="ie-act-input search-input" data-ie-act-key="' + escAttr(at.key) + '" data-search-type="' + escAttr(at.entity) + '" placeholder="' + escAttr(at.hint || 'Search ' + at.entity + '...') + '" value="' + escAttr(String(val)) + '"><div class="search-results" style="display:none"></div></div>'; - rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\')">X</button>'; - rows += '</div>'; - } else { - rows += '<div class="ie-act-row"><span class="ie-act-label">' + esc(at.label) + '</span>'; - rows += '<input class="ie-act-input" data-ie-act-key="' + escAttr(at.key) + '" value="' + escAttr(String(val)) + '" placeholder="' + escAttr(at.hint || '') + '" style="flex:1">'; - rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\')">X</button>'; - rows += '</div>'; - } - }); + if (hasAny) { + return '<div class="ie-act-root">' + rows + '</div>'; } + return ''; +} + +// ── Render messages sub-list (plain strings) ── - // Add effect buttons - var addbar = '<div class="ie-act-addbar">'; - if (!msgsPresent) { - addbar += '<button class="btn btn-sm ie-add-btn" onclick="ie_addActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'messages\',\'' + escAttr(scope) + '\')">+ Add Message</button>'; +function ie_renderMessages(step, secId, idx, si, secPath) { + var present = step && Array.isArray(step.messages); + if (!present) { + return '<button class="btn btn-sm ie-add-btn" onclick="ie_addMessages(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ')">+ Add Message</button>'; } - IE_ACT_TYPES.forEach(function(at) { - if (actionObj && Object.prototype.hasOwnProperty.call(actionObj, at.key)) { - if (at.kind === 'checkbox') { - if (actionObj[at.key] === true) return; - } else { - return; + var msgs = step.messages; + var rmFn = 'ie_removeMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ')'; + var h = '<div class="ie-act-kv" data-ie-act-key="messages">'; + h += '<div class="ie-act-kv-header"><span>Messages</span></div>'; + h += '<div class="ie-kv-list">'; + msgs.forEach(function(m, mi) { + var upBtn = mi > 0 + ? '<button class="btn btn-sm ie-msg-up" onclick="ie_moveMessage(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',' + mi + ',' + (mi - 1) + ')" title="Move up">▲</button>' + : ''; + var dnBtn = mi < msgs.length - 1 + ? '<button class="btn btn-sm ie-msg-dn" onclick="ie_moveMessage(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',' + mi + ',' + (mi + 1) + ')" title="Move down">▼</button>' + : ''; + h += '<div class="ie-kv-row ie-msg-row"><input class="ie-act-msg" data-ie-msg-idx="' + mi + '" value="' + escAttr(String(m)) + '" placeholder="Shown to player" style="flex:1">' + upBtn + dnBtn + '<button class="btn btn-sm btn-danger" onclick="' + rmFn + '">X</button></div>'; + }); + h += '</div>'; + h += '<button class="btn btn-sm ie-add-btn" style="margin-top:2px" onclick="ie_addMessage(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ')">+ Add Message</button>'; + h += '</div>'; + return h; +} + +// ── Render steps list of a trigger entry ── + +function ie_renderStepList(steps, secId, idx, secPath) { + steps = steps || []; + var h = '<div class="ie-step-list" data-idx="' + idx + '">'; + steps.forEach(function(step, si) { + var stepCond = step && step.condition && typeof step.condition === 'object'; + h += '<div class="ie-step-row" data-step="' + si + '" data-idx="' + idx + '">'; + h += '<div class="ie-step-header">'; + h += '<span class="ie-step-label">Step ' + (si + 1) + '</span>'; + h += '<button class="btn btn-sm ie-step-addmore" onclick="this.closest(\'.ie-step-row\').classList.toggle(\'show-addbar\')" title="Add action to this step">+</button>'; + h += '<span class="ie-step-spacer"></span>'; + if (si > 0) h += '<button class="btn btn-sm ie-step-up" onclick="ie_moveStep(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',' + (si - 1) + ')" title="Move up">▲</button>'; + if (si < steps.length - 1) h += '<button class="btn btn-sm ie-step-dn" onclick="ie_moveStep(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',' + (si + 1) + ')" title="Move down">▼</button>'; + h += '<button class="btn btn-sm btn-danger" onclick="ie_removeStep(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ')" title="Remove step">X</button>'; + h += '</div>'; + + h += '<div class="ie-step-cond-panel">'; + if (stepCond) { + h += ie_renderCond(step.condition || null, secId, idx, secPath, 'step-' + si); + } else { + h += '<button class="btn btn-sm ie-add-btn" onclick="ie_addStepCond(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ')">+ Add Step Condition</button>'; + } + h += '</div>'; + + h += '<div class="ie-step-effects">' + ie_renderAct(step, secId, idx, si, secPath) + '</div>'; + h += '<div class="ie-step-addbar">' + ie_renderStepAddbar(step, secId, idx, si, secPath) + '</div>'; + h += '<div class="ie-step-messages">' + ie_renderMessages(step, secId, idx, si, secPath) + '</div>'; + + h += '</div>'; + }); + h += '<div class="ie-step-addbar-bottom">' + ie_renderBottomAddbar(secId, secPath, idx) + '</div>'; + h += '</div>'; + return h; +} + +// ── Collect effects (everything except messages and condition) ── + +function ie_collectAct(container) { + if (!container) return null; + var r = {}; + + container.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'); + var vEl = row.querySelector('.ie-kv-val'); + if (kEl && kEl.value.trim() && vEl) { + var v = vEl.value.trim(); + var num = parseFloat(v); + map[kEl.value.trim()] = v === 'true' ? true : (v === 'false' ? false : (!isNaN(num) ? num : v)); } + }); + r[key] = map; + }); + + container.querySelectorAll('.ie-act-input').forEach(function(el) { + var key = el.getAttribute('data-ie-act-key'); + if (!key) return; + var kind = el.getAttribute('data-ie-act-kind'); + if (kind === 'checkbox') { + r[key] = el.checked; + } else if (kind === 'number') { + var n = parseFloat(el.value); + if (isNaN(n)) return; + r[key] = n; + } else { + r[key] = el.value.trim(); } - addbar += '<button class="btn btn-sm ie-add-btn" onclick="ie_addActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\',\'' + escAttr(scope) + '\')">+ ' + esc(at.label) + '</button>'; }); - if (scope === 'seq') { - IE_ACT_TYPES_SEQ.forEach(function(at) { - if (actionObj && Object.prototype.hasOwnProperty.call(actionObj, at.key)) return; - addbar += '<button class="btn btn-sm ie-add-btn" onclick="ie_addActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\',\'' + escAttr(scope) + '\')">+ ' + esc(at.label) + '</button>'; + + if (Object.keys(r).length === 0) return null; + return r; +} + +// ── Collect messages (plain strings) ── + +function ie_collectMessages(container) { + if (!container) return null; + var msgsKv = container.querySelector('.ie-act-kv[data-ie-act-key="messages"]'); + if (!msgsKv) return null; + var list = []; + msgsKv.querySelectorAll('.ie-kv-row .ie-act-msg').forEach(function(el) { + list.push(el.value.trim()); + }); + return list; +} + +// ── Sync a single step's DOM back into data ── + +function ie_syncStep(ed, secId, secPath, idx, si) { + var card = document.querySelector('.obj-card[data-card="' + secId + '"]'); + if (!card) return; + var stepRow = card.querySelector('.obj-sub-row.ie-inter-row[data-idx="' + idx + '"] .ie-step-row[data-step="' + si + '"]'); + if (!stepRow) return; + if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].steps || !ed[secPath][idx].steps[si]) return; + var step = ed[secPath][idx].steps[si]; + + var condRoot = stepRow.querySelector(':scope > .ie-step-cond-panel .ie-cond-root'); + if (condRoot) { + var cond = ie_collectCond(condRoot); + if (cond) step.condition = cond; else delete step.condition; + } + + var effContainer = stepRow.querySelector(':scope > .ie-step-effects'); + if (effContainer) { + var act = ie_collectAct(effContainer); + if (act) { + Object.keys(act).forEach(function(k) { step[k] = act[k]; }); + } + // Drop keys the user X'd out (no longer present in the DOM among the rendered IE_ACT_TYPES set). + IE_ACT_TYPES.forEach(function(at) { + if (at.key === 'messages') return; + if (!Object.prototype.hasOwnProperty.call(act || {}, at.key)) { + if (Object.prototype.hasOwnProperty.call(step, at.key)) delete step[at.key]; + } }); } - addbar += '</div>'; - // Only wrap the content + addbar in .ie-act-root when there's actual content. - // An empty action just emits the addbar (mirroring ie_renderCond, which - // emits an unwrapped addbar for an empty condition). This keeps the empty - // condition and empty action panels visually symmetric — one box, not two. - if (hasAny) { - return '<div class="ie-act-root">' + rows + addbar + '</div>'; + var msgsContainer = stepRow.querySelector(':scope > .ie-step-messages'); + if (msgsContainer) { + var list = ie_collectMessages(msgsContainer); + if (list !== null) step.messages = list; + else delete step.messages; + if (Array.isArray(step.messages) && step.messages.length === 0) { + // keep empty array — survives next render, ced_cleanOutput will strip + // it on save. + } } - return addbar; +} + +// ── Sync all interaction DOM back to data ── + +function ie_syncAllInteractions(data, secs) { + if (!data || !secs) return; + secs.forEach(function(sec) { + if (!sec.interactionStyle || !sec.isArray) return; + var p = sec.path; + if (!data[p]) return; + var card = document.querySelector('.obj-card[data-card="' + sec.id + '"]'); + if (!card) return; + var rows = card.querySelectorAll('.obj-sub-row.ie-inter-row'); + rows.forEach(function(row, idx) { + if (!data[p][idx]) data[p][idx] = {}; + var lockCb = row.querySelector(':scope > .ie-inter-header .ie-lock-cb'); + if (lockCb) { + if (lockCb.checked) data[p][idx].lock = true; + else delete data[p][idx].lock; + } + var entryCondRoot = row.querySelector(':scope > .ie-inter-panel .ie-cond-root'); + if (entryCondRoot) { + var cond = ie_collectCond(entryCondRoot); + if (cond) data[p][idx].condition = cond; + else delete data[p][idx].condition; + } + if (!data[p][idx].steps) data[p][idx].steps = []; + var stepRows = row.querySelectorAll(':scope > .ie-inter-steps .ie-step-row'); + stepRows.forEach(function(stepRow) { + var si = parseInt(stepRow.getAttribute('data-step'), 10); + if (isNaN(si)) return; + ie_syncStep(data, sec.id, p, idx, si); + }); + }); + }); } // ── Add/Remove/Toggle: Condition ── -function ie_addLeaf(secId, secPath, idx, groupPath, leafType) { +function ie_addLeaf(secId, secPath, idx, kind, groupPath, leafType) { 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; + _ie_syncCond(ed, secId, secPath, idx, kind); + var cond = _ie_getCond(ed, secPath, idx, kind); var group = ie_condAt(cond, groupPath); var leaf = {}; - leaf[leafType] = leafType === 'min_credits' ? 0 : ''; + leaf[leafType] = (leafType === 'min_credits' || leafType === 'room') ? 0 : ''; var newGroup; if (!group) { @@ -599,28 +734,21 @@ function ie_addLeaf(secId, secPath, idx, groupPath, leafType) { } var newCond = ie_condReplace(cond, groupPath, newGroup); - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (newCond) ed[secPath][idx].condition = newCond; - else delete ed[secPath][idx].condition; + _ie_setCond(ed, secPath, idx, kind, newCond); ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_removeCond(secId, secPath, idx, groupPath, childIdx) { +function ie_removeCond(secId, secPath, idx, kind, groupPath, childIdx) { 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; + _ie_syncCond(ed, secId, secPath, idx, kind); + var cond = _ie_getCond(ed, secPath, idx, kind); - // Removing the single root leaf. Note: children of a *root-level group* - // are also rendered with groupPath='' (and a real child index), so the - // test must be childIdx, not groupPath, otherwise removing any leaf from - // a root group wipes the entire condition. if (childIdx === IE_ROOT_LEAF) { - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - delete ed[secPath][idx].condition; + _ie_setCond(ed, secPath, idx, kind, null); ced_syncDOMToData(ed); - window._ieRenderOnly(); + window._ieRenderOnly(); return; } @@ -635,8 +763,6 @@ function ie_removeCond(secId, secPath, idx, groupPath, childIdx) { if (children.length === 0) { newGroup = null; } else if (children.length === 1 && !group.not) { - // Collapse a single-child non-NOT group to the child itself so removal - // doesn't leave a one-child all_of/any_of wrapper in the live tree. newGroup = children[0]; } else { newGroup = {}; @@ -644,36 +770,28 @@ function ie_removeCond(secId, secPath, idx, groupPath, childIdx) { newGroup[mode] = children; } var newCond = ie_condReplace(cond, groupPath, newGroup); - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (newCond) ed[secPath][idx].condition = newCond; - else delete ed[secPath][idx].condition; + _ie_setCond(ed, secPath, idx, kind, newCond); } else { var nc = ie_condReplace(cond, groupPath, null); - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (nc) ed[secPath][idx].condition = nc; - else delete ed[secPath][idx].condition; + _ie_setCond(ed, secPath, idx, kind, nc); } ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_removeGroup(secId, secPath, idx, groupPath) { +function ie_removeGroup(secId, secPath, idx, kind, groupPath) { var ed = window._editorData; if (!ed) return; - ie_syncCondEntry(ed, secId, secPath, idx); - if (!ed[secPath] || !ed[secPath][idx]) return; - var cond = ed[secPath][idx].condition; + _ie_syncCond(ed, secId, secPath, idx, kind); + var cond = _ie_getCond(ed, secPath, idx, kind); - // Root group: removing the entire condition. if (!groupPath) { - delete ed[secPath][idx].condition; + _ie_setCond(ed, secPath, idx, kind, null); ced_syncDOMToData(ed); - window._ieRenderOnly(); + window._ieRenderOnly(); return; } - // Non-root group: gpath is the group's own path. Parent path = gpath minus - // last segment; childIdx = last segment. var parts = groupPath.split('-').map(Number); var childIdx = parts[parts.length - 1]; var parentPath = parts.slice(0, -1).join('-'); @@ -696,41 +814,41 @@ function ie_removeGroup(secId, secPath, idx, groupPath) { } var newCond = ie_condReplace(cond, parentPath, newParent); - if (newCond) ed[secPath][idx].condition = newCond; - else delete ed[secPath][idx].condition; + _ie_setCond(ed, secPath, idx, kind, newCond); ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_addRootConditionGroup(secPath, idx) { +function ie_addRootConditionGroup(secPath, idx, kind) { var ed = window._editorData; if (!ed) return; if (!ed[secPath]) ed[secPath] = []; if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (!ed[secPath][idx].condition || typeof ed[secPath][idx].condition !== 'object') { - ed[secPath][idx].condition = {all_of: []}; + var si = _ie_kindStep(kind); + if (si < 0) { + if (!ed[secPath][idx].condition || typeof ed[secPath][idx].condition !== 'object') { + ed[secPath][idx].condition = {all_of: []}; + } + } else { + if (!ed[secPath][idx].steps) ed[secPath][idx].steps = []; + if (!ed[secPath][idx].steps[si]) ed[secPath][idx].steps[si] = {}; + if (!ed[secPath][idx].steps[si].condition || typeof ed[secPath][idx].steps[si].condition !== 'object') { + ed[secPath][idx].steps[si].condition = {all_of: []}; + } } ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_addEffectToInteraction(secPath, idx) { - var ed = window._editorData; - if (!ed) return; - if (!ed[secPath]) ed[secPath] = []; - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (!ed[secPath][idx].action || typeof ed[secPath][idx].action !== 'object') { - ed[secPath][idx].action = {}; - } - ced_syncDOMToData(ed); - window._ieRenderOnly(); +function ie_addStepCond(secId, secPath, idx, si) { + ie_addRootConditionGroup(secPath, idx, 'step-' + si); } -function ie_addGroup(secId, secPath, idx, groupPath) { +function ie_addGroup(secId, secPath, idx, kind, 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; + _ie_syncCond(ed, secId, secPath, idx, kind); + var cond = _ie_getCond(ed, secPath, idx, kind); var group = ie_condAt(cond, groupPath); var newSub = {all_of: []}; @@ -749,17 +867,16 @@ function ie_addGroup(secId, secPath, idx, groupPath) { } var newCond = ie_condReplace(cond, groupPath, newGroup); - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - ed[secPath][idx].condition = newCond; + _ie_setCond(ed, secPath, idx, kind, newCond); ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_toggleCondMode(secId, secPath, idx, groupPath) { +function ie_toggleCondMode(secId, secPath, idx, kind, 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; + _ie_syncCond(ed, secId, secPath, idx, kind); + var cond = _ie_getCond(ed, secPath, idx, kind); var group = ie_condAt(cond, groupPath); if (!group || !group.all_of && !group.any_of) return; @@ -770,291 +887,257 @@ function ie_toggleCondMode(secId, secPath, idx, groupPath) { newGroup[oldMode === 'all_of' ? 'any_of' : 'all_of'] = children; var newCond = ie_condReplace(cond, groupPath, newGroup); - ed[secPath][idx].condition = newCond; + _ie_setCond(ed, secPath, idx, kind, newCond); ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_toggleCondNot(secId, secPath, idx, groupPath) { +function ie_toggleCondNot(secId, secPath, idx, kind, groupPath) { + ced_syncDOMToData(window._editorData); + window._ieRenderOnly(); +} + +function ie_toggleNotLeaf(secId, secPath, idx, kind, groupPath, ci) { + ced_syncDOMToData(window._editorData); + window._ieRenderOnly(); +} + +// ── Add/Remove: Steps ── + +function ie_removeStep(secId, secPath, idx, si) { var ed = window._editorData; if (!ed) return; - ie_syncCondEntry(ed, secId, secPath, idx); ced_syncDOMToData(ed); - window._ieRenderOnly(); + if (window._ieSyncAll) window._ieSyncAll(ed); + if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].steps) return; + ed[secPath][idx].steps.splice(si, 1); + window._ieRenderCurrent(); } -function ie_toggleNotLeaf(secId, secPath, idx, groupPath, ci) { +function ie_moveStep(secId, secPath, idx, fromSi, toSi) { var ed = window._editorData; if (!ed) return; - ie_syncCondEntry(ed, secId, secPath, idx); ced_syncDOMToData(ed); + if (window._ieSyncAll) window._ieSyncAll(ed); + var steps = ed[secPath] && ed[secPath][idx] && ed[secPath][idx].steps; + if (!steps || fromSi < 0 || fromSi >= steps.length || toSi < 0 || toSi >= steps.length) return; + var m = steps.splice(fromSi, 1)[0]; + steps.splice(toSi, 0, m); + window._ieRenderCurrent(); +} + +function ie_moveRow(secPath, fromIdx, toIdx) { + var ed = window._editorData; + if (!ed) return; + ced_syncDOMToData(ed); + if (window._ieSyncAll) window._ieSyncAll(ed); + var arr = ed[secPath]; + if (!arr || fromIdx < 0 || fromIdx >= arr.length || toIdx < 0 || toIdx >= arr.length) return; + var m = arr.splice(fromIdx, 1)[0]; + arr.splice(toIdx, 0, m); + window._ieRenderCurrent(); +} + +function ie_moveMessage(secId, secPath, idx, si, fromMi, toMi) { + var ed = window._editorData; + if (!ed) return; + ced_syncDOMToData(ed); + ie_syncStep(ed, secId, secPath, idx, si); + var step = ed[secPath] && ed[secPath][idx] && ed[secPath][idx].steps && ed[secPath][idx].steps[si]; + if (!step || !Array.isArray(step.messages)) return; + if (fromMi < 0 || fromMi >= step.messages.length || toMi < 0 || toMi >= step.messages.length) return; + var m = step.messages.splice(fromMi, 1)[0]; + step.messages.splice(toMi, 0, m); window._ieRenderOnly(); } -// ── Add/Remove: Action ── +// ── Add/Remove: Action effects ── -function ie_addActEffect(secId, secPath, idx, effectKey, scope) { +function ie_addActEffect(secId, secPath, idx, si, effectKey) { var ed = window._editorData; if (!ed) return; - ie_syncActEntry(ed, secId, secPath, idx); + ie_syncStep(ed, secId, secPath, idx, si); if (!ed[secPath]) ed[secPath] = []; if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (!ed[secPath][idx].action) ed[secPath][idx].action = {}; - - var act = ed[secPath][idx].action; - if (effectKey === 'messages') { - act.messages = []; + if (!ed[secPath][idx].steps) ed[secPath][idx].steps = []; + if (!ed[secPath][idx].steps[si]) ed[secPath][idx].steps[si] = {}; + var step = ed[secPath][idx].steps[si]; + + var at = IE_ACT_TYPES.find(function(a) { return a.key === effectKey; }); + if (!at) return; + if (at.kind === 'kv') { + step[effectKey] = {}; + } else if (at.kind === 'checkbox') { + step[effectKey] = true; + } else if (at.kind === 'number') { + step[effectKey] = 0; } 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; }); - var info = at || at2; - if (!info) return; - if (info.kind === 'kv') { - act[effectKey] = {}; - } else if (info.kind === 'checkbox') { - act[effectKey] = true; - } else if (info.kind === 'number') { - act[effectKey] = 0; - } else { - act[effectKey] = ''; - } + step[effectKey] = ''; } ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_removeActEffect(secId, secPath, idx, effectKey) { +function ie_removeActEffect(secId, secPath, idx, si, effectKey) { var ed = window._editorData; if (!ed) return; - ie_syncActEntry(ed, secId, secPath, idx); - if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].action) return; - delete ed[secPath][idx].action[effectKey]; - if (Object.keys(ed[secPath][idx].action).length === 0) { - delete ed[secPath][idx].action; - } + ie_syncStep(ed, secId, secPath, idx, si); + var step = ed[secPath] && ed[secPath][idx] && ed[secPath][idx].steps && ed[secPath][idx].steps[si]; + if (!step) return; + delete step[effectKey]; ced_syncDOMToData(ed); window._ieRenderOnly(); } -// ── Add/Remove: KV action row ── -// -// KV rows are added/removed through these handlers rather than inline DOM -// manipulation so removal can drop the whole effect when the last row goes. -// "+ Add" appends a blank row to the DOM (no data change yet — it joins the -// map on the next sync, the same way typing into an existing row does). -// Removal deletes the row's key from the data map; if the map empties out the -// whole action key is dropped so the section "disappears when all flags are -// removed" (per the spec) and re-renders with a single starter row only when -// the user clicks the addbar "+ Set ... Flags" again. -function ie_addKVRow(secId, secPath, idx, effectKey) { +// ── Add/Remove: KV row ── + +function ie_addKVRow(secId, secPath, idx, si, effectKey) { var ed = window._editorData; if (!ed) return; - // Sync first so any in-progress typing in the existing rows is captured. - ie_syncActEntry(ed, secId, secPath, idx); - // Append a blank row directly to the DOM (no data change yet). + ie_syncStep(ed, secId, secPath, idx, si); 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="' + effectKey + '"] .ie-kv-list'); + var stepRow = card.querySelector('.obj-sub-row.ie-inter-row[data-idx="' + idx + '"] .ie-step-row[data-step="' + si + '"]'); + if (!stepRow) return; + var list = stepRow.querySelector('.ie-act-kv[data-ie-act-key="' + effectKey + '"] .ie-kv-list'); if (!list) return; var rowEl = document.createElement('div'); rowEl.className = 'ie-kv-row'; - rowEl.innerHTML = '<input value="" placeholder="key" class="ie-kv-key"><input value="" placeholder="val" class="ie-kv-val"><button class="btn btn-sm btn-danger" onclick="ie_removeKVRow(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(effectKey) + '\')">X</button>'; + rowEl.innerHTML = '<input value="" placeholder="key" class="ie-kv-key"><input value="" placeholder="val" class="ie-kv-val"><button class="btn btn-sm btn-danger" onclick="ie_removeKVRow(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',\'' + escAttr(effectKey) + '\')">X</button>'; list.appendChild(rowEl); rowEl.querySelector('.ie-kv-key').focus(); } -function ie_removeKVRow(rowEl, secId, secPath, idx, effectKey) { +function ie_removeKVRow(rowEl, secId, secPath, idx, si, effectKey) { var ed = window._editorData; if (!ed) return; - // rowEl may be the X button itself (callers use `this` in inline onclick) — - // walk up to the actual .ie-kv-row so the key input below is found. 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(effectKey)) return; + ie_syncStep(ed, secId, secPath, idx, si); + var step = ed[secPath] && ed[secPath][idx] && ed[secPath][idx].steps && ed[secPath][idx].steps[si]; + if (!step || !step.hasOwnProperty(effectKey)) return; var keyEl = rowEl.querySelector('.ie-kv-key'); var k = keyEl ? keyEl.value.trim() : ''; - if (k && act[effectKey].hasOwnProperty(k)) delete act[effectKey][k]; - // Empty-key rows are not in the data map at all; nothing to delete there. - if (Object.keys(act[effectKey]).length === 0) { - delete act[effectKey]; - if (Object.keys(act).length === 0) delete ed[secPath][idx].action; + if (k && step[effectKey].hasOwnProperty(k)) delete step[effectKey][k]; + if (Object.keys(step[effectKey]).length === 0) { + delete step[effectKey]; } ced_syncDOMToData(ed); window._ieRenderOnly(); } -// ── Add/Remove: Messages list row ── +// ── Add/Remove: Messages ── + +function ie_addMessages(secId, secPath, idx, si) { + var ed = window._editorData; + if (!ed) return; + ie_syncStep(ed, secId, secPath, idx, si); + if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].steps || !ed[secPath][idx].steps[si]) return; + ed[secPath][idx].steps[si].messages = ['']; + ced_syncDOMToData(ed); + window._ieRenderOnly(); +} -// 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) { +function ie_addMessage(secId, secPath, idx, si) { var ed = window._editorData; if (!ed) return; - ie_syncActEntry(ed, secId, secPath, idx); + ie_syncStep(ed, secId, secPath, idx, si); 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'); + var stepRow = card.querySelector('.obj-sub-row.ie-inter-row[data-idx="' + idx + '"] .ie-step-row[data-step="' + si + '"]'); + if (!stepRow) return; + var list = stepRow.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 = '<input type="number" class="ie-act-msg-delay" value="" placeholder="delay" style="width:60px" title="Ticks before showing"><input class="ie-act-msg" value="" placeholder="Shown to player" style="flex:1"><button class="btn btn-sm btn-danger" onclick="' + rmFn + '">X</button>'; + rowEl.className = 'ie-kv-row ie-msg-row'; + var rmFn = 'ie_removeMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ')'; + rowEl.innerHTML = '<input class="ie-act-msg" value="" placeholder="Shown to player" style="flex:1"><button class="btn btn-sm btn-danger" onclick="' + rmFn + '">X</button>'; 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) { +function ie_removeMessage(rowEl, secId, secPath, idx, si) { 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; + ie_syncStep(ed, secId, secPath, idx, si); + var step = ed[secPath] && ed[secPath][idx] && ed[secPath][idx].steps && ed[secPath][idx].steps[si]; + if (!step || !Array.isArray(step.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'); + var stepRow = card.querySelector('.obj-sub-row.ie-inter-row[data-idx="' + idx + '"] .ie-step-row[data-step="' + si + '"]'); + if (stepRow) { + var kvRows = stepRow.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); + step.messages.splice(ri, 1); break; } } } - if (act.messages.length === 0) { - delete act.messages; - if (Object.keys(act).length === 0) delete ed[secPath][idx].action; + if (step.messages.length === 0) { + delete step.messages; } ced_syncDOMToData(ed); window._ieRenderOnly(); } -// ── Pre-render sync helpers ── +// ── Shared trigger-style array-section renderer ── -function ie_syncCondEntry(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 condRoot = row.querySelector('.ie-cond-root'); - if (!condRoot) return; - var cond = ie_collectCond(condRoot); - if (!ed[secPath]) ed[secPath] = []; - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (cond) ed[secPath][idx].condition = cond; - else delete ed[secPath][idx].condition; -} - -function 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 actRoot = row.querySelector('.ie-act-root'); - if (!actRoot) return; - var act = ie_collectAct(actRoot); - if (!ed[secPath]) ed[secPath] = []; - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (act) { - // Preserve any action keys that aren't rendered in the DOM (e.g. legacy - // seq-only effects on an inline section, or unknown future keys). Without - // this, the first sync after any edit would silently drop them. - var existing = (ed[secPath][idx].action && typeof ed[secPath][idx].action === 'object') ? ed[secPath][idx].action : null; - if (existing) { - Object.keys(existing).forEach(function(k) { - if (!Object.prototype.hasOwnProperty.call(act, k)) act[k] = existing[k]; - }); - } - ed[secPath][idx].action = act; - } else { - delete ed[secPath][idx].action; - } -} - -// ── Shared interaction-style array-section renderer ── -// -// Replaces the duplicated `renderArraySection` (objecteditor.js) and -// `renderMobArraySection` (mobeditor.js). -// -// ctx: { -// sec: section def (.id, .path, .isArray, .interactionStyle, .interScope, .fields, .label), -// data: editor's data root, -// visMap: per-editor vis object (e.g. window._interVis[sec.id]), -// showRemove(idx) => onclick string for "Remove Interaction", -// showField(secId, idx, key) => onclick string for "+ Add Required Item/...", -// hideField(secId, idx, key) => onclick string for the panel "X", -// renderField(sec, f, data, idx, optStyle) => HTML for the item_id field, -// onAdd() => onclick string for the bottom "+ Add ...", -// } function ie_renderArraySection(ctx) { var sec = ctx.sec; var data = ctx.data; var visMap = ctx.visMap; var arr = data[sec.path] || []; var h = ''; + var allowItem = sec.itemIDAllowed !== false; arr.forEach(function(item, idx) { visMap[idx] = visMap[idx] || {}; var vis = visMap[idx]; - var showItem = vis.item_id || (item.item_id && item.item_id !== ''); + var showItem = allowItem && (vis.item_id || (item.item_id && item.item_id !== '')); var condExists = !!(item.condition && typeof item.condition === 'object'); - var actExists = !!(item.action && typeof item.action === 'object'); + var lockVal = !!item.lock; + var steps = item.steps || []; h += '<div class="obj-sub-row ie-inter-row" data-idx="' + idx + '">'; h += '<div class="ie-inter-header">'; - if (!showItem) { - h += '<button class="btn btn-sm ie-add-btn" onclick="' + ctx.showField(sec.id, idx, 'item_id') + '">+ Add Required Item</button>'; - } else { - h += ctx.renderField(sec, sec.fields[0], data, idx, 'flex:1;min-width:0'); - h += '<button class="btn btn-sm btn-danger ie-inter-field-x" onclick="' + ctx.hideField(sec.id, idx, 'item_id') + '" title="Remove required item">X</button>'; + h += '<label class="ie-lock-lbl"><input type="checkbox" class="ie-lock-cb"' + (lockVal ? ' checked' : '') + '> Lock Player</label>'; + if (idx > 0) h += '<button class="btn btn-sm ie-trig-up" onclick="ie_moveRow(\'' + escAttr(sec.path) + '\',' + idx + ',' + (idx - 1) + ')" title="Move up">▲</button>'; + if (idx < arr.length - 1) h += '<button class="btn btn-sm ie-trig-dn" onclick="ie_moveRow(\'' + escAttr(sec.path) + '\',' + idx + ',' + (idx + 1) + ')" title="Move down">▼</button>'; + + if (allowItem) { + if (!showItem) { + h += '<button class="btn btn-sm ie-add-btn" onclick="' + ctx.showField(sec.id, idx, 'item_id') + '">+ Add Required Item</button>'; + } else { + h += ctx.renderField(sec, sec.fields[0], data, idx, 'flex:1;min-width:0'); + h += '<button class="btn btn-sm btn-danger ie-inter-field-x" onclick="' + ctx.hideField(sec.id, idx, 'item_id') + '" title="Remove required item">X</button>'; + } } if (!condExists) { - h += '<button class="btn btn-sm ie-add-btn" onclick="' + ctx.addCondition(sec.id, idx) + '">+ Add Condition</button>'; - } - if (!actExists) { - h += '<button class="btn btn-sm ie-add-btn" onclick="' + ctx.addEffect(sec.id, idx) + '">+ Add Action</button>'; + h += '<button class="btn btn-sm ie-add-btn" onclick="ie_addRootConditionGroup(\'' + escAttr(sec.path) + '\',' + idx + ',\'entry\')">+ Add Condition</button>'; } h += '<span class="ie-inter-header-spacer"></span>'; - h += '<button class="btn btn-sm btn-danger ie-inter-remove" onclick="' + ctx.showRemove(idx) + '">Remove Interaction</button>'; + h += '<button class="btn btn-sm btn-danger ie-inter-remove" onclick="' + ctx.showRemove(idx) + '">Remove Trigger</button>'; h += '</div>'; h += '<div class="ie-inter-panel" style="display:' + (condExists ? 'block' : 'none') + '">'; - h += '<div class="ie-inter-panel-body">' + ie_renderCond(item.condition || null, sec.id, idx, sec.path) + '</div>'; + h += '<div class="ie-inter-panel-body">' + ie_renderCond(item.condition || null, sec.id, idx, sec.path, 'entry') + '</div>'; h += '</div>'; - h += '<div class="ie-inter-panel" style="display:' + (actExists ? 'block' : 'none') + '">'; - h += '<div class="ie-inter-panel-body">' + ie_renderAct(item.action || null, sec.interScope || 'inline', sec.id, idx, sec.path) + '</div>'; + h += '<div class="ie-inter-steps">'; + h += ie_renderStepList(steps, sec.id, idx, sec.path); h += '</div>'; h += '</div>'; }); - h += '<button class="btn btn-sm obj-sub-add" onclick="' + ctx.onAdd() + '">+ Add ' + esc(sec.label) + '</button>'; + h += '<button class="btn btn-sm obj-sub-add" onclick="' + ctx.onAdd() + '">+ Add ' + esc(sec.label || 'Trigger') + '</button>'; return h; } @@ -1072,12 +1155,37 @@ function ie_hideField(visMap, secId, idx, key) { visMap[secId][idx][key] = false; } +// ── Step-cleaning helper used by save paths ── + +function ie_cleanStep(step) { + var s = {}; + if (step && step.condition) { + var c = ie_cleanCondition(step.condition); + if (c) s.condition = c; + } + if (step && step.wait !== undefined && step.wait !== null && step.wait !== 0) s.wait = step.wait; + if (step && Array.isArray(step.messages) && step.messages.filter(function(m){return m;}).length > 0) { + s.messages = step.messages.filter(function(m){return m;}); + } + IE_ACT_TYPES.forEach(function(at) { + if (at.key === 'wait' || at.key === 'messages') return; + if (!step || !Object.prototype.hasOwnProperty.call(step, at.key)) return; + var v = step[at.key]; + if (v === undefined || v === null || v === '') return; + if (typeof v === 'object') { + if (Object.keys(v).length === 0) return; + s[at.key] = v; + } else if (typeof v === 'boolean') { + if (!v) return; + s[at.key] = v; + } else { + s[at.key] = v; + } + }); + return s; +} + // ── Bindings (set by each editor) ── -// _ieRenderCurrent: full sync + render. Used by chrome toggles -// (show/hide panels, add row, remove row, add section, etc.). -// _ieRenderOnly: render only, no sync. Used by ie_* mutation handlers -// that have already synced + mutated the data and must not re-read the -// stale DOM before the next render. window._ieRenderCurrent = function() {}; -window._ieRenderOnly = function() {}; +window._ieRenderOnly = function() {};
\ No newline at end of file diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 00230a4..ab20a86 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -575,6 +575,8 @@ function renderSidePanel(data) { html += '<button class="panel-tab' + (currentPanelTab === 5 ? ' active' : '') + '" onclick="switchPanelTab(5)">Items</button>'; html += '<button class="panel-tab' + (currentPanelTab === 6 ? ' active' : '') + '" onclick="switchPanelTab(6)">Hazard</button>'; html += '<button class="panel-tab' + (currentPanelTab === 7 ? ' active' : '') + '" onclick="switchPanelTab(7)">Agility</button>'; + html += '<button class="panel-tab' + (currentPanelTab === 8 ? ' active' : '') + '" onclick="switchPanelTab(8)">Enter/Exit</button>'; + html += '<button class="panel-tab' + (currentPanelTab === 9 ? ' active' : '') + '" onclick="switchPanelTab(9)">Triggers</button>'; html += '</div>'; html += '<div class="panel-tab-content">'; @@ -595,6 +597,8 @@ function renderSidePanel(data) { html += renderCourseTab(); } } + else if (currentPanelTab === 8) html += renderEnterExitTab(r); + else if (currentPanelTab === 9) html += renderTriggersTab(r); html += '</div>'; panel.innerHTML = html; @@ -1401,6 +1405,8 @@ function switchPanelTab(idx) { html = renderCourseTab(); } } + else if (idx === 8) html = renderEnterExitTab(r); + else if (idx === 9) html = renderTriggersTab(r); var content = document.querySelector('.panel-tab-content'); if (content) content.innerHTML = html; var tabs = document.querySelectorAll('.panel-tab'); @@ -2096,6 +2102,10 @@ async function doSavePanel() { r.mobs = currentRoomData.room.mobs; r.exits = currentRoomData.room.exits; r.hazard = currentRoomData.room.hazard || ''; + r.on_enter = currentRoomData.room.on_enter; + r.on_exit = currentRoomData.room.on_exit; + r.on_flag_change = currentRoomData.room.on_flag_change; + r.on_global_flag_change = currentRoomData.room.on_global_flag_change; if (r.block_transport === undefined) r.block_transport = currentRoomData.room.block_transport; } @@ -3350,3 +3360,222 @@ document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function window.addEventListener('keydown', function(e) { if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); } }); window.addEventListener('keyup', function(e) { if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); } }); }); + +// ── Enter/Exit tab ── +// Renders on_enter and on_exit trigger lists, wiring the shared ie_* helpers +// from intereditor.js so the same condition/step/message editors used by the +// object/mob editors are reused here. Edits mutate currentRoomData.room.* +// and the debounced autoSave() persists them. + +function renderEnterExitTab(r) { + var onEnter = Array.isArray(r.on_enter) ? r.on_enter : []; + var onExit = Array.isArray(r.on_exit) ? r.on_exit : []; + window._editorData = { on_enter: onEnter, on_exit: onExit }; + + window._ieRenderCurrent = function() { + if (window._ieSyncAll) window._ieSyncAll(window._editorData, [ + {id: 'on_enter', path: 'on_enter', isArray: true, interactionStyle: true, itemIDAllowed: false, label: 'Trigger'}, + {id: 'on_exit', path: 'on_exit', isArray: true, interactionStyle: true, itemIDAllowed: false, label: 'Trigger'} + ]); + if (currentRoomData && currentRoomData.room) { + currentRoomData.room.on_enter = window._editorData.on_enter; + currentRoomData.room.on_exit = window._editorData.on_exit; + } + switchPanelTab(8); + autoSave(); + }; + window._ieRenderOnly = window._ieRenderCurrent; + window._ieSyncAll = ie_syncAllInteractions; + + var h = '<h3 style="margin:8px 0 4px">On-Enter Triggers</h3>'; + h += '<p style="color:#888;font-size:11px;margin:0 0 6px">Fires when a player enters this room. First matching entry wins.</p>'; + h += renderTriggerListHTML(r.on_enter, 'on_enter', 'on_enter', false); + h += '<h3 style="margin:16px 0 4px">On-Exit Triggers</h3>'; + h += '<p style="color:#888;font-size:11px;margin:0 0 6px">Fires when a player leaves this room. First matching entry wins.</p>'; + h += renderTriggerListHTML(r.on_exit, 'on_exit', 'on_exit', false); + return h; +} + +// ── Triggers tab (on_flag_change / on_global_flag_change) ── + +function renderTriggersTab(r) { + var fc = Array.isArray(r.on_flag_change) ? r.on_flag_change : []; + var gfc = Array.isArray(r.on_global_flag_change) ? r.on_global_flag_change : []; + window._editorData = { on_flag_change: fc, on_global_flag_change: gfc }; + + window._ieRenderCurrent = function() { + if (window._ieSyncAll) window._ieSyncAll(window._editorData, [ + {id: 'on_flag_change', path: 'on_flag_change', isArray: true, interactionStyle: true, itemIDAllowed: false, label: 'Trigger'}, + {id: 'on_global_flag_change', path: 'on_global_flag_change', isArray: true, interactionStyle: true, itemIDAllowed: false, label: 'Trigger'} + ]); + if (currentRoomData && currentRoomData.room) { + currentRoomData.room.on_flag_change = window._editorData.on_flag_change; + currentRoomData.room.on_global_flag_change = window._editorData.on_global_flag_change; + } + switchPanelTab(9); + autoSave(); + }; + window._ieRenderOnly = window._ieRenderCurrent; + window._ieSyncAll = ie_syncAllInteractions; + + var h = '<h3 style="margin:8px 0 4px">On-Flag-Change Triggers</h3>'; + h += '<p style="color:#888;font-size:11px;margin:0 0 6px">Fires when a player flag changes. Use the condition\'s "In Room" gate to scope to this room (delete it to make global).</p>'; + h += renderFlagTriggerListHTML(r.on_flag_change, 'on_flag_change', 'on_flag_change', 'player'); + h += '<h3 style="margin:16px 0 4px">On-Global-Flag-Change Triggers</h3>'; + h += '<p style="color:#888;font-size:11px;margin:0 0 6px">Fires when a global flag changes (no player involved).</p>'; + h += renderFlagTriggerListHTML(r.on_global_flag_change, 'on_global_flag_change', 'on_global_flag_change', 'global'); + return h; +} + +// renderTriggerListHTML emits the HTML for a []Trigger block using the shared +// ie_renderArraySection. It's a thin shim that builds the ctx the card +// editors construct. +function renderTriggerListHTML(arr, secId, secPath, allowItem) { + var visMap = {}; + arr = Array.isArray(arr) ? arr : []; + var sec = { + id: secId, + path: secPath, + isArray: true, + interactionStyle: true, + itemIDAllowed: allowItem, + label: 'Trigger', + fields: [['item_id', 'Item ID', 'search', 'e.g. keycard', '', null, 'items']] + }; + var ctx = { + sec: sec, + data: window._editorData, + visMap: visMap, + showField: function(sid, idx, key) { + ie_showField(visMap, sid, idx, key); + return 'void(0)'; + }, + hideField: function(sid, idx, key) { + ie_hideField(visMap, sid, idx, key); + window._ieRenderCurrent(); + return 'void(0)'; + }, + addCondition: function(sid, idx) { + return 'ie_addRootConditionGroup(\'' + sid + '\',' + idx + ',\'entry\')'; + }, + showRemove: function(idx) { + return '_mapRemoveTrigger(\'' + secPath + '\',' + idx + ')'; + }, + renderField: function(s, field, data, idx, style) { + var val = (data[secPath] && data[secPath][idx] && data[secPath][idx].item_id) || ''; + return '<div class="obj-search" style="position:relative;flex:1;min-width:0"><input class="search-input" data-field-path="' + secPath + '.' + idx + '.item_id" data-search-type="items" placeholder="item ID" value="' + escAttr(val) + '"><div class="search-results" style="display:none"></div></div>'; + }, + onAdd: function() { + return '_mapAddTrigger(\'' + secPath + '\')'; + } + }; + return ie_renderArraySection(ctx); +} + +// renderFlagTriggerListHTML is like renderTriggerListHTML but adds the +// on_player_flag / on_global_flag subscription header per entry. +function renderFlagTriggerListHTML(arr, secId, secPath, kind) { + var visMap = {}; + arr = Array.isArray(arr) ? arr : []; + var sec = { + id: secId, + path: secPath, + isArray: true, + interactionStyle: true, + itemIDAllowed: false, + label: 'Trigger', + fields: [] + }; + var flagKey = kind === 'player' ? 'on_player_flag' : 'on_global_flag'; + + var h = ''; + arr.forEach(function(item, idx) { + visMap[idx] = visMap[idx] || {}; + var condExists = !!(item.condition && typeof item.condition === 'object'); + var lockVal = !!item.lock; + var steps = item.steps || []; + var flagName = item[flagKey] || ''; + + h += '<div class="obj-sub-row ie-inter-row" data-idx="' + idx + '">'; + h += '<div class="ie-inter-header">'; + h += '<label class="ie-lock-lbl"><input type="checkbox" class="ie-lock-cb"' + (lockVal ? ' checked' : '') + '> Lock Player</label>'; + if (idx > 0) h += '<button class="btn btn-sm ie-trig-up" onclick="ie_moveRow(\'' + escAttr(secPath) + '\',' + idx + ',' + (idx - 1) + ')" title="Move up">▲</button>'; + if (idx < arr.length - 1) h += '<button class="btn btn-sm ie-trig-dn" onclick="ie_moveRow(\'' + escAttr(secPath) + '\',' + idx + ',' + (idx + 1) + ')" title="Move down">▼</button>'; + h += '<span class="ie-cond-leaf-label">' + esc(kind === 'player' ? 'Player Flag' : 'Global Flag') + '</span>'; + h += '<input class="ie-flag-name" data-ie-flag-key="' + escAttr(flagKey) + '" data-idx="' + idx + '" value="' + escAttr(flagName) + '" placeholder="' + escAttr(kind === 'player' ? 'player flag name' : 'global flag name') + '" style="flex:1;min-width:80px" oninput="_mapSyncFlag(\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(flagKey) + '\')">'; + h += '<span class="ie-cond-eq">=</span>'; + h += '<input class="ie-flag-value" data-idx="' + idx + '" value="' + escAttr(item.value !== undefined ? String(item.value) : '') + '" placeholder="exists" style="width:60px" oninput="_mapSyncFlag(\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(flagKey) + '\')">'; + h += '<span class="ie-inter-header-spacer"></span>'; + if (!condExists) { + h += '<button class="btn btn-sm ie-add-btn" onclick="ie_addRootConditionGroup(\'' + escAttr(secPath) + '\',' + idx + ',\'entry\')">+ Add Condition</button>'; + } + h += '<button class="btn btn-sm btn-danger ie-inter-remove" onclick="_mapRemoveTrigger(\'' + escAttr(secPath) + '\',' + idx + ')">Remove Trigger</button>'; + h += '</div>'; + h += '<div class="ie-inter-panel" style="display:' + (condExists ? 'block' : 'none') + '">'; + h += '<div class="ie-inter-panel-body">' + ie_renderCond(item.condition || null, secId, idx, secPath, 'entry') + '</div>'; + h += '</div>'; + h += '<div class="ie-inter-steps">'; + h += ie_renderStepList(steps, secId, idx, secPath); + h += '</div>'; + h += '</div>'; + }); + h += '<button class="btn btn-sm obj-sub-add" onclick="_mapAddFlagTrigger(\'' + escAttr(secPath) + '\',\'' + escAttr(flagKey) + '\')">+ Add Trigger</button>'; + return h; +} + +// _mapAddTrigger / _mapRemoveTrigger / _mapAddFlagTrigger: mutation helpers +// called from the inline onclick handlers. They sync the DOM, splice the +// _editorData array, and re-render. +function _mapAddTrigger(secPath) { + if (!window._editorData) return; + if (window._ieSyncAll) window._ieSyncAll(window._editorData, []); + if (!window._editorData[secPath]) window._editorData[secPath] = []; + window._editorData[secPath].push({steps: []}); + if (currentRoomData && currentRoomData.room) { + currentRoomData.room[secPath] = window._editorData[secPath]; + } + window._ieRenderCurrent(); +} + +function _mapAddFlagTrigger(secPath, flagKey) { + if (!window._editorData) return; + if (!window._editorData[secPath]) window._editorData[secPath] = []; + var entry = {steps: []}; + entry[flagKey] = ''; + window._editorData[secPath].push(entry); + if (currentRoomData && currentRoomData.room) { + currentRoomData.room[secPath] = window._editorData[secPath]; + } + window._ieRenderCurrent(); +} + +function _mapRemoveTrigger(secPath, idx) { + if (!window._editorData || !window._editorData[secPath]) return; + window._editorData[secPath].splice(idx, 1); + if (currentRoomData && currentRoomData.room) { + currentRoomData.room[secPath] = window._editorData[secPath]; + } + window._ieRenderCurrent(); +} + +function _mapSyncFlag(secPath, idx, flagKey) { + if (!window._editorData || !window._editorData[secPath]) return; + var row = document.querySelector('.ie-inter-row[data-idx="' + idx + '"]'); + if (!row) return; + var nameEl = row.querySelector('.ie-flag-name'); + var valEl = row.querySelector('.ie-flag-value'); + if (nameEl) { + window._editorData[secPath][idx][flagKey] = nameEl.value.trim(); + } + if (valEl) { + var v = valEl.value.trim(); + if (v === '') delete window._editorData[secPath][idx].value; + else { + try { window._editorData[secPath][idx].value = JSON.parse(v); } catch(e) { window._editorData[secPath][idx].value = v; } + } + } + if (currentRoomData && currentRoomData.room) { + currentRoomData.room[secPath] = window._editorData[secPath]; + } + autoSave(); +} diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js index c2364c8..463af0a 100644 --- a/internal/admin/static/mobeditor.js +++ b/internal/admin/static/mobeditor.js @@ -121,12 +121,10 @@ var MOB_SECTIONS = [ ] }, { - id: 'on_kill', label: 'On Kill', labelHint: '(fired when this mob is defeated or its task completes — additive over drops)', path: 'on_kill', isArray: true, fullWidth: true, interactionStyle: true, interScope: 'inline', + id: 'on_kill', label: 'On Kill', labelHint: '(fired when this mob is defeated or its task completes — additive over drops)', path: 'on_kill', isArray: true, fullWidth: true, interactionStyle: true, detect: function(d) { return d.on_kill && Array.isArray(d.on_kill); }, fields: [ ['item_id', 'Item ID', 'search', '(optional — only fires if you wield this weapon when the mob is defeated)', '', null, 'items'], - ['condition', 'Condition', 'inter_cond'], - ['action', 'Action', 'inter_act'], ] }, ]; @@ -813,8 +811,9 @@ function saveMob() { var cleanedCond = ie_cleanCondition(dataItem.condition); if (cleanedCond) item.condition = cleanedCond; } - if (dataItem.action && typeof dataItem.action === 'object') { - item.action = dataItem.action; + if (dataItem.lock) item.lock = true; + if (Array.isArray(dataItem.steps)) { + item.steps = dataItem.steps.map(ie_cleanStep).filter(function(s) { return s && Object.keys(s).length > 0; }); } } if (Object.keys(item).length > 0) arr.push(item); diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js index 9c5a279..1abccbf 100644 --- a/internal/admin/static/objecteditor.js +++ b/internal/admin/static/objecteditor.js @@ -86,21 +86,17 @@ var SECTIONS = [ ] }, { - id: 'on_use', label: 'On Use', labelHint: '(what happens when you use items on this)', path: 'on_use', isArray: true, fullWidth: true, interactionStyle: true, interScope: 'inline', + id: 'on_use', label: 'On Use', labelHint: '(what happens when you use items on this)', path: 'on_use', isArray: true, fullWidth: true, interactionStyle: true, detect: function(d) { return d.on_use && Array.isArray(d.on_use); }, fields: [ ['item_id', 'Item ID', 'search', 'e.g. keycard (empty = bare use)', '', null, 'items'], - ['condition', 'Condition', 'inter_cond'], - ['action', 'Action', 'inter_act'], ] }, { - id: 'on_look', label: 'On Look', labelHint: '(what happens when you "look <this>")', path: 'on_look', isArray: true, fullWidth: true, interactionStyle: true, interScope: 'inline', + id: 'on_look', label: 'On Look', labelHint: '(what happens when you "look <this>")', path: 'on_look', isArray: true, fullWidth: true, interactionStyle: true, detect: function(d) { return d.on_look && Array.isArray(d.on_look); }, fields: [ ['item_id', 'Item ID', 'search', '(optional — only fires if you carry this item)', '', null, 'items'], - ['condition', 'Condition', 'inter_cond'], - ['action', 'Action', 'inter_act'], ] }, ]; @@ -517,8 +513,6 @@ function renderArraySection(sec, data) { showRemove: function(idx) { return 'removeArrayItem(\'' + sec.id + '\',' + idx + ')'; }, showField: function(secId, idx, key) { return 'showInterField(\'' + secId + '\',' + idx + ',\'' + key + '\')'; }, hideField: function(secId, idx, key) { return 'hideInterField(\'' + secId + '\',' + idx + ',\'' + key + '\')'; }, - addCondition: function(secId, idx) { return 'addInterRootCondition(\'' + secId + '\',' + idx + ')'; }, - addEffect: function(secId, idx) { return 'addInterAction(\'' + secId + '\',' + idx + ')'; }, renderField: function(sec, f, data, idx, optStyle) { return renderField(sec, f, data, idx, null, optStyle); }, onAdd: function() { return 'addArrayItem(\'' + sec.id + '\')'; }, }); @@ -822,10 +816,15 @@ function addArrayItem(cardID) { var sec = SECTIONS.find(function(s) { return s.id === cardID; }); if (!sec || !sec.isArray) return; var arr = objectData[sec.path] || []; - var empty = {}; - sec.fields.forEach(function(f) { - empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : ''); - }); + var empty; + if (sec.interactionStyle) { + empty = {steps: []}; + } else { + empty = {}; + sec.fields.forEach(function(f) { + empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : ''); + }); + } arr.push(empty); objectData[sec.path] = arr; renderCurrent(); @@ -915,13 +914,7 @@ function showInterField(secId, idx, key) { function addInterRootCondition(secId, idx) { var sec = SECTIONS.find(function(s) { return s.id === secId; }); if (!sec) return; - ie_addRootConditionGroup(sec.path, idx); -} - -function addInterAction(secId, idx) { - var sec = SECTIONS.find(function(s) { return s.id === secId; }); - if (!sec) return; - ie_addEffectToInteraction(sec.path, idx); + ie_addRootConditionGroup(sec.path, idx, 'entry'); } function hideInterField(secId, idx, key) { @@ -932,8 +925,8 @@ function hideInterField(secId, idx, key) { ced_syncDOMToData(objectData); ie_syncAllInteractions(objectData, SECTIONS); if (objectData[secPath] && objectData[secPath][idx]) { - if (key === 'condition' || key === 'action') { - delete objectData[secPath][idx][key]; + if (key === 'condition') { + delete objectData[secPath][idx].condition; } else { objectData[secPath][idx][key] = ''; } @@ -1030,8 +1023,10 @@ function saveObject() { var cleanedCond = ie_cleanCondition(dataItem.condition); if (cleanedCond) item.condition = cleanedCond; } - if (dataItem.action && typeof dataItem.action === 'object') { - item.action = dataItem.action; + if (dataItem.lock) item.lock = true; + if (dataItem.steps && Array.isArray(dataItem.steps)) { + var cleanedSteps = dataItem.steps.map(ie_cleanStep).filter(function(s) { return Object.keys(s).length > 0; }); + if (cleanedSteps.length > 0) item.steps = cleanedSteps; } } if (Object.keys(item).length > 0) arr.push(item); diff --git a/internal/admin/templates/map.html b/internal/admin/templates/map.html index 0915aaa..79cd14f 100644 --- a/internal/admin/templates/map.html +++ b/internal/admin/templates/map.html @@ -50,5 +50,7 @@ </div> </div> </div> +<script src="/static/cardeditor.js"></script> +<script src="/static/intereditor.js"></script> <script src="/static/map.js"></script> {{end}} diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go index 12eef0b..75f836b 100644 --- a/internal/behavior/behavior.go +++ b/internal/behavior/behavior.go @@ -1,21 +1,21 @@ package behavior type GatherConfig struct { - Skill string `yaml:"skill"` - Tools []string `yaml:"tools"` - NoToolSpeed float64 `yaml:"no_tool_speed,omitempty"` - Bait string `yaml:"bait"` + Skill string `yaml:"skill"` + Tools []string `yaml:"tools"` + NoToolSpeed float64 `yaml:"no_tool_speed,omitempty"` + Bait string `yaml:"bait"` Success SuccessFormula `yaml:"success"` - GatherMessage string `yaml:"gather_message"` - DepletedMessage string `yaml:"depleted_message"` - ExhaustedMessage string `yaml:"exhausted_message"` - FailMessage string `yaml:"fail_message"` - Drops []DropEntry `yaml:"drops"` - RespawnTimer float64 `yaml:"respawn_timer"` - RespawnBroadcast string `yaml:"respawn_broadcast"` - DepleteTimer float64 `yaml:"deplete_timer"` - NestChance int `yaml:"nest_chance"` - BroadcastMessage string `yaml:"broadcast_message"` + GatherMessage string `yaml:"gather_message"` + DepletedMessage string `yaml:"depleted_message"` + ExhaustedMessage string `yaml:"exhausted_message"` + FailMessage string `yaml:"fail_message"` + Drops []DropEntry `yaml:"drops"` + RespawnTimer float64 `yaml:"respawn_timer"` + RespawnBroadcast string `yaml:"respawn_broadcast"` + DepleteTimer float64 `yaml:"deplete_timer"` + NestChance int `yaml:"nest_chance"` + BroadcastMessage string `yaml:"broadcast_message"` } type SuccessFormula struct { @@ -32,88 +32,83 @@ type TalkNode struct { Messages []string `yaml:"messages,omitempty"` Condition *Condition `yaml:"condition,omitempty"` Options []TalkOption `yaml:"options"` - Action *NodeAction `yaml:"action"` + Action *Step `yaml:"action,omitempty"` Goto string `yaml:"goto,omitempty"` } type TalkOption struct { - Text string `yaml:"text"` - Goto string `yaml:"goto"` - Condition *Condition `yaml:"condition"` - Action *NodeAction `yaml:"action,omitempty"` -} - -// NodeAction is the inline-only effects payload — used by talk nodes, -// talk options, and any callers that need a simple "do these effects now" -// primitive (use/look/kill interactions, exits). It is the embedded -// subset of StepAction (the universal superset used by on_enter and triggers). -type NodeAction struct { - SetGlobalFlags map[string]any `yaml:"set_global_flags,omitempty"` - SetPlayerFlags map[string]any `yaml:"set_player_flags,omitempty"` - GiveItem string `yaml:"give_item,omitempty"` - TakeItem string `yaml:"take_item,omitempty"` - Teleport int `yaml:"teleport,omitempty"` - Heal int `yaml:"heal,omitempty"` - Credits int `yaml:"credits,omitempty"` - 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). -// The first entry whose item filter and Condition pass wins and fires exactly -// once. + Text string `yaml:"text"` + Goto string `yaml:"goto"` + Condition *Condition `yaml:"condition"` + Action *Step `yaml:"action,omitempty"` +} + +// Step is the universal effect primitive — one struct shared across every +// effect executor in the game: talk nodes/options, on_use/on_look/on_kill/ +// on_enter/on_exit/on_traverse triggers, and on_flag_change/on_global_flag_change +// triggers. A trigger's effects are expressed as an ordered list of Steps; +// spacing between steps is the Wait field (a pure {wait: N} step pauses the +// sequence for N ticks before the next step fires). // -// Action is a full StepAction so on_kill may broadcast a kill announcement or -// spawn a follow-up mob; inline callers (use/look) simply leave the -// sequence-only fields empty. -type Interaction struct { - Item string `yaml:"item_id,omitempty"` - Condition *Condition `yaml:"condition,omitempty"` - Action *StepAction `yaml:"action,omitempty"` -} - -// StepAction is the universal effect primitive — one superset struct shared -// 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 (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"` - 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 || len(s.Messages) > 0 || s.Broadcast != "" || s.BroadcastGlobal != "" || +// Condition is evaluated per-step by the sequence scheduler; an inline caller +// (talk node, synchronous use/look/kill) fires a single step without the +// scheduler and ignores the per-step Condition (its gating is done at the +// parent Trigger level instead). +type Step struct { + Condition *Condition `yaml:"condition,omitempty"` + Wait int `yaml:"wait,omitempty"` + Messages []string `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"` + SetGlobalFlags map[string]any `yaml:"set_global_flags,omitempty"` + SetPlayerFlags map[string]any `yaml:"set_player_flags,omitempty"` + GiveItem string `yaml:"give_item,omitempty"` + TakeItem string `yaml:"take_item,omitempty"` + Teleport int `yaml:"teleport,omitempty"` + Heal int `yaml:"heal,omitempty"` + Credits int `yaml:"credits,omitempty"` + ApsNode bool `yaml:"aps_node,omitempty"` +} + +// HasEffects reports whether the step carries any effect beyond a pure wait. +func (s Step) HasEffects() bool { + return 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 || s.Credits != 0 || s.ApsNode } -// SpawnMobConfig configures a transient mob spawned by a trigger or on_enter -// step. The mob id is required; other fields are optional. +// Trigger is the one conditional wrapper used by every event block in the +// game: on_use, on_look, on_kill, on_enter, on_exit, on_traverse, +// on_flag_change, and on_global_flag_change. Entries in a block are walked +// top-to-bottom; the first whose ItemID filter and Condition pass wins and +// its Steps run as a scripted sequence. +// +// For verb blocks only ItemID/Condition/Steps/Lock are meaningful. For +// on_flag_change/on_global_flag_change blocks, OnPlayerFlag/OnGlobalFlag (and +// optional Value) declare the flag subscription; Condition provides an +// additional gate (use the Room condition to scope a flag trigger to a room). +// +// Lock, when true, makes the sequence atomic: the player is blocked from any +// verb (except quit) until it completes, and the sequence is saved and resumed +// across disconnect. Unlocked sequences (the default) are interruptable by any +// verb and are not persisted. +type Trigger struct { + Lock bool `yaml:"lock,omitempty"` + ItemID string `yaml:"item_id,omitempty"` + Condition *Condition `yaml:"condition,omitempty"` + Steps []Step `yaml:"steps"` + OnPlayerFlag string `yaml:"on_player_flag,omitempty"` + OnGlobalFlag string `yaml:"on_global_flag,omitempty"` + Value any `yaml:"value,omitempty"` + DedupKey string `yaml:"-"` // runtime only — disambiguates flag-trigger dedup keys +} + +// SpawnMobConfig configures a transient mob spawned by a trigger step. The +// mob id is required; other fields are optional. type SpawnMobConfig struct { ID string `yaml:"id"` OwnerOnly bool `yaml:"owner_only"` @@ -210,15 +205,19 @@ func (c *ShopConfig) FindItem(itemID string) *ShopItem { return nil } +// Condition is the single predicate struct used everywhere a gate is needed: +// exits, descriptions, talk nodes/options, trigger entries, and per-step +// conditions. The atomic predicates combine via AllOf/AnyOf nesting and the +// Not inverter. Room matches when the triggering player is currently in that +// room (ignored for global-flag triggers with no player). type Condition struct { - GlobalFlag string `yaml:"global_flag"` - Value any `yaml:"value"` - Not bool `yaml:"not"` - PlayerFlag string `yaml:"player_flag"` - HasItem string `yaml:"has_item"` - MinCredits int `yaml:"min_credits"` - AllOf []Condition `yaml:"all_of"` - AnyOf []Condition `yaml:"any_of"` + GlobalFlag string `yaml:"global_flag,omitempty"` + PlayerFlag string `yaml:"player_flag,omitempty"` + Room int `yaml:"room,omitempty"` + Value any `yaml:"value,omitempty"` + Not bool `yaml:"not,omitempty"` + HasItem string `yaml:"has_item,omitempty"` + MinCredits int `yaml:"min_credits,omitempty"` + AllOf []Condition `yaml:"all_of,omitempty"` + AnyOf []Condition `yaml:"any_of,omitempty"` } - - diff --git a/internal/behavior/types.go b/internal/behavior/types.go index f451400..7aba045 100644 --- a/internal/behavior/types.go +++ b/internal/behavior/types.go @@ -28,30 +28,29 @@ func WordPrefixMatch(input, name string) bool { type ActionType string const ( - TypeGather ActionType = "gather" - TypeSteal ActionType = "steal" - TypeTalk ActionType = "talk" - TypeBurn ActionType = "burn" - TypeStoke ActionType = "stoke" - TypeSearch ActionType = "search" - TypeIdentify ActionType = "identify" - TypePlant ActionType = "plant" - TypeHarvest ActionType = "harvest" - TypeRake ActionType = "rake" - TypeWater ActionType = "water" - TypeCure ActionType = "cure" - TypeObstacle ActionType = "obstacle" - TypeFletch ActionType = "fletch" - TypeClean ActionType = "cleaning" - TypeCook ActionType = "cook" - TypeSmelt ActionType = "smelting" - TypeSmith ActionType = "smith" - TypeCraft ActionType = "craft" - TypeCombine ActionType = "combine" - TypeMix ActionType = "mix" - TypeConstruct ActionType = "construct" - TypeTriggerModule ActionType = "trigger_module" - TypeInteractionSeq ActionType = "interaction_seq" + TypeGather ActionType = "gather" + TypeSteal ActionType = "steal" + TypeTalk ActionType = "talk" + TypeBurn ActionType = "burn" + TypeStoke ActionType = "stoke" + TypeSearch ActionType = "search" + TypeIdentify ActionType = "identify" + TypePlant ActionType = "plant" + TypeHarvest ActionType = "harvest" + TypeRake ActionType = "rake" + TypeWater ActionType = "water" + TypeCure ActionType = "cure" + TypeObstacle ActionType = "obstacle" + TypeFletch ActionType = "fletch" + TypeClean ActionType = "cleaning" + TypeCook ActionType = "cook" + TypeSmelt ActionType = "smelting" + TypeSmith ActionType = "smith" + TypeCraft ActionType = "craft" + TypeCombine ActionType = "combine" + TypeMix ActionType = "mix" + TypeConstruct ActionType = "construct" + TypeTriggerModule ActionType = "trigger_module" ) type Action struct { @@ -111,7 +110,7 @@ type TalkData struct { EstateDirectory bool StealGuard bool PendingChoice string - PendingAction *NodeAction + PendingAction *Step CancelTalk bool PendingSeq bool PendingChosen bool @@ -143,11 +142,11 @@ type ObstacleData struct { CompletionXP int // FailChance is the resolved failure probability for this attempt. // nil => derived at runtime from agility level vs RequiredLevel. - FailChance *float64 - FailDamageMin int - FailDamageMax int - Phases []PhaseData - RequiredLevel int + FailChance *float64 + FailDamageMin int + FailDamageMax int + Phases []PhaseData + RequiredLevel int } // PhaseData is the runtime form of an obstacle phase. diff --git a/internal/game/act.go b/internal/game/act.go index 1fc2b1c..352f235 100644 --- a/internal/game/act.go +++ b/internal/game/act.go @@ -235,8 +235,6 @@ func (g *Game) AdvanceActions() { g.advanceTalk(sess, p) case behavior.TypeTriggerModule: g.advanceTriggerModule(sess, p) - case behavior.TypeInteractionSeq: - g.advanceInteractionSeq(sess, p) case behavior.TypeCombine: g.advanceCombine(sess, p) default: @@ -302,6 +300,13 @@ func (g *Game) checkCondition(sess *net.Session, c *behavior.Condition) bool { val, present := g.GlobalFlags.Get(c.GlobalFlag) return flagMatches(present, val, c.Value, c.Not) } + if c.Room != 0 { + has := p != nil && p.RoomID == c.Room + if c.Not { + return !has + } + return has + } if c.HasItem != "" { has := p != nil && p.HasItem(c.HasItem) if c.Not { @@ -319,6 +324,44 @@ func (g *Game) checkCondition(sess *net.Session, c *behavior.Condition) bool { return true } +// checkConditionGlobal evaluates a condition without a player session. +// Only global_flag predicates (plus all_of/any_of/not composition) are evaluated. +// Room, player_flag, has_item, and min_credits are player-dependent: Room passes +// (it is used as a reference-room selector, not a gate), and the rest fail. +func (g *Game) checkConditionGlobal(c *behavior.Condition) bool { + if c.AllOf != nil { + for _, sub := range c.AllOf { + if !g.checkConditionGlobal(&sub) { + return false + } + } + return true + } + if c.AnyOf != nil { + for _, sub := range c.AnyOf { + if g.checkConditionGlobal(&sub) { + return true + } + } + return false + } + + if c.GlobalFlag != "" { + val, present := g.GlobalFlags.Get(c.GlobalFlag) + return flagMatches(present, val, c.Value, c.Not) + } + if c.PlayerFlag != "" { + return c.Not + } + if c.HasItem != "" { + return c.Not + } + if c.MinCredits > 0 { + return c.Not + } + return true +} + // flagMatches evaluates a global_flag/player_flag condition. The name is // historical; it evaluates both flag types. // diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go index a243b44..5fbff48 100644 --- a/internal/game/act_agility.go +++ b/internal/game/act_agility.go @@ -199,4 +199,4 @@ func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) *float64 { chance = 0.60 } return &chance -}
\ No newline at end of file +} diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go index 9fce264..cb60c5d 100644 --- a/internal/game/act_effects.go +++ b/internal/game/act_effects.go @@ -10,74 +10,47 @@ import ( "thehouseoficarus/internal/player" ) -// effectScope controls which subset of a StepAction's effects applyStepAction -// will fire. The three scopes cover the three callers of the unified executor: +// effectScope controls which subset of a Step's effects applyStep will fire. // -// - scopePlayer: inline triggers (talk nodes, on_use, on_look, -// on_kill, exit traversal). Receives all -// player-targeted effects (flags, items, heal, credits, -// teleport, aps_node). Broadcast is allowed (so on_kill -// can announce to the room); broadcast_global is not -// (there's no per-tick sequence owner to author it). -// - scopePlayerSeq: on_enter steps + trigger player sequences. Same as -// scopePlayer plus broadcast_global (a sequence can -// shout to the whole world) and spawn_mob/despawn_mob -// (player-owned transient mobs). -// - scopeGlobal: trigger global sequences. No player is involved; only -// broadcasts, global flag mutations, and world-owned -// spawn/despawn fire. +// - scopePlayer: every player-bound trigger (on_use, on_look, on_kill, +// on_enter, on_exit, on_traverse, on_flag_change). Receives +// the full effect vocabulary: messages, broadcast, +// broadcast_global, flags, items, heal, credits, mob +// spawn/despawn (player-owned), teleport, aps_node. +// - scopeGlobal: global-flag triggers (on_global_flag_change) with no +// originating player. Only broadcasts, global flag +// mutations, and world-owned mob spawn/despawn fire. type effectScope int const ( scopePlayer effectScope = iota - scopePlayerSeq scopeGlobal ) -// applyStepAction is the single effect executor used by every conditional -// interaction and timed step in the game: talk nodes, talk options, -// on_use/on_look/on_kill interactions, exit traversal, on_enter steps, and -// room/global trigger sequences. The previous fireEnterStep / -// executeTriggerStep / executeGlobalTriggerStep / applyNodeAction executors -// are all thin wrappers over this one. +// applyStep is the single effect executor used by every trigger in the game: +// talk nodes/options, on_use/on_look/on_kill/on_enter/on_exit/on_traverse +// triggers, and on_flag_change/on_global_flag_change sequences. scopeGlobal +// ignores the player entirely. // // flagValue (may be nil) is substituted into %v in any message/broadcast -// template; playerName (derived from p when non-nil) is substituted into %p. -// scopeGlobal ignores p entirely. +// template; the player's name is substituted into %p. // -// The fixed effect order (matching the original executors, normalized): -// 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 -// via applyFlagMutations so they aren't set twice) -// 5. set_player_flags (player/seq scopes; cascades via setPlayerFlag) -// 6. take_item (player/seq scopes) -// 7. give_item (player/seq scopes; honors 28-slot inventory limit) -// 8. heal (player/seq scopes; clamps to MaxHP) -// 9. credits (player/seq scopes; negative is gated by affordability) -// 10. spawn_mob (seq/global scopes; player-owned when seq) -// 11. despawn_mob (seq/global scopes; filtered by owner when seq) -// 12. teleport (player/seq scopes; re-runs look + on_enter of target) -// 13. aps_node (player/seq scopes; marks "aps_node_<roomID>" player flag) - -// 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, 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 - } - rendered := expandTemplate(msg, playerName, flagValue) - seqSpec := g.resolveColor(sess, "sequence") - mode := g.colorMode(sess) - rendered = color.ExpandTagsDefault(mode, seqSpec, rendered) - sess.WriteLine(rendered) -} - -func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) { +// The fixed effect order: +// 1. messages (player scope — skipped for scopeGlobal) +// 2. broadcast -> all in room (both scopes) +// 3. broadcast_global -> all online (both scopes) +// 4. set_global_flags (scopeGlobal sets here; player scope folds globals +// into step 5 via applyFlagMutations so they aren't set twice) +// 5. set_player_flags (player scope; cascades via setPlayerFlag) +// 6. take_item (player scope) +// 7. give_item (player scope; honors 28-slot inventory limit) +// 8. heal (player scope; clamps to MaxHP) +// 9. credits (player scope; negative is gated by affordability) +// 10. spawn_mob (player scope: player-owned; global scope: world-owned) +// 11. despawn_mob (player scope: owner-filtered; global scope: all) +// 12. teleport (player scope; re-runs look + on_enter of target) +// 13. aps_node (player scope; marks "aps_node_<roomID>" player flag) +func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Step, roomID int, sc effectScope, flagValue any) { if step == nil { return } @@ -87,20 +60,16 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi playerName = p.Name } - // 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). + // 1. messages — direct to the triggering session. if sc != scopeGlobal { for _, msg := range step.Messages { - g.writePlayerMessage(sess, msg.Message, playerName, flagValue) + g.writePlayerMessage(sess, msg, playerName, flagValue) } } - // 2 & 3. broadcast / broadcast_global — re-render color tags per recipient - // so each player sees them in their own color mode. - broadcastSpec := g.resolveColor(nil, "broadcast") - if (sc == scopePlayerSeq || sc == scopeGlobal) && g.Hub != nil { + // 2 & 3. broadcast / broadcast_global — re-render color tags per recipient. + if g.Hub != nil && (step.Broadcast != "" || step.BroadcastGlobal != "") { + broadcastSpec := g.resolveColor(nil, "broadcast") if step.Broadcast != "" { raw := expandTemplate(step.Broadcast, playerName, flagValue) for _, other := range g.Hub.PlayersInRoom(roomID) { @@ -122,16 +91,13 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi } } - // 4. set_global_flags — scopeGlobal has no player, so it sets globals here - // (cascades via OnChange). Player scopes handle globals in step 5 alongside - // player flags (applyFlagMutations sets both atomically per-scope), avoiding - // a redundant second SetAll. + // 4. set_global_flags — scopeGlobal has no player, set globals here. if sc == scopeGlobal && len(step.SetGlobalFlags) > 0 { g.GlobalFlags.SetAll(step.SetGlobalFlags) } - // Global triggers have no player — stop after the world-scoped effects. if sc == scopeGlobal { + // 10/11. world-owned spawn/despawn. if step.SpawnMob != nil { g.spawnWorldTriggerMob(step.SpawnMob, roomID) } @@ -141,13 +107,11 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi return } - // Everything below needs a player. if p == nil { return } - // 5. set_player_flags — cascade via setPlayerFlag (may synchronously re-fire - // other player-flag triggers). + // 5. set_player_flags (and globals, folded in so the SetAll happens once). if len(step.SetGlobalFlags) > 0 || len(step.SetPlayerFlags) > 0 { g.applyFlagMutations(p, step.SetGlobalFlags, step.SetPlayerFlags) g.AccountStore.SaveCharacter(p) @@ -186,7 +150,7 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi } } - // 9. credits (signed; negative is gated) + // 9. credits (signed; negative is gated). if step.Credits != 0 { if step.Credits < 0 { if p.Credits >= -step.Credits { @@ -201,12 +165,11 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi } } - // 10/11. spawn_mob / despawn_mob — sequence-only effects. Inline callers - // (scopePlayer) leave these empty; nothing fires. - if sc == scopePlayerSeq && step.SpawnMob != nil { + // 10/11. spawn_mob / despawn_mob (player-owned / owner-filtered). + if step.SpawnMob != nil { g.spawnTriggerMob(sess, p, step.SpawnMob, roomID) } - if sc == scopePlayerSeq && step.DespawnMob != "" { + if step.DespawnMob != "" { g.despawnTriggerMobs(step.DespawnMob, p.Name) } @@ -215,68 +178,58 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi g.teleportPlayer(sess, p, step.Teleport) } - // 13. aps_node — marks this current room as a discovered APS node on the - // player's datapad. Kept as a dedicated effect because the room-id - // substitution is dynamic (the player's current room), not expressible - // via a static set_player_flags entry. + // 13. aps_node — dynamic room-id flag, kept as a dedicated effect. if step.ApsNode { g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true) g.AccountStore.SaveCharacter(p) } } -// applyNodeAction is the inline-only convenience wrapper used by talk nodes, -// talk options, and any historical caller that holds a *NodeAction rather -// than a *StepAction. It wraps the NodeAction in a StepAction (sequence-only -// fields empty) and dispatches to applyStepAction with scopePlayer scoped -// to the player's current room. -func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) { - p := sess.Player - if p == nil || na == nil { +// 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 trigger system. +func (g *Game) writePlayerMessage(sess *net.Session, msg string, playerName string, flagValue any) { + if sess == nil || msg == "" { return } - g.applyStepAction(sess, p, &behavior.StepAction{NodeAction: *na}, p.RoomID, scopePlayer, nil) + rendered := expandTemplate(msg, playerName, flagValue) + seqSpec := g.resolveColor(sess, "sequence") + mode := g.colorMode(sess) + rendered = color.ExpandTagsDefault(mode, seqSpec, rendered) + sess.WriteLine(rendered) } -// 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). -// -// 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 -// the player to be wielding it. nil means the item_id field is ignored (used -// by callers without an item filter, e.g. exit traversal). Entries are walked -// top-to-bottom; the first whose item filter, Condition, and the first-match- -// wins rule all pass fires. -func (g *Game) applyInteraction(sess *net.Session, p *player.Player, list []behavior.Interaction, roomID int, allowSeq bool, itemMatch func(string) bool) bool { - sc := scopePlayer - if allowSeq { - sc = scopePlayerSeq +// applyTalkStep fires a talk node/option's Action (a single Step) +// synchronously at scopePlayer, scoped to the player's current room. +func (g *Game) applyTalkStep(sess *net.Session, step *behavior.Step) { + p := sess.Player + if p == nil || step == nil { + return } - for _, it := range list { - if it.Item != "" && itemMatch != nil && !itemMatch(it.Item) { + g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil) +} + +// runTrigger runs the first matching entry in a trigger block. itemMatch +// (optional) gates entries that carry an ItemID: for on_look it requires the +// player to hold the item; for on_kill it requires wielding it. nil means the +// ItemID field is ignored (on_enter/on_exit/on_traverse/on_flag_change). The +// first entry whose item filter and Condition pass wins; its Steps run as a +// scripted sequence. roomLocked controls the per-step room-lock: when true, +// steps are skipped if the player has left roomID (used by on_enter so a +// cutscene doesn't play into the wrong room). Returns true if a trigger +// matched and fired. +func (g *Game) runTrigger(sess *net.Session, p *player.Player, list []behavior.Trigger, roomID int, itemMatch func(string) bool, roomLocked bool) bool { + for i := range list { + t := &list[i] + if t.ItemID != "" && itemMatch != nil && !itemMatch(t.ItemID) { continue } - if it.Condition != nil && !g.checkCondition(sess, it.Condition) { + if t.Condition != nil && !g.checkCondition(sess, t.Condition) { continue } - g.runInteraction(sess, p, it.Action, roomID, sc, nil) + g.startSequence(sess, p, t.Steps, roomID, scopePlayer, nil, t.Lock, roomLocked, verbSeqKey(p, t.ItemID)) 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 deleted file mode 100644 index 8b515c4..0000000 --- a/internal/game/act_interaction_seq.go +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index c3fc8fa..0000000 --- a/internal/game/act_interaction_seq_test.go +++ /dev/null @@ -1,216 +0,0 @@ -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 a6acc21..889ce85 100644 --- a/internal/game/act_room.go +++ b/internal/game/act_room.go @@ -4,26 +4,13 @@ import ( "fmt" "strings" - "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) -// enterSeq is an in-flight on_enter stepped sequence for a single player. It is -// driven one step at a time by EnterSeqTick. -type enterSeq struct { - sess *net.Session - playerName string - roomID int - steps []behavior.StepAction - wait int -} - -// runEnterSteps evaluates a room's on_enter script for the player. Steps whose -// condition fails are dropped. If none of the surviving steps are "timed" -// (any non-zero effect field) the messages are printed synchronously. -// Otherwise the surviving steps become a scheduled enter sequence driven -// by EnterSeqTick. +// runEnterSteps evaluates a room's on_enter block for the player. The first +// trigger whose Condition passes wins; its Steps run as a scripted sequence +// (room-locked, so steps are skipped if the player immediately leaves). func (g *Game) runEnterSteps(sess *net.Session, roomID int) { room, err := g.World.LoadRoom(roomID) if err != nil || len(room.OnEnter) == 0 { @@ -33,154 +20,29 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) { if p == nil { return } - - var steps []behavior.StepAction - sequenced := false - for _, step := range room.OnEnter { - if step.Condition != nil && !g.checkCondition(sess, step.Condition) { - continue - } - steps = append(steps, step) - if step.IsTimed() { - sequenced = true - } - } - if len(steps) == 0 { - return - } - - if !sequenced { - // Pure message-only steps — print synchronously, no side effects. - for _, step := range steps { - for _, msg := range step.Messages { - if msg.Message != "" { - sess.WriteLine(msg.Message) - } - } - } - return - } - - g.startEnterSeq(sess, p, roomID, steps) + g.runTrigger(sess, p, room.OnEnter, roomID, nil, true) } -// startEnterSeq registers a scheduled on_enter sequence and persists a marker -// on the player so the sequence can be resumed on reconnect if interrupted -// (disconnect, server restart). -func (g *Game) startEnterSeq(sess *net.Session, p *player.Player, roomID int, steps []behavior.StepAction) { - g.cancelEnterSeq(p.Name) - if p.EnterSeqRoom != roomID { - p.EnterSeqRoom = roomID - g.AccountStore.SaveCharacter(p) - } - g.enterMu.Lock() - g.enterSeqs[p.Name] = &enterSeq{ - sess: sess, - playerName: p.Name, - roomID: roomID, - steps: steps, - wait: steps[0].Delay, - } - g.enterMu.Unlock() -} - -// EnterSeqTick advances every in-flight enter sequence by one tick. Sequences -// for a player who is no longer connected are paused (left for resume); -// sequences are removed from the map on completion or cancellation. -func (g *Game) EnterSeqTick() { - g.enterMu.Lock() - seqs := make([]*enterSeq, 0, len(g.enterSeqs)) - for _, s := range g.enterSeqs { - seqs = append(seqs, s) - } - g.enterMu.Unlock() - - for _, seq := range seqs { - if !g.isCharLive(seq.playerName, seq.sess) { - continue - } - - var jobs []behavior.StepAction - done := false - - g.enterMu.Lock() - if g.enterSeqs[seq.playerName] != seq { - g.enterMu.Unlock() - continue - } - seq.wait-- - for seq.wait <= 0 && len(seq.steps) > 0 { - step := seq.steps[0] - seq.steps = seq.steps[1:] - jobs = append(jobs, step) - if len(seq.steps) > 0 { - seq.wait = seq.steps[0].Delay - } else { - seq.wait = 0 - } - } - if len(seq.steps) == 0 { - done = true - delete(g.enterSeqs, seq.playerName) - } - g.enterMu.Unlock() - - for i := range jobs { - g.fireEnterStep(seq, &jobs[i]) - } - if done { - g.completeEnterSeq(seq) - } - } -} - -// fireEnterStep runs one on_enter step's effects via the unified -// applyStepAction executor. The room-lock check (player must still be in the -// room that the sequence is being played for) lives here, before dispatch — -// applyStepAction itself doesn't re-check it. -func (g *Game) fireEnterStep(seq *enterSeq, step *behavior.StepAction) { - p := seq.sess.Player - if p == nil || p.RoomID != seq.roomID { - return - } - g.applyStepAction(seq.sess, p, step, seq.roomID, scopePlayerSeq, nil) -} - -func (g *Game) completeEnterSeq(seq *enterSeq) { - if !g.isCharLive(seq.playerName, seq.sess) { +// runExitSteps evaluates a room's on_exit block for the player. It fires the +// first matching trigger as a non-room-locked sequence using the room being +// left as the reference room (broadcasts/spawn resolve to the old room). It is +// invoked before the player's RoomID is updated to the destination. +func (g *Game) runExitSteps(sess *net.Session, roomID int) { + room, err := g.World.LoadRoom(roomID) + if err != nil || len(room.OnExit) == 0 { return } - p := seq.sess.Player + p := sess.Player if p == nil { return } - if p.EnterSeqRoom == seq.roomID { - p.EnterSeqRoom = 0 - g.AccountStore.SaveCharacter(p) - } - g.writePrompt(seq.sess) -} - -// cancelEnterSeq drops any in-flight enter sequence for the player. It does not -// touch the persisted EnterSeqRoom marker, so a sequence cancelled by -// disconnect can still be resumed on reconnect. -func (g *Game) cancelEnterSeq(name string) { - g.enterMu.Lock() - delete(g.enterSeqs, name) - g.enterMu.Unlock() -} - -func (g *Game) isCharLive(name string, sess *net.Session) bool { - g.charsMu.Lock() - defer g.charsMu.Unlock() - return g.loggedInChars[name] == sess + g.runTrigger(sess, p, room.OnExit, roomID, nil, false) } // applyFlagMutations sets global and player flags. It is the player-scoped -// (scopePlayer / scopePlayerSeq) flag step of applyStepAction, called once -// per non-global step that carries set_global_flags and/or set_player_flags. -// scopeGlobal sets globals directly in applyStepAction step 4 instead (so -// world-scoped cascades don't reach back into this helper). +// flag step of applyStep, called once per step that carries set_global_flags +// and/or set_player_flags. scopeGlobal sets globals directly in applyStep +// instead (so world-scoped cascades don't reach back into this helper). func (g *Game) applyFlagMutations(p *player.Player, setGlobalFlags, setPlayerFlags map[string]any) { g.GlobalFlags.SetAll(setGlobalFlags) if len(setPlayerFlags) > 0 { @@ -213,3 +75,11 @@ func (g *Game) BroadcastRespawns() { } } } + +// isCharLive reports whether the named player is currently connected via the +// given session (used by older helpers that still reference it). +func (g *Game) isCharLive(name string, sess *net.Session) bool { + g.charsMu.Lock() + defer g.charsMu.Unlock() + return g.loggedInChars[name] == sess +} diff --git a/internal/game/act_state.go b/internal/game/act_state.go index 130b088..73ecd57 100644 --- a/internal/game/act_state.go +++ b/internal/game/act_state.go @@ -81,8 +81,6 @@ func (g *Game) playerActionDisplay(p *player.Player) string { return "constructing some " + a.TargetName case behavior.TypeTriggerModule: return "triggering " + a.TargetName - case behavior.TypeInteractionSeq: - return "using " + a.TargetName } } diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go index d081d8b..bf0ce14 100644 --- a/internal/game/act_steal.go +++ b/internal/game/act_steal.go @@ -37,17 +37,17 @@ var stallGuardTalk = &behavior.TalkConfig{ }, "bribe": { Messages: []string{"Smart choice. Hand over 500 credits and we'll forget this happened."}, - Action: &behavior.NodeAction{Credits: -500}, + Action: &behavior.Step{Credits: -500}, Options: []behavior.TalkOption{{Text: "\"Fine, take it.\""}}, }, "jail": { Messages: []string{"Off to the detention cell with you!"}, - Action: &behavior.NodeAction{Teleport: 162}, + Action: &behavior.Step{Teleport: 162}, Options: []behavior.TalkOption{{Text: "(You are dragged away)"}}, }, "fight": { Messages: []string{"Then defend yourself!"}, - Action: &behavior.NodeAction{SetGlobalFlags: map[string]any{"guard_hostile": true}}, + Action: &behavior.Step{SetGlobalFlags: map[string]any{"guard_hostile": true}}, Options: []behavior.TalkOption{{Text: "(The guard attacks!)"}}, }, }, diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go index e08fbd2..8de58df 100644 --- a/internal/game/act_talk.go +++ b/internal/game/act_talk.go @@ -56,8 +56,8 @@ func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.Object } } -func (g *Game) handleNodeAction(sess *net.Session, na *behavior.NodeAction) (intercepted bool) { - g.applyNodeAction(sess, na) +func (g *Game) handleNodeAction(sess *net.Session, na *behavior.Step) (intercepted bool) { + g.applyTalkStep(sess, na) if v, ok := g.GlobalFlags.Get("guard_hostile"); ok && v != nil { g.GlobalFlags.Delete("guard_hostile") diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go index e3bc2b5..3fda55e 100644 --- a/internal/game/cmd_dig.go +++ b/internal/game/cmd_dig.go @@ -90,10 +90,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { } p.Action = nil g.cancelRest(p.Name) - g.cancelEnterSeq(p.Name) - if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { - p.EnterSeqRoom = 0 - } + g.cancelSequences(p.Name) if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } @@ -189,10 +186,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { } p.Action = nil g.cancelRest(p.Name) - g.cancelEnterSeq(p.Name) - if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { - p.EnterSeqRoom = 0 - } + g.cancelSequences(p.Name) if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } diff --git a/internal/game/cmd_goto.go b/internal/game/cmd_goto.go index 41e7b37..1771f8d 100644 --- a/internal/game/cmd_goto.go +++ b/internal/game/cmd_goto.go @@ -37,10 +37,7 @@ func (g *Game) executeGoto(sess *net.Session, args []string, rawInput string) { } p.Action = nil g.cancelRest(p.Name) - g.cancelEnterSeq(p.Name) - if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { - p.EnterSeqRoom = 0 - } + g.cancelSequences(p.Name) if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } diff --git a/internal/game/cmd_inspect.go b/internal/game/cmd_inspect.go index 7488591..2c1b611 100644 --- a/internal/game/cmd_inspect.go +++ b/internal/game/cmd_inspect.go @@ -85,19 +85,21 @@ func (g *Game) executeInspect(sess *net.Session, args []string, rawInput string) } if len(room.OnEnter) > 0 { - sess.WriteLine(fmt.Sprintf("\nOn-Enter Steps: %d", len(room.OnEnter))) + sess.WriteLine(fmt.Sprintf("\nOn-Enter Triggers: %d", len(room.OnEnter))) } - - if len(room.Triggers) > 0 { - sess.WriteLine(fmt.Sprintf("\nRoom Triggers: %d", len(room.Triggers))) - for _, trigger := range room.Triggers { - kind := "" - if trigger.OnGlobalFlag != "" { - kind = fmt.Sprintf("global flag %q", trigger.OnGlobalFlag) - } else if trigger.OnPlayerFlag != "" { - kind = fmt.Sprintf("player flag %q", trigger.OnPlayerFlag) - } - sess.WriteLine(fmt.Sprintf(" %s: %s (%d steps)", trigger.ID, kind, len(trigger.Steps))) + if len(room.OnExit) > 0 { + sess.WriteLine(fmt.Sprintf("\nOn-Exit Triggers: %d", len(room.OnExit))) + } + if len(room.OnFlagChange) > 0 { + sess.WriteLine(fmt.Sprintf("\nOn-Flag-Change Triggers: %d", len(room.OnFlagChange))) + for _, t := range room.OnFlagChange { + sess.WriteLine(fmt.Sprintf(" player flag %q (%d steps)", t.OnPlayerFlag, len(t.Steps))) + } + } + if len(room.OnGlobalFlagChange) > 0 { + sess.WriteLine(fmt.Sprintf("\nOn-Global-Flag-Change Triggers: %d", len(room.OnGlobalFlagChange))) + for _, t := range room.OnGlobalFlagChange { + sess.WriteLine(fmt.Sprintf(" global flag %q (%d steps)", t.OnGlobalFlag, len(t.Steps))) } } diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 4391b1f..2967e79 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -73,7 +73,7 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { // fire in completeMove once the player arrives. ExitDef.Condition (above, // evaluated by exitDisplayState) gates whether the exit is passable at // all; each on_traverse entry has its own additional Condition gate. - p.MovePendingInteraction = g.matchExitInteraction(sess, p, exitDef) + p.MovePendingTrigger = g.matchExitTrigger(sess, p, exitDef) inCombat := g.Combat.Get(p.Name) != nil @@ -101,17 +101,17 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { } } -// matchExitInteraction returns the first on_traverse Interaction on exitDef -// whose Condition (if any) passes for this player, or nil if the exit has no -// on_traverse entries. The matched interaction's Action fires (with its optional -// Message) once the player arrives in the target room. -func (g *Game) matchExitInteraction(sess *net.Session, p *player.Player, exitDef world.ExitDef) *behavior.Interaction { +// matchExitTrigger returns the first on_traverse Trigger on exitDef whose +// Condition (and item filter, n/a for traverse) passes for this player, or nil +// if the exit has no matching on_traverse entry. The matched trigger's Steps +// run as a sequence once the player arrives in the target room. +func (g *Game) matchExitTrigger(sess *net.Session, p *player.Player, exitDef world.ExitDef) *behavior.Trigger { for i := range exitDef.OnTraverse { - it := &exitDef.OnTraverse[i] - if it.Condition != nil && !g.checkCondition(sess, it.Condition) { + t := &exitDef.OnTraverse[i] + if t.Condition != nil && !g.checkCondition(sess, t.Condition) { continue } - return it + return t } return nil } @@ -152,7 +152,7 @@ func (g *Game) moveTicks(p *player.Player, multiplier float64) int { func (g *Game) completeMove(sess *net.Session, p *player.Player) { exitDir := p.MoveDirection targetID := p.MoveTarget - pendingInteraction := p.MovePendingInteraction + pendingTrigger := p.MovePendingTrigger p.ClearMoveState() @@ -174,19 +174,26 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) { } } } + + // Interruptable: an unlocked in-flight verb sequence is cancelled by the + // move. (Locked sequences block movement entirely at the command router.) + g.cancelUnlockedSequences(p.Name) + + // Fire the on_exit block of the room being left BEFORE updating RoomID, so + // the exit scene resolves against the old room. + if oldRoom != targetID { + g.runExitSteps(sess, oldRoom) + } + p.RoomID = targetID p.HazardTimer = 0 p.Stats.RecordRoomVisit(targetID) - g.cancelEnterSeq(p.Name) - if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { - p.EnterSeqRoom = 0 - } - // Fire the on_traverse interaction (if any matched at classification - // 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.runInteraction(sess, p, pendingInteraction.Action, p.RoomID, scopePlayer, nil) + // Fire the on_traverse trigger (if any matched at classification time) + // AFTER p.RoomID is set to the new room, so effects like aps_node/teleport + // operate on the destination as their reference frame. + if pendingTrigger != nil { + g.runTrigger(sess, p, []behavior.Trigger{*pendingTrigger}, p.RoomID, nil, false) } g.AccountStore.SaveCharacter(p) diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index e2c34aa..d03993a 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -150,7 +150,7 @@ var commandRegistry = map[string]commandDef{ "goto": {(*Game).executeGoto, ClassInstant}, "summon": {(*Game).executeSummon, ClassInstant}, "dig": {(*Game).executeDig, ClassInstant}, - "setglobalflag": {(*Game).executeSetGlobalFlag, ClassInstant}, + "setglobalflag": {(*Game).executeSetGlobalFlag, ClassInstant}, "setplayerflag": {(*Game).executeSetPlayerFlag, ClassInstant}, "reload": {(*Game).executeReload, ClassInstant}, "shutdown": {(*Game).executeShutdown, ClassInstant}, @@ -177,6 +177,24 @@ func classifyCommand(cmd string) CommandClass { } func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawInput string) { + p := sess.Player + + // Locked-sequence busy gate: a locked in-flight trigger sequence blocks every + // verb except quit/logout (and god-mode admins) until it completes. + if p != nil && !p.GodMode && g.playerLocked(p.Name) && cmd != "quit" && cmd != "logout" { + sess.WriteLine("You're busy — wait for the current action to finish.") + return + } + + // Unlocked sequences are interruptable: any active/free verb cancels them + // before running. (Instant info commands like look/inventory/help do not.) + if p != nil { + switch classifyCommand(cmd) { + case ClassActive, ClassFree: + g.cancelUnlockedSequences(p.Name) + } + } + if def, ok := commandRegistry[cmd]; ok { def.handler(g, sess, args, rawInput) return @@ -187,7 +205,6 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI return } - p := sess.Player if a, ok := verbAliases[cmd]; ok { g.cancelAction(p) switch a { diff --git a/internal/game/cmd_reload.go b/internal/game/cmd_reload.go index 2b8230a..3fa6054 100644 --- a/internal/game/cmd_reload.go +++ b/internal/game/cmd_reload.go @@ -29,9 +29,9 @@ func (g *Game) executeReload(sess *net.Session, args []string, rawInput string) g.World.RebuildRoomIndex(g.DataDir) g.World.ClearHazardCache() - g.TriggerStore.ClearTriggers() - g.TriggerStore.LoadGlobal(g.DataDir) - g.seedRoomTriggers() + // Abort all in-flight sequences, then rebuild the flag-trigger index. + g.abortAllSequences() + g.loadAllFlagTriggers() sess.WriteLine("All caches reloaded.") } diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go index 15b05ef..132469d 100644 --- a/internal/game/cmd_room_insert.go +++ b/internal/game/cmd_room_insert.go @@ -177,10 +177,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { } p.Action = nil g.cancelRest(p.Name) - g.cancelEnterSeq(p.Name) - if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { - p.EnterSeqRoom = 0 - } + g.cancelSequences(p.Name) if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } diff --git a/internal/game/cmd_room_remove_test.go b/internal/game/cmd_room_remove_test.go index a13af8a..8bd8470 100644 --- a/internal/game/cmd_room_remove_test.go +++ b/internal/game/cmd_room_remove_test.go @@ -17,9 +17,12 @@ import ( type removeTestConn struct{ buf []byte } func (c *removeTestConn) ReadMessage() (string, error) { return "", nil } -func (c *removeTestConn) Write(b []byte) (int, error) { c.buf = append(c.buf, b...); return len(b), nil } -func (c *removeTestConn) Close() error { return nil } -func (c *removeTestConn) SetEcho(bool) error { return nil } +func (c *removeTestConn) Write(b []byte) (int, error) { + c.buf = append(c.buf, b...) + return len(b), nil +} +func (c *removeTestConn) Close() error { return nil } +func (c *removeTestConn) SetEcho(bool) error { return nil } func (c *removeTestConn) output() string { return string(c.buf) } diff --git a/internal/game/cmd_summon.go b/internal/game/cmd_summon.go index 3aa8d7b..9383e31 100644 --- a/internal/game/cmd_summon.go +++ b/internal/game/cmd_summon.go @@ -52,10 +52,7 @@ func (g *Game) executeSummon(sess *net.Session, args []string, rawInput string) } tp.Action = nil g.cancelRest(tp.Name) - g.cancelEnterSeq(tp.Name) - if tp.EnterSeqRoom != 0 && tp.EnterSeqRoom == oldRoom { - tp.EnterSeqRoom = 0 - } + g.cancelSequences(tp.Name) if ss, ok := g.safespot.Get(tp.Name); ok { g.forceLeaveSafespot(targetSess, tp, &ss, "") } diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index a2cb135..975eddb 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -90,17 +90,10 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string) g.startAction(sess, bt, def.Name) return true } - for _, ui := range def.OnUse { - if ui.Item != "" { - continue - } - if ui.Condition != nil && !g.checkCondition(sess, ui.Condition) { - continue - } - g.cancelAction(p) - name := def.Name - g.broadcastAction(sess, "%s uses the %s.", p.Name, name) - g.runInteraction(sess, p, ui.Action, p.RoomID, scopePlayer, nil) + g.cancelAction(p) + name := def.Name + g.broadcastAction(sess, "%s uses the %s.", p.Name, name) + if g.runTrigger(sess, p, def.OnUse, p.RoomID, func(id string) bool { return false }, false) { return true } if len(def.OnUse) > 0 { @@ -276,17 +269,10 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, return } - for _, ui := range def.OnUse { - if ui.Item != itemAID { - continue - } - if ui.Condition != nil && !g.checkCondition(sess, ui.Condition) { - continue - } - g.cancelAction(p) - name := def.Name - g.broadcastAction(sess, "%s uses the %s.", p.Name, name) - g.runInteraction(sess, p, ui.Action, p.RoomID, scopePlayer, nil) + g.cancelAction(p) + name := def.Name + g.broadcastAction(sess, "%s uses the %s.", p.Name, name) + if g.runTrigger(sess, p, def.OnUse, p.RoomID, func(id string) bool { return id == itemAID }, false) { return } diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go index 6c20004..24957ab 100644 --- a/internal/game/cmd_verbs.go +++ b/internal/game/cmd_verbs.go @@ -76,7 +76,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { } for _, ui := range def.OnUse { - if ui.Item == "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) { + if ui.ItemID == "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) { addUnique(§ionObjs, &seen, "use "+name) break } @@ -107,8 +107,8 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { } for _, ui := range def.OnUse { - if ui.Item != "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) { - if itemDef, err := g.ItemStore.Load(ui.Item); err == nil { + if ui.ItemID != "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) { + if itemDef, err := g.ItemStore.Load(ui.ItemID); err == nil { addUnique(§ionObjs, &seen, "use "+itemDef.Name+" on "+name) } } diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go index 0395049..9affede 100644 --- a/internal/game/combat_mob.go +++ b/internal/game/combat_mob.go @@ -221,7 +221,7 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst // task mob's HP to zero is the "kill" that completes the work). itemMatch // is the "wielding" gate — on_kill entries with an item_id only fire if // the player currently has that item equipped in a weapon-hand slot. - g.applyInteraction(sess, p, mob.OnKill, p.RoomID, true, p.IsWielding) + g.runTrigger(sess, p, mob.OnKill, p.RoomID, p.IsWielding, false) g.scheduleMobRespawn(mob) g.writePrompt(sess) } diff --git a/internal/game/combat_mob_test.go b/internal/game/combat_mob_test.go index 28427b7..70c744b 100644 --- a/internal/game/combat_mob_test.go +++ b/internal/game/combat_mob_test.go @@ -48,7 +48,7 @@ func TestMobStrongestRangedScience(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { mob := &world.MobInstance{ - AttackTypes: c.types, + AttackTypes: c.types, MaxRangedHit: c.maxRanged, MaxScienceHit: c.maxScience, SciencePercentBonus: c.sciencePct, diff --git a/internal/game/core_course.go b/internal/game/core_course.go index 6394b99..232e6dc 100644 --- a/internal/game/core_course.go +++ b/internal/game/core_course.go @@ -252,4 +252,4 @@ func (cs *CourseStore) loadAllLocked() { }) obstacleVerbs = localVerbs -}
\ No newline at end of file +} diff --git a/internal/game/core_flags.go b/internal/game/core_flags.go index c78f3c3..dbde5d7 100644 --- a/internal/game/core_flags.go +++ b/internal/game/core_flags.go @@ -41,20 +41,10 @@ func (g *Game) setPlayerFlag(p *player.Player, key string, val any) { old, existed := p.Flags[key] p.Flags[key] = val if !existed || !engine.ValuesEqual(old, val) { - g.firePlayerFlagTrigger(p, key, val) + g.firePlayerFlagTriggers(p, key, val) } } -func (g *Game) firePlayerFlagTrigger(p *player.Player, flagName string, flagValue any) { - g.charsMu.Lock() - sess := g.loggedInChars[p.Name] - g.charsMu.Unlock() - if sess == nil { - return - } - g.TriggerStore.FirePlayerInRoom(p.Name, p.RoomID, flagName, flagValue) -} - func intFromFlag(flags map[string]any, key string) int { val, ok := flags[key] if !ok { diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index 90c06be..38499e2 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -112,21 +112,8 @@ func (g *Game) connectCharacter(sess *net.Session, name string) { g.doLook(sess) g.checkAggro(sess) - // Resume an interrupted on_enter sequence (e.g. disconnected mid-sequence). - // Only fires when a persisted marker matches the room the player is in, so - // ordinary on_enter scripts never run on login. - if p.EnterSeqRoom != 0 { - if p.EnterSeqRoom == p.RoomID { - g.runEnterSteps(sess, p.RoomID) - } - g.enterMu.Lock() - _, active := g.enterSeqs[p.Name] - g.enterMu.Unlock() - if !active { - p.EnterSeqRoom = 0 - g.AccountStore.SaveCharacter(p) - } - } + // Resume an interrupted LOCKED trigger sequence saved across disconnect. + g.resumePendingSequence(sess, p) sess.WritePrompt("> ") } diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go index 2872f34..ff905f6 100644 --- a/internal/game/exit_migrate.go +++ b/internal/game/exit_migrate.go @@ -9,8 +9,8 @@ import ( // rewritePlayerRoomIDs remaps room-ID-embedded state in a single player per // idMap (oldID -> newID): discovered-exit player flag keys -// (hidden_exit_<id>_<dir>), current RoomID, EnterSeqRoom, MapSymbols keys, and -// Stats.RoomsVisited bitset. Returns whether any field was changed. +// (hidden_exit_<id>_<dir>), current RoomID, PendingSequence.RoomID, MapSymbols +// keys, and Stats.RoomsVisited bitset. Returns whether any field was changed. // // idMap is assumed to be a bijection (see Room.RewriteRoomIDs); the flag-key // and int-map rekeys use delete-all-then-set-all so a swap (A<->B) cannot @@ -23,7 +23,7 @@ func rewritePlayerRoomIDs(p *player.Player, idMap map[int]int) bool { if remapScalar(idMap, &p.RoomID) { changed = true } - if p.EnterSeqRoom != 0 && remapScalar(idMap, &p.EnterSeqRoom) { + if p.PendingSequence != nil && p.PendingSequence.RoomID != 0 && remapScalar(idMap, &p.PendingSequence.RoomID) { changed = true } if len(p.Flags) > 0 && rewriteDiscoveredExitFlags(p.Flags, idMap) { diff --git a/internal/game/exit_migrate_test.go b/internal/game/exit_migrate_test.go index f86b85b..1ca33f7 100644 --- a/internal/game/exit_migrate_test.go +++ b/internal/game/exit_migrate_test.go @@ -9,8 +9,8 @@ import ( func TestRewritePlayerRoomIDsRename(t *testing.T) { p := &player.Player{ - RoomID: 10, - EnterSeqRoom: 20, + RoomID: 10, + PendingSequence: &player.PendingSequence{RoomID: 20, Key: "k"}, Flags: map[string]any{ "hidden_exit_10_north": 1, "hidden_exit_20_down": 1, @@ -31,8 +31,8 @@ func TestRewritePlayerRoomIDsRename(t *testing.T) { if p.RoomID != 100 { t.Errorf("RoomID = %d, want 100", p.RoomID) } - if p.EnterSeqRoom != 200 { - t.Errorf("EnterSeqRoom = %d, want 200", p.EnterSeqRoom) + if p.PendingSequence == nil || p.PendingSequence.RoomID != 200 { + t.Errorf("PendingSequence.RoomID = %v, want 200", p.PendingSequence) } wantFlags := map[string]any{ diff --git a/internal/game/game.go b/internal/game/game.go index f34e649..eed61f5 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -50,9 +50,9 @@ type Game struct { Deps Hub *net.Hub - GlobalFlags *GlobalFlagStore + GlobalFlags *GlobalFlagStore Combat *combat.Tracker - TriggerStore *world.TriggerStore + flagIndex *flagTriggerIndex queue *CommandQueue safespot *SafespotManager ValidationConfig config.ValidationConfig @@ -66,8 +66,9 @@ type Game struct { hackingStates map[string]*hacking.Session pendingDepletions []pendingDepletion farmTickCounter int - enterMu sync.Mutex - enterSeqs map[string]*enterSeq + seqMu sync.Mutex + sequences map[string]*sequence + globalSeqs []*sequence shutdownCancel chan struct{} shutdownActive bool @@ -88,9 +89,9 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali ColorConfig: colorConfig, DataDir: dataDir, }, - GlobalFlags: NewGlobalFlagStore(), + GlobalFlags: NewGlobalFlagStore(), Combat: combat.NewTracker(), - TriggerStore: world.NewTriggerStore(), + flagIndex: newFlagTriggerIndex(), queue: NewCommandQueue(), safespot: NewSafespotManager(), ValidationConfig: valConfig, @@ -99,14 +100,14 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali restTimers: make(map[string]uint64), guardWatchTimers: make(map[string]int), hackingStates: make(map[string]*hacking.Session), - enterSeqs: make(map[string]*enterSeq), + sequences: make(map[string]*sequence), } g.CourseStore.SetWorld(g.World) g.CourseStore.LoadAll() g.LoadMods() g.LoadTechs() g.buildCraftIndex() - g.seedRoomTriggers() + g.loadAllFlagTriggers() g.ValidateAndLog() return g } @@ -116,9 +117,9 @@ func (g *Game) SetHub(hub *net.Hub) { hub.OnRemove(func(sess *net.Session) { if p := sess.Player; p != nil { g.restoreGodPlayer(p) + g.persistLockedOnDisconnect(p) g.AccountStore.SaveCharacter(p) p.DeactivateAllTechs() - g.cancelEnterSeq(p.Name) g.charsMu.Lock() delete(g.loggedInChars, p.Name) g.charsMu.Unlock() @@ -129,7 +130,7 @@ func (g *Game) SetHub(hub *net.Hub) { } }) g.GlobalFlags.OnChange(func(name string, value any) { - g.TriggerStore.FireGlobal(name, value) + g.fireGlobalFlagTriggers(name, value) }) } @@ -353,18 +354,6 @@ func (g *Game) buildCraftIndex() { g.CraftIndex.Build(items) } -func (g *Game) seedRoomTriggers() { - g.TriggerStore.LoadGlobal(g.DataDir) - roomIndex := g.World.RoomIndex() - for id := range roomIndex { - room, err := g.World.LoadRoom(id) - if err != nil || len(room.Triggers) == 0 { - continue - } - g.TriggerStore.SeedRoomTriggers(id, room.Triggers) - } -} - type pendingDepletion struct { instanceKey string objDefID string diff --git a/internal/game/look_target.go b/internal/game/look_target.go index 24a1df5..3f7141e 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -155,7 +155,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { } if len(def.OnLook) > 0 { - g.applyInteraction(sess, p, def.OnLook, p.RoomID, false, p.HasItem) + g.runTrigger(sess, p, def.OnLook, p.RoomID, p.HasItem, false) } if def.Safespot != nil { diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go index 6a1e224..a1955e7 100644 --- a/internal/game/sys_triggers.go +++ b/internal/game/sys_triggers.go @@ -2,7 +2,12 @@ package game import ( "fmt" + "os" + "path/filepath" "strings" + "sync" + + "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/engine" @@ -10,11 +15,349 @@ import ( "thehouseoficarus/internal/player" ) -func (g *Game) TriggerSeqTick() { - g.processPlayerTriggerSequences() - g.processGlobalTriggerSequences() +// sequence is an in-flight scripted trigger sequence (one Trigger's Steps +// running over time). It is driven one step at a time by SequenceTick. Every +// trigger in the game — on_use, on_look, on_kill, on_enter, on_exit, +// on_traverse, on_flag_change, on_global_flag_change — produces a sequence. +type sequence struct { + sess *net.Session // nil for purely-global sequences + player *player.Player + playerName string + roomID int // reference room for broadcasts/spawn/teleport + steps []behavior.Step + wait int // ticks before the next step fires + scope effectScope // scopePlayer | scopeGlobal + flagVal any + key string // dedup key + locked bool // atomic + resumable on disconnect + roomLocked bool // skip steps whose player has left roomID +} + +// verbSeqKey builds a dedup key for a verb-triggered sequence so two of the +// same verb can't run simultaneously for one player. +func verbSeqKey(p *player.Player, itemID string) string { + if p == nil { + return "" + } + if itemID != "" { + return p.Name + ":verb:" + itemID + } + return p.Name + ":verb" +} + +// startSequence registers a sequence (deduped by key). If a sequence with the +// same key is already in flight for the player, the new one is dropped (no +// re-entrancy). +func (g *Game) startSequence(sess *net.Session, p *player.Player, steps []behavior.Step, roomID int, sc effectScope, flagVal any, locked, roomLocked bool, key string) { + if len(steps) == 0 && sess != nil { + return + } + seq := &sequence{ + sess: sess, + player: p, + roomID: roomID, + steps: append([]behavior.Step(nil), steps...), + scope: sc, + flagVal: flagVal, + key: key, + locked: locked, + roomLocked: roomLocked, + } + if p != nil { + seq.playerName = p.Name + } + if len(steps) > 0 { + seq.wait = steps[0].Wait + } + g.seqMu.Lock() + if key != "" { + if _, exists := g.sequences[key]; exists { + g.seqMu.Unlock() + return + } + g.sequences[key] = seq + } else { + g.globalSeqs = append(g.globalSeqs, seq) + } + g.seqMu.Unlock() + if locked && p != nil { + p.PendingSequence = &player.PendingSequence{ + Key: key, + RoomID: roomID, + Steps: append([]behavior.Step(nil), steps...), + Wait: seq.wait, + } + g.AccountStore.SaveCharacter(p) + } +} + +// SequenceTick advances every in-flight sequence by one tick, draining +// consecutive zero-wait steps in a single tick (matching the original +// on_enter/trigger schedulers). Player-bound sequences for a player who is no +// longer connected are dropped (unlocked) or persisted (locked). Completed +// sequences are removed. +func (g *Game) SequenceTick() { + g.seqMu.Lock() + playerSeqs := make([]*sequence, 0, len(g.sequences)) + for _, s := range g.sequences { + playerSeqs = append(playerSeqs, s) + } + globalSeqs := append([]*sequence(nil), g.globalSeqs...) + g.seqMu.Unlock() + + for _, seq := range playerSeqs { + g.advanceSequence(seq) + } + for _, seq := range globalSeqs { + g.advanceSequence(seq) + } +} + +func (g *Game) advanceSequence(seq *sequence) { + // Player-bound: handle disconnect and live-session gating. + if seq.scope == scopePlayer { + g.charsMu.Lock() + sess := g.loggedInChars[seq.playerName] + g.charsMu.Unlock() + if sess == nil || sess.Player == nil { + // Disconnected. Unlocked sequences are dropped; locked ones were + // already snapshotted to PendingSequence at disconnect time, so + // drop the in-memory copy too. + g.removeSequence(seq) + return + } + p := sess.Player + // Room-lock: skip steps whose player is no longer in the reference room. + inRoom := p.RoomID == seq.roomID + if seq.roomLocked && !inRoom { + // Still decrement the wait so timing advances, but don't fire. + // Drop a step when its wait elapses; resume firing on return. + seq.wait-- + if seq.wait <= 0 && len(seq.steps) > 0 { + seq.steps = seq.steps[1:] + if len(seq.steps) > 0 { + seq.wait = seq.steps[0].Wait + } + } + if len(seq.steps) == 0 { + g.completePlayerSequence(seq, p) + } + return + } + + seq.wait-- + var jobs []behavior.Step + for seq.wait <= 0 && len(seq.steps) > 0 { + step := seq.steps[0] + seq.steps = seq.steps[1:] + jobs = append(jobs, step) + if len(seq.steps) > 0 { + seq.wait = seq.steps[0].Wait + } else { + seq.wait = 0 + } + } + for i := range jobs { + g.fireStep(seq, sess, p, &jobs[i]) + } + if len(seq.steps) == 0 { + g.completePlayerSequence(seq, p) + } + return + } + + // Global sequence: no player/room. + seq.wait-- + var jobs []behavior.Step + for seq.wait <= 0 && len(seq.steps) > 0 { + step := seq.steps[0] + seq.steps = seq.steps[1:] + jobs = append(jobs, step) + if len(seq.steps) > 0 { + seq.wait = seq.steps[0].Wait + } else { + seq.wait = 0 + } + } + for i := range jobs { + g.fireGlobalStep(seq, &jobs[i]) + } + if len(seq.steps) == 0 { + g.completeGlobalSequence(seq) + } +} + +// fireStep runs one player-scoped step, gated by its per-step Condition. +func (g *Game) fireStep(seq *sequence, sess *net.Session, p *player.Player, step *behavior.Step) { + if step.Condition != nil && !g.checkCondition(sess, step.Condition) { + return + } + g.applyStep(sess, p, step, seq.roomID, scopePlayer, seq.flagVal) +} + +// fireGlobalStep runs one global-scoped step. +func (g *Game) fireGlobalStep(seq *sequence, step *behavior.Step) { + g.applyStep(nil, nil, step, seq.roomID, scopeGlobal, seq.flagVal) +} + +func (g *Game) completePlayerSequence(seq *sequence, p *player.Player) { + g.removeSequence(seq) + if p != nil && p.PendingSequence != nil && p.PendingSequence.Key == seq.key { + p.PendingSequence = nil + g.AccountStore.SaveCharacter(p) + } + if sess := seq.sess; sess != nil && sess.State == net.StateGame { + g.writePrompt(sess) + } +} + +func (g *Game) completeGlobalSequence(seq *sequence) { + g.removeSequence(seq) +} + +func (g *Game) removeSequence(seq *sequence) { + g.seqMu.Lock() + if seq.key != "" { + delete(g.sequences, seq.key) + } else { + for i, s := range g.globalSeqs { + if s == seq { + g.globalSeqs = append(g.globalSeqs[:i], g.globalSeqs[i+1:]...) + break + } + } + } + g.seqMu.Unlock() +} + +// playerLocked reports whether the player has a locked in-flight sequence (and +// is therefore "busy" — verbs are blocked except quit). +func (g *Game) playerLocked(name string) bool { + g.seqMu.Lock() + defer g.seqMu.Unlock() + for _, s := range g.sequences { + if s.playerName == name && s.locked { + return true + } + } + return false } +// cancelUnlockedSequences drops all unlocked in-flight sequences for the +// player (an unlocked sequence is interruptable by any verb). Locked sequences +// are never cancelled by verbs. +func (g *Game) cancelUnlockedSequences(name string) { + g.seqMu.Lock() + var toRemove []string + for k, s := range g.sequences { + if s.playerName == name && !s.locked { + toRemove = append(toRemove, k) + } + } + for _, k := range toRemove { + delete(g.sequences, k) + } + g.seqMu.Unlock() +} + +// cancelSequences drops ALL in-flight sequences for the player (used on admin +// teleport / forced movement that bypasses the busy lock). +func (g *Game) cancelSequences(name string) { + g.seqMu.Lock() + var toRemove []string + for k, s := range g.sequences { + if s.playerName == name { + toRemove = append(toRemove, k) + } + } + for _, k := range toRemove { + delete(g.sequences, k) + } + g.seqMu.Unlock() +} + +// abortAllSequences drops every in-flight sequence (player and global) and +// clears persisted PendingSequence markers. Used on admin reload. +func (g *Game) abortAllSequences() { + g.seqMu.Lock() + g.sequences = make(map[string]*sequence) + g.globalSeqs = nil + g.seqMu.Unlock() + g.charsMu.Lock() + chars := g.loggedInChars + g.charsMu.Unlock() + for _, sess := range chars { + if sess != nil && sess.Player != nil && sess.Player.PendingSequence != nil { + sess.Player.PendingSequence = nil + } + } +} + +// persistLockedOnDisconnect snapshots the most recent locked sequence +// to PendingSequence (the most recent one wins) and drops all of the player's +// in-memory sequences. Called from the disconnect hook. +func (g *Game) persistLockedOnDisconnect(p *player.Player) { + if p == nil { + return + } + g.seqMu.Lock() + var locked *sequence + var keysToRemove []string + for k, s := range g.sequences { + if s.playerName == p.Name { + if s.locked { + locked = s + } + keysToRemove = append(keysToRemove, k) + } + } + for _, k := range keysToRemove { + delete(g.sequences, k) + } + g.seqMu.Unlock() + if locked != nil { + p.PendingSequence = &player.PendingSequence{ + Key: locked.key, + RoomID: locked.roomID, + Steps: append([]behavior.Step(nil), locked.steps...), + Wait: locked.wait, + } + g.AccountStore.SaveCharacter(p) + } +} + +// resumePendingSequence re-arms a locked sequence from a player's +// PendingSequence snapshot on reconnect. +func (g *Game) resumePendingSequence(sess *net.Session, p *player.Player) { + if p == nil || p.PendingSequence == nil { + return + } + ps := p.PendingSequence + p.PendingSequence = nil + seq := &sequence{ + sess: sess, + player: p, + playerName: p.Name, + roomID: ps.RoomID, + steps: append([]behavior.Step(nil), ps.Steps...), + wait: ps.Wait, + scope: scopePlayer, + flagVal: nil, + key: ps.Key, + locked: true, + roomLocked: false, + } + g.seqMu.Lock() + if seq.key != "" { + g.sequences[seq.key] = seq + } else { + g.globalSeqs = append(g.globalSeqs, seq) + } + g.seqMu.Unlock() +} + +// TransientMobTick drives the despawn-on-leave lifecycle for transient mobs +// spawned by trigger steps (carried over unchanged from the old system). func (g *Game) TransientMobTick() { if g.Hub == nil { return @@ -61,94 +404,6 @@ func (g *Game) TransientMobTick() { } } -func (g *Game) processPlayerTriggerSequences() { - seqs := g.TriggerStore.SnapshotPlayerSeqs() - for _, seq := range seqs { - g.charsMu.Lock() - sess := g.loggedInChars[seq.PlayerName] - g.charsMu.Unlock() - if sess == nil || sess.Player == nil { - g.TriggerStore.RemovePlayerSeq(seq.PlayerName, seq.TriggerID) - continue - } - p := sess.Player - if p.RoomID != seq.RoomID { - continue - } - - seq.Wait-- - if seq.Wait > 0 { - continue - } - - var jobs []behavior.StepAction - for len(seq.Steps) > 0 && seq.Wait <= 0 { - step := seq.Steps[0] - seq.Steps = seq.Steps[1:] - jobs = append(jobs, step) - if len(seq.Steps) > 0 { - seq.Wait = seq.Steps[0].Delay - } - } - - for i := range jobs { - g.executeTriggerStep(sess, p, &jobs[i], seq.RoomID, seq.FlagValue) - } - - if len(seq.Steps) == 0 { - g.TriggerStore.RemovePlayerSeq(seq.PlayerName, seq.TriggerID) - if p.RoomID == seq.RoomID && sess.State == net.StateGame { - g.writePrompt(sess) - } - } - } -} - -func (g *Game) processGlobalTriggerSequences() { - seqs := g.TriggerStore.SnapshotGlobalSeqs() - for _, seq := range seqs { - seq.Wait-- - if seq.Wait > 0 { - continue - } - - var jobs []behavior.StepAction - for len(seq.Steps) > 0 && seq.Wait <= 0 { - step := seq.Steps[0] - seq.Steps = seq.Steps[1:] - jobs = append(jobs, step) - if len(seq.Steps) > 0 { - seq.Wait = seq.Steps[0].Delay - } - } - - for i := range jobs { - g.executeGlobalTriggerStep(&jobs[i], seq.RoomID, seq.FlagValue) - } - - if len(seq.Steps) == 0 { - g.TriggerStore.RemoveGlobalSeq(seq.TriggerID) - } - } -} - -// executeTriggerStep is a thin wrapper over applyStepAction for trigger player -// sequences (full player-targeted effects + sequence/broadcast/spawn fields). -func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, flagValue any) { - // On every step, evaluate the per-step condition (a trigger can now gate - // individual steps via the Condition field inherited from StepAction). - if step.Condition != nil && !g.checkCondition(sess, step.Condition) { - return - } - g.applyStepAction(sess, p, step, roomID, scopePlayerSeq, flagValue) -} - -// executeGlobalTriggerStep is a thin wrapper over applyStepAction for global -// trigger sequences (no player; only broadcasts/global-flags/spawn/despawn). -func (g *Game) executeGlobalTriggerStep(step *behavior.StepAction, roomID int, flagValue any) { - g.applyStepAction(nil, nil, step, roomID, scopeGlobal, flagValue) -} - func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *behavior.SpawnMobConfig, roomID int) { if cfg.ID == "" { return @@ -218,3 +473,174 @@ func expandTemplate(tmpl string, playerName string, flagValue any) string { } return s } + +// ---- Flag-change trigger index (replaces the old TriggerStore) ---- +// +// All on_player_flag / on_global_flag triggers (room-embedded and global-file) +// are indexed by flag name and listen everywhere. Room scoping is opt-in via +// the trigger's Condition.Room. + +type flagTriggerIndex struct { + mu sync.Mutex + playerFlag map[string][]*behavior.Trigger + globalFlag map[string][]*behavior.Trigger +} + +func newFlagTriggerIndex() *flagTriggerIndex { + return &flagTriggerIndex{ + playerFlag: make(map[string][]*behavior.Trigger), + globalFlag: make(map[string][]*behavior.Trigger), + } +} + +func (fi *flagTriggerIndex) clear() { + fi.mu.Lock() + defer fi.mu.Unlock() + fi.playerFlag = make(map[string][]*behavior.Trigger) + fi.globalFlag = make(map[string][]*behavior.Trigger) +} + +func (fi *flagTriggerIndex) addPlayerFlag(t *behavior.Trigger) { + fi.mu.Lock() + defer fi.mu.Unlock() + fi.playerFlag[t.OnPlayerFlag] = append(fi.playerFlag[t.OnPlayerFlag], t) +} + +func (fi *flagTriggerIndex) addGlobalFlag(t *behavior.Trigger) { + fi.mu.Lock() + defer fi.mu.Unlock() + fi.globalFlag[t.OnGlobalFlag] = append(fi.globalFlag[t.OnGlobalFlag], t) +} + +// loadGlobalTriggers loads file-backed triggers from dataDir/triggers (one +// Trigger per yaml file; the filename stem is the trigger id). +func (g *Game) loadGlobalTriggers(dataDir string) error { + dir := filepath.Join(dataDir, "triggers") + if _, err := os.Stat(dir); os.IsNotExist(err) { + return nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + path := filepath.Join(dir, entry.Name()) + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".yaml" && ext != ".yml" { + continue + } + data, err := os.ReadFile(path) + if err != nil { + continue + } + var t behavior.Trigger + if err := yamlUnmarshal(data, &t); err != nil { + return fmt.Errorf("trigger %q: %w", path, err) + } + stem := strings.TrimSuffix(entry.Name(), ext) + if t.OnPlayerFlag != "" { + tc := copyTrigger(t) + tc.DedupKey = "file:" + stem + g.flagIndex.addPlayerFlag(tc) + } + if t.OnGlobalFlag != "" { + tc := copyTrigger(t) + tc.DedupKey = "file:" + stem + g.flagIndex.addGlobalFlag(tc) + } + } + return nil +} + +func copyTrigger(t behavior.Trigger) *behavior.Trigger { + tcopy := t + return &tcopy +} + +// seedRoomFlagTriggers indexes the on_flag_change / on_global_flag_change +// blocks of every room. +func (g *Game) seedRoomFlagTriggers() { + roomIndex := g.World.RoomIndex() + for id := range roomIndex { + room, err := g.World.LoadRoom(id) + if err != nil { + continue + } + for i := range room.OnFlagChange { + t := room.OnFlagChange[i] + if t.OnPlayerFlag != "" { + t.DedupKey = fmt.Sprintf("room:%d:on_flag_change:%d", id, i) + g.flagIndex.addPlayerFlag(&room.OnFlagChange[i]) + } + } + for i := range room.OnGlobalFlagChange { + if room.OnGlobalFlagChange[i].OnGlobalFlag != "" { + room.OnGlobalFlagChange[i].DedupKey = fmt.Sprintf("room:%d:on_global_flag_change:%d", id, i) + g.flagIndex.addGlobalFlag(&room.OnGlobalFlagChange[i]) + } + } + } +} + +func (g *Game) loadAllFlagTriggers() { + g.flagIndex.clear() + _ = g.loadGlobalTriggers(g.DataDir) + g.seedRoomFlagTriggers() +} + +// firePlayerFlagTriggers starts sequences for every on_flag_change trigger +// subscribed to flagName whose Value (if any) matches and whose Condition +// passes for the player. Called from setPlayerFlag after an actual change. +func (g *Game) firePlayerFlagTriggers(p *player.Player, flagName string, flagValue any) { + g.charsMu.Lock() + sess := g.loggedInChars[p.Name] + g.charsMu.Unlock() + if sess == nil { + return + } + g.flagIndex.mu.Lock() + triggers := append([]*behavior.Trigger(nil), g.flagIndex.playerFlag[flagName]...) + g.flagIndex.mu.Unlock() + for i := range triggers { + t := triggers[i] + if t.Value != nil && !engine.ValuesEqual(t.Value, flagValue) { + continue + } + if t.Condition != nil && !g.checkCondition(sess, t.Condition) { + continue + } + g.startSequence(sess, p, t.Steps, p.RoomID, scopePlayer, flagValue, t.Lock, false, p.Name+":flag:"+flagName+":"+t.DedupKey) + } +} + +// fireGlobalFlagTriggers starts sequences for every on_global_flag_change +// trigger subscribed to flagName whose Value (if any) matches. Global-flag +// triggers have no originating player and run at scopeGlobal. +func (g *Game) fireGlobalFlagTriggers(flagName string, flagValue any) { + g.flagIndex.mu.Lock() + triggers := append([]*behavior.Trigger(nil), g.flagIndex.globalFlag[flagName]...) + g.flagIndex.mu.Unlock() + for i := range triggers { + t := triggers[i] + if t.Value != nil && !engine.ValuesEqual(t.Value, flagValue) { + continue + } + if t.Condition != nil && !g.checkConditionGlobal(t.Condition) { + continue + } + roomID := 0 + if t.Condition != nil && t.Condition.Room != 0 { + roomID = t.Condition.Room + } + key := fmt.Sprintf("global:%s:%d:%s", flagName, roomID, t.DedupKey) + g.startSequence(nil, nil, t.Steps, roomID, scopeGlobal, flagValue, false, false, key) + } +} + +// yamlUnmarshal is a small indirection so this file need not import yaml directly. +func yamlUnmarshal(data []byte, out *behavior.Trigger) error { + return yaml.Unmarshal(data, out) +} diff --git a/internal/item/item.go b/internal/item/item.go index 1536006..0a6ef99 100644 --- a/internal/item/item.go +++ b/internal/item/item.go @@ -54,10 +54,10 @@ type DefenseBonuses struct { } type OtherBonuses struct { - Strength int `yaml:"strength,omitempty"` + Strength int `yaml:"strength,omitempty"` RangedStrength int `yaml:"ranged_strength,omitempty"` - Science int `yaml:"science,omitempty"` - Technology int `yaml:"technology,omitempty"` + Science int `yaml:"science,omitempty"` + Technology int `yaml:"technology,omitempty"` } type EquipmentDef struct { diff --git a/internal/object/object.go b/internal/object/object.go index fb93b98..5dbe1cd 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -6,26 +6,26 @@ type ObjectSteal struct { Drops []behavior.DropEntry `yaml:"drops,omitempty"` Level int `yaml:"level"` XP int `yaml:"xp"` - Speed float64 `yaml:"speed"` - GuardMob string `yaml:"guard_mob,omitempty"` + Speed float64 `yaml:"speed"` + GuardMob string `yaml:"guard_mob,omitempty"` } type ObjectDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Aliases []string `yaml:"aliases"` - Color string `yaml:"color"` - Hidden bool `yaml:"hidden"` - InRoomDescription string `yaml:"inroom_description"` - RemovalItem string `yaml:"removal_item"` - Description behavior.DescList `yaml:"description"` - OnUse []behavior.Interaction `yaml:"on_use,omitempty"` - Steal *ObjectSteal `yaml:"steal,omitempty"` + ID string `yaml:"id"` + Name string `yaml:"name"` + Aliases []string `yaml:"aliases"` + Color string `yaml:"color"` + Hidden bool `yaml:"hidden"` + InRoomDescription string `yaml:"inroom_description"` + RemovalItem string `yaml:"removal_item"` + Description behavior.DescList `yaml:"description"` + OnUse []behavior.Trigger `yaml:"on_use,omitempty"` + Steal *ObjectSteal `yaml:"steal,omitempty"` Gather *behavior.GatherConfig `yaml:"gather,omitempty"` Talk *behavior.TalkConfig `yaml:"talk,omitempty"` Safespot *SafespotConfig `yaml:"safespot,omitempty"` - OnLook []behavior.Interaction `yaml:"on_look,omitempty"` + OnLook []behavior.Trigger `yaml:"on_look,omitempty"` } type SafespotConfig struct { diff --git a/internal/player/player.go b/internal/player/player.go index f56521e..5ffea6d 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -172,7 +172,7 @@ type Player struct { Prompt string `yaml:"prompt"` Options map[string]any `yaml:"-"` Flags map[string]any `yaml:"flags"` - EnterSeqRoom int `yaml:"enter_seq_room,omitempty"` + PendingSequence *PendingSequence `yaml:"pending_sequence,omitempty"` RegenerateTick int AmmoQty int `yaml:"ammo_qty,omitempty"` Action *behavior.Action `yaml:"-"` @@ -180,13 +180,13 @@ type Player struct { WalkSequence []string `yaml:"-"` ConsumeCooldown int `yaml:"-"` HomeTransportCooldown int `yaml:"-"` - MoveTicks int `yaml:"-"` + MoveTicks int `yaml:"-"` MoveDirection string `yaml:"-"` - MoveTarget int `yaml:"-"` - MovePendingInteraction *behavior.Interaction `yaml:"-"` - VisualTickCurrent int `yaml:"-"` - AutotriggerMod string `yaml:"-"` - QueuedTrigger string `yaml:"-"` + MoveTarget int `yaml:"-"` + MovePendingTrigger *behavior.Trigger `yaml:"-"` + VisualTickCurrent int `yaml:"-"` + AutotriggerMod string `yaml:"-"` + QueuedTrigger string `yaml:"-"` AttackTimer int `yaml:"-"` HazardTimer int `yaml:"-"` PendingDangerDir string `yaml:"-"` @@ -208,6 +208,18 @@ type MapSymbolData struct { Color string `yaml:"color,omitempty"` } +// PendingSequence is the persisted snapshot of an in-flight LOCKED trigger +// sequence (Trigger.Lock == true). Unlocked sequences are never persisted. +// On reconnect, the game rebuilds a sequence from this snapshot and resumes +// it from the saved Wait offset. +type PendingSequence struct { + Key string `yaml:"key"` + RoomID int `yaml:"room_id"` + Steps []behavior.Step `yaml:"steps"` + Wait int `yaml:"wait"` + FlagVal any `yaml:"flag_val,omitempty"` +} + type PotionBuff struct { Stat string BonusPercent int @@ -218,7 +230,7 @@ func (p *Player) ClearMoveState() { p.MoveTicks = 0 p.MoveDirection = "" p.MoveTarget = 0 - p.MovePendingInteraction = nil + p.MovePendingTrigger = nil } func (p *Player) OptionBool(name string) bool { diff --git a/internal/validate/checks.go b/internal/validate/checks.go index 2e70323..c1e8f19 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -67,11 +67,9 @@ func validateRooms(s Source) []Issue { }) } } - for i, it := range exit.OnTraverse { - issues = append(issues, validateStepAction( - fmt.Sprintf("Room %d: exit %q on_traverse[%d]", id, dir, i), - it.Action, itemIDs, roomIndex, mobIDs)...) - } + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Room %d: exit %q on_traverse", id, dir), + exit.OnTraverse, false, itemIDs, roomIndex, mobIDs)...) } for _, rm := range room.Mobs { @@ -223,7 +221,7 @@ func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]b } if len(def.OnLook) > 0 { - issues = append(issues, validateOnLook(prefix+": on_look", def.OnLook, itemIDs, roomIndex, mobIDs)...) + issues = append(issues, validateTriggerBlock(prefix+": on_look", def.OnLook, false, itemIDs, roomIndex, mobIDs)...) } return issues @@ -487,10 +485,8 @@ func validateMobs(s Source) []Issue { } } - for i, it := range def.OnKill { - issues = append(issues, validateStepAction( - fmt.Sprintf("Mob %q: on_kill[%d]", id, i), it.Action, itemIDs, roomIndex, mobIDs)...) - } + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Mob %q: on_kill", id), def.OnKill, false, itemIDs, roomIndex, mobIDs)...) } return issues @@ -594,19 +590,8 @@ func validateObjects(s Source) []Issue { } } - for _, ui := range obj.OnUse { - if ui.Item != "" && !itemIDs[ui.Item] { - issues = append(issues, Issue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Object %q: on_use item %q does not exist", - id, ui.Item), - }) - } - if ui.Action != nil { - issues = append(issues, validateStepAction(fmt.Sprintf("Object %q: on_use", id), ui.Action, itemIDs, roomIndex, mobIDs)...) - } - } + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Object %q: on_use", id), obj.OnUse, false, itemIDs, roomIndex, mobIDs)...) if obj.Gather != nil { issues = append(issues, validateGather(fmt.Sprintf("Object %q: gather", id), obj.Gather, itemIDs, dropIDs)...) @@ -617,7 +602,7 @@ func validateObjects(s Source) []Issue { } if len(obj.OnLook) > 0 { - issues = append(issues, validateOnLook(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, itemIDs, roomIndex, mobIDs)...) + issues = append(issues, validateTriggerBlock(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, false, itemIDs, roomIndex, mobIDs)...) } } @@ -802,7 +787,10 @@ func validateCourses(s Source) []Issue { return issues } -func validateRoomEnterSteps(s Source) []Issue { +// validateRoomTriggers validates every trigger-shaped block on every room: +// on_enter, on_exit (verb blocks), and on_flag_change / on_global_flag_change +// (flag blocks, which require on_player_flag/on_global_flag). +func validateRoomTriggers(s Source) []Issue { var issues []Issue roomIndex := s.World.RoomIndex() itemIDs := s.Items.IDSet() @@ -810,35 +798,24 @@ func validateRoomEnterSteps(s Source) []Issue { for id := range roomIndex { room, err := s.World.LoadRoom(id) - if err != nil || len(room.OnEnter) == 0 { + if err != nil { continue } - for si := range room.OnEnter { - stepPrefix := fmt.Sprintf("Room %d: on_enter[%d]", id, si) - issues = append(issues, validateStepAction(stepPrefix, &room.OnEnter[si], itemIDs, roomIndex, mobIDs)...) + if len(room.OnEnter) > 0 { + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Room %d: on_enter", id), room.OnEnter, false, itemIDs, roomIndex, mobIDs)...) } - } - - return issues -} - -func validateRoomTriggers(s Source) []Issue { - var issues []Issue - roomIndex := s.World.RoomIndex() - itemIDs := s.Items.IDSet() - mobIDs := s.Mobs.AllDefIDs() - - for id := range roomIndex { - room, err := s.World.LoadRoom(id) - if err != nil || len(room.Triggers) == 0 { - continue + if len(room.OnExit) > 0 { + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Room %d: on_exit", id), room.OnExit, false, itemIDs, roomIndex, mobIDs)...) } - for ti, trigger := range room.Triggers { - prefix := fmt.Sprintf("Room %d: trigger[%d]", id, ti) - for si := range trigger.Steps { - stepPrefix := fmt.Sprintf("%s: step[%d]", prefix, si) - issues = append(issues, validateStepAction(stepPrefix, &trigger.Steps[si], itemIDs, roomIndex, mobIDs)...) - } + if len(room.OnFlagChange) > 0 { + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Room %d: on_flag_change", id), room.OnFlagChange, true, itemIDs, roomIndex, mobIDs)...) + } + if len(room.OnGlobalFlagChange) > 0 { + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Room %d: on_global_flag_change", id), room.OnGlobalFlagChange, true, itemIDs, roomIndex, mobIDs)...) } } @@ -1128,7 +1105,7 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str for nodeID, node := range cfg.Nodes { nodePrefix := fmt.Sprintf("%s: node %q", prefix, nodeID) if node.Action != nil { - issues = append(issues, validateNodeAction(nodePrefix, node.Action, itemIDs, roomIndex)...) + issues = append(issues, validateTalkStep(nodePrefix, node.Action, itemIDs, roomIndex)...) } if node.Goto != "" { if _, ok := cfg.Nodes[node.Goto]; !ok { @@ -1142,7 +1119,7 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str for i, opt := range node.Options { optPrefix := fmt.Sprintf("%s: option %d", nodePrefix, i+1) if opt.Action != nil { - issues = append(issues, validateNodeAction(optPrefix, opt.Action, itemIDs, roomIndex)...) + issues = append(issues, validateTalkStep(optPrefix, opt.Action, itemIDs, roomIndex)...) } } } @@ -1196,66 +1173,25 @@ func validateExitReciprocity(s Source) []Issue { return issues } -func validateNodeAction(prefix string, na *behavior.NodeAction, itemIDs map[string]bool, roomIndex map[int]bool) []Issue { - var issues []Issue - if na == nil { - return issues - } - if na.GiveItem != "" && !itemIDs[na.GiveItem] { - issues = append(issues, Issue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("%s: give_item %q does not exist", - prefix, na.GiveItem), - }) - } - - if na.TakeItem != "" && !itemIDs[na.TakeItem] { - issues = append(issues, Issue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("%s: take_item %q does not exist", - prefix, na.TakeItem), - }) - } - - if na.Teleport > 0 { - if _, ok := roomIndex[na.Teleport]; !ok { - issues = append(issues, Issue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("%s: teleport to nonexistent room %d", - prefix, na.Teleport), - }) - } - } - - return issues -} - -// validateStepAction validates the universal effect superset used by on_use, -// on_look, on_kill, on_enter steps, trigger steps, and exit -// traversal. It covers the inline NodeAction fields plus spawn_mob/despawn_mob -// (when mobIDs is non-nil). Returns the list of issues found. -func validateStepAction(prefix string, step *behavior.StepAction, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue { +// validateStep validates a single Step's effects. mobIDs may be nil when the +// caller doesn't track mob ids (e.g. local-object on_look); spawn/despawn mob +// references are then skipped. +func validateStep(prefix string, step *behavior.Step, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue { var issues []Issue if step == nil { 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), - }) - } + if step.Wait < 0 { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: wait is negative (%d)", + prefix, step.Wait), + }) } - // Validate the embedded NodeAction subset. if step.GiveItem != "" && !itemIDs[step.GiveItem] { issues = append(issues, Issue{ Level: "ERROR", @@ -1283,8 +1219,6 @@ func validateStepAction(prefix string, step *behavior.StepAction, itemIDs map[st } } - // Spawn mob: validate mob def + despawn_rooms (only if mobIDs populated — - // a nil map means the caller doesn't track mob ids, e.g. local objects). if step.SpawnMob != nil && step.SpawnMob.ID != "" { if mobIDs != nil && !mobIDs[step.SpawnMob.ID] { issues = append(issues, Issue{ @@ -1306,7 +1240,6 @@ func validateStepAction(prefix string, step *behavior.StepAction, itemIDs map[st } } - // Despawn mob. if step.DespawnMob != "" && mobIDs != nil && !mobIDs[step.DespawnMob] { issues = append(issues, Issue{ Level: "ERROR", @@ -1316,26 +1249,148 @@ func validateStepAction(prefix string, step *behavior.StepAction, itemIDs map[st }) } + if step.Condition != nil { + issues = append(issues, validateCondition(prefix, step.Condition, roomIndex)...) + } return issues } -// validateOnLook validates a list of on_look interactions (now a list, not a -// single NodeAction). For each entry, validates the optional item_id (unused -// on on_look but permitted), the action's effects, and skips (the entry's -// condition is a runtime gate, not a static referential ref). -func validateOnLook(prefix string, list []behavior.Interaction, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue { +// validateCondition checks a Condition tree's Room references (structural room +// IDs) and recurses into all_of/any_of. +func validateCondition(prefix string, c *behavior.Condition, roomIndex map[int]bool) []Issue { + if c == nil { + return nil + } var issues []Issue - for i, it := range list { - entryPrefix := fmt.Sprintf("%s[%d]", prefix, i) - if it.Item != "" && !itemIDs[it.Item] { + if c.Room != 0 { + if _, ok := roomIndex[c.Room]; !ok { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", - Message: fmt.Sprintf("%s: item_id %q does not exist", - entryPrefix, it.Item), + Message: fmt.Sprintf("%s: condition room references nonexistent room %d", + prefix, c.Room), + }) + } + } + for i := range c.AllOf { + issues = append(issues, validateCondition(prefix, &c.AllOf[i], roomIndex)...) + } + for i := range c.AnyOf { + issues = append(issues, validateCondition(prefix, &c.AnyOf[i], roomIndex)...) + } + return issues +} + +// checkPlayerOnlyInCondition warns when a global-scope condition uses +// player-dependent predicates that cannot be evaluated without a player. +func checkPlayerOnlyInCondition(prefix string, c *behavior.Condition) []Issue { + if c == nil { + return nil + } + var issues []Issue + if c.PlayerFlag != "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: player_flag %q has no effect on global-flag triggers (no player)", prefix, c.PlayerFlag), + }) + } + if c.HasItem != "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: has_item %q has no effect on global-flag triggers (no player)", prefix, c.HasItem), + }) + } + if c.MinCredits > 0 { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: min_credits has no effect on global-flag triggers (no player)", prefix), + }) + } + for i := range c.AllOf { + issues = append(issues, checkPlayerOnlyInCondition(prefix, &c.AllOf[i])...) + } + for i := range c.AnyOf { + issues = append(issues, checkPlayerOnlyInCondition(prefix, &c.AnyOf[i])...) + } + return issues +} + +// validateTriggerBlock validates a list of Trigger entries for one event block. +// isFlag is true for on_flag_change / on_global_flag_change blocks (which +// require on_player_flag/on_global_flag and forbid item_id) and false for verb +// blocks (on_use/on_look/on_kill/on_enter/on_exit/on_traverse), where item_id +// is checked against itemIDs. +func validateTriggerBlock(prefix string, block []behavior.Trigger, isFlag bool, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue { + var issues []Issue + for i := range block { + t := &block[i] + entryPrefix := fmt.Sprintf("%s[%d]", prefix, i) + if isFlag { + if t.OnPlayerFlag == "" && t.OnGlobalFlag == "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: flag-change trigger has no on_player_flag/on_global_flag — it can never fire", + entryPrefix), + }) + } + if t.ItemID != "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: item_id is ignored on flag-change triggers", + entryPrefix), + }) + } + } else { + if t.OnPlayerFlag != "" || t.OnGlobalFlag != "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: on_player_flag/on_global_flag are ignored on verb triggers", + entryPrefix), + }) + } + if t.ItemID != "" && !itemIDs[t.ItemID] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: item_id %q does not exist", + entryPrefix, t.ItemID), + }) + } + } + if t.Lock && len(t.Steps) == 0 { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: lock is true but the trigger has no steps", entryPrefix), }) } - issues = append(issues, validateStepAction(entryPrefix, it.Action, itemIDs, roomIndex, mobIDs)...) + if t.Condition != nil { + issues = append(issues, validateCondition(entryPrefix, t.Condition, roomIndex)...) + } + if isFlag && t.OnGlobalFlag != "" && t.Condition != nil { + issues = append(issues, checkPlayerOnlyInCondition(entryPrefix, t.Condition)...) + } + for si := range t.Steps { + issues = append(issues, validateStep(fmt.Sprintf("%s: step[%d]", entryPrefix, si), &t.Steps[si], itemIDs, roomIndex, mobIDs)...) + if isFlag && t.OnGlobalFlag != "" && t.Steps[si].Condition != nil { + issues = append(issues, checkPlayerOnlyInCondition(fmt.Sprintf("%s: step[%d]", entryPrefix, si), t.Steps[si].Condition)...) + } + } } return issues } + +// validateTalkStep validates a talk node/option Action (a single Step, no mob +// spawn/despawn expected — mobIDs nil so those are skipped). +func validateTalkStep(prefix string, step *behavior.Step, itemIDs map[string]bool, roomIndex map[int]bool) []Issue { + if step == nil { + return nil + } + return validateStep(prefix, step, itemIDs, roomIndex, nil) +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 6558216..234651e 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -77,7 +77,6 @@ func Run(s Source) []Issue { issues = append(issues, validateDropTables(s)...) issues = append(issues, validateCourses(s)...) issues = append(issues, validateRoomTriggers(s)...) - issues = append(issues, validateRoomEnterSteps(s)...) issues = append(issues, validateTechs(s)...) issues = append(issues, validateRoomWiring(s)...) issues = append(issues, validateRoomGrid(s)...) diff --git a/internal/world/grid.go b/internal/world/grid.go index 7be8eed..ac80476 100644 --- a/internal/world/grid.go +++ b/internal/world/grid.go @@ -87,11 +87,11 @@ func BuildGrid(seed int, load func(int) (*Room, bool), include func(int) bool, e continue } - g.Coord[target] = want - g.RoomAt[want] = target - g.Dist[target] = g.Dist[rid] + 1 - queue = append(queue, target) - } + g.Coord[target] = want + g.RoomAt[want] = target + g.Dist[target] = g.Dist[rid] + 1 + queue = append(queue, target) + } } return g diff --git a/internal/world/insert_remove_test.go b/internal/world/insert_remove_test.go index 15d752e..201b0d4 100644 --- a/internal/world/insert_remove_test.go +++ b/internal/world/insert_remove_test.go @@ -95,8 +95,10 @@ func TestInsertGridConflictsOverlap(t *testing.T) { // TestInsertGridConflictsTwist: room 3 beyond 2 is also reachable from 1 via a // separate path, so after the push 3 would have two candidate positions. +// // 1 east→2 east→3 (3 at (2,0,0), pushed to (3,0,0)) // 1 south→4 east→5 north→3 (3 at (1,0,0)) +// // 3 is reachable two ways already → the pre-edit world is itself a twist. The // insert helper must surface a twist conflict. func TestInsertGridConflictsTwist(t *testing.T) { diff --git a/internal/world/mob.go b/internal/world/mob.go index 45b0fb8..fa0cdbf 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -78,13 +78,13 @@ type MobCombat struct { } type MobDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Description string `yaml:"description"` - IdleDescriptions []string `yaml:"idle_descriptions"` - Protected bool `yaml:"protected"` - Unique bool `yaml:"unique"` - Drops MobDropTable `yaml:"drops"` + ID string `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + IdleDescriptions []string `yaml:"idle_descriptions"` + Protected bool `yaml:"protected"` + Unique bool `yaml:"unique"` + Drops MobDropTable `yaml:"drops"` Steal *MobSteal `yaml:"steal,omitempty"` Task *MobTask `yaml:"task,omitempty"` @@ -94,7 +94,7 @@ type MobDef struct { Talk *behavior.TalkConfig `yaml:"talk,omitempty"` Shop *behavior.ShopConfig `yaml:"shop,omitempty"` - OnKill []behavior.Interaction `yaml:"on_kill,omitempty"` + OnKill []behavior.Trigger `yaml:"on_kill,omitempty"` } func (d *MobDef) IsTalkable() bool { return d.Talk != nil } @@ -132,7 +132,7 @@ type MobInstance struct { AttackBonus int StrengthBonus int - AttackTypes []string + AttackTypes []string RangedBonus int ScienceBonus int @@ -172,7 +172,7 @@ type MobInstance struct { // HP draining to zero (the "completion" of the work). The first entry // whose item filter, Condition, and the first-match-wins rule all pass // fires. - OnKill []behavior.Interaction + OnKill []behavior.Trigger // Shop is the (shared, read-only) shop config from the def. Per-instance // live stock is tracked in ShopStock; ShopRestock holds per-item countdown @@ -313,7 +313,7 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in Drops: def.Drops, AttackBonus: attackBonus, StrengthBonus: strengthBonus, - AttackTypes: attackType, + AttackTypes: attackType, RangedBonus: rangedBonus, ScienceBonus: scienceBonus, SciencePercentBonus: sciencePercentBonus, diff --git a/internal/world/room.go b/internal/world/room.go index 8ae2d13..bc115f8 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -75,27 +75,29 @@ type SpawnDef struct { } type ExitDef struct { - Room int `yaml:"room"` - Condition *behavior.Condition `yaml:"condition,omitempty"` - BlockedMessage string `yaml:"blocked_message,omitempty"` - OnTraverse []behavior.Interaction `yaml:"on_traverse,omitempty"` - Hidden bool `yaml:"hidden,omitempty"` - AlwaysBlocked bool `yaml:"always_blocked,omitempty"` + Room int `yaml:"room"` + Condition *behavior.Condition `yaml:"condition,omitempty"` + BlockedMessage string `yaml:"blocked_message,omitempty"` + OnTraverse []behavior.Trigger `yaml:"on_traverse,omitempty"` + Hidden bool `yaml:"hidden,omitempty"` + AlwaysBlocked bool `yaml:"always_blocked,omitempty"` } type Room struct { - ID int `yaml:"id"` - Name string `yaml:"name"` - Color string `yaml:"color"` - Description behavior.DescList `yaml:"description"` - Exits map[ExitDir]ExitDef `yaml:"exits"` - Objects []RoomObject `yaml:"objects"` - ItemSpawns []SpawnDef `yaml:"item_spawns"` - Mobs []RoomMob `yaml:"mobs"` - OnEnter []behavior.StepAction `yaml:"on_enter"` - Hazard string `yaml:"hazard"` - BlockTransport bool `yaml:"block_transport"` - Triggers []TriggerDef `yaml:"triggers"` + ID int `yaml:"id"` + Name string `yaml:"name"` + Color string `yaml:"color"` + Description behavior.DescList `yaml:"description"` + Exits map[ExitDir]ExitDef `yaml:"exits"` + Objects []RoomObject `yaml:"objects"` + ItemSpawns []SpawnDef `yaml:"item_spawns"` + Mobs []RoomMob `yaml:"mobs"` + OnEnter []behavior.Trigger `yaml:"on_enter,omitempty"` + OnExit []behavior.Trigger `yaml:"on_exit,omitempty"` + OnFlagChange []behavior.Trigger `yaml:"on_flag_change,omitempty"` + OnGlobalFlagChange []behavior.Trigger `yaml:"on_global_flag_change,omitempty"` + Hazard string `yaml:"hazard"` + BlockTransport bool `yaml:"block_transport"` } type RoomMob struct { @@ -151,19 +153,19 @@ func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error { func (ro RoomObject) MarshalYAML() (interface{}, error) { if ro.Local != nil { type inlineObj struct { - Name string `yaml:"name"` - Aliases []string `yaml:"aliases,omitempty"` - Color string `yaml:"color,omitempty"` - Hidden bool `yaml:"hidden,omitempty"` - InRoomDescription string `yaml:"inroom_description,omitempty"` - RemovalItem string `yaml:"removal_item,omitempty"` - Description behavior.DescList `yaml:"description,omitempty"` - OnUse []behavior.Interaction `yaml:"on_use,omitempty"` - Steal *object.ObjectSteal `yaml:"steal,omitempty"` - Gather *behavior.GatherConfig `yaml:"gather,omitempty"` - Talk *behavior.TalkConfig `yaml:"talk,omitempty"` - Safespot *object.SafespotConfig `yaml:"safespot,omitempty"` - OnLook []behavior.Interaction `yaml:"on_look,omitempty"` + Name string `yaml:"name"` + Aliases []string `yaml:"aliases,omitempty"` + Color string `yaml:"color,omitempty"` + Hidden bool `yaml:"hidden,omitempty"` + InRoomDescription string `yaml:"inroom_description,omitempty"` + RemovalItem string `yaml:"removal_item,omitempty"` + Description behavior.DescList `yaml:"description,omitempty"` + OnUse []behavior.Trigger `yaml:"on_use,omitempty"` + Steal *object.ObjectSteal `yaml:"steal,omitempty"` + Gather *behavior.GatherConfig `yaml:"gather,omitempty"` + Talk *behavior.TalkConfig `yaml:"talk,omitempty"` + Safespot *object.SafespotConfig `yaml:"safespot,omitempty"` + OnLook []behavior.Trigger `yaml:"on_look,omitempty"` } def := ro.Local return inlineObj{ diff --git a/internal/world/room_migrate.go b/internal/world/room_migrate.go index 946417f..d609c6a 100644 --- a/internal/world/room_migrate.go +++ b/internal/world/room_migrate.go @@ -1,66 +1,174 @@ package world +import ( + "thehouseoficarus/internal/behavior" +) + // RewriteRoomIDs remaps every genuine room-ID reference in this room per idMap -// (oldID -> newID): exit targets, trigger Room/Teleport/DespawnRooms, on-enter -// Teleport/DespawnRooms, and mob WanderRooms. It does NOT touch author-chosen -// set_flags/set_player_flags values or condition `value:` fields — those are not -// structural room references (and historically the swapid regex rewrote them -// incorrectly as a side effect of matching any bare integer). +// (oldID -> newID): exit targets; every Step.Teleport and Step.SpawnMob +// .DespawnRooms inside on_enter/on_exit/on_flag_change/on_global_flag_change/ +// on_traverse/on_use/on_look/on_kill blocks; every Condition.Room anywhere in +// the room (exits, descriptions, trigger conditions, per-step conditions, +// including nested all_of/any_of); and mob WanderRooms. It does NOT touch +// author-chosen set_flags/set_player_flags values or condition `value:` fields +// — those are not structural room references. // // idMap is assumed to be a bijection (each old ID maps to a distinct new ID); -// this holds for both swap (A<->B) and rename-to-unused (A->B). Returns whether -// any field was changed. +// this holds for both swap (A<->B) and rename-to-unused (A->B). Returns +// whether any field was changed. func (r *Room) RewriteRoomIDs(idMap map[int]int) bool { if r == nil || len(idMap) == 0 { return false } changed := false + + // Exit targets + their on_traverse steps + exit/desc conditions. for dir, exit := range r.Exits { var exitChanged bool if remapInt(idMap, &exit.Room) { exitChanged = true } - for i := range exit.OnTraverse { - if exit.OnTraverse[i].Action != nil { - if remapInt(idMap, &exit.OnTraverse[i].Action.Teleport) { - exitChanged = true - } - if exit.OnTraverse[i].Action.SpawnMob != nil && - remapIntSlice(idMap, exit.OnTraverse[i].Action.SpawnMob.DespawnRooms) { - exitChanged = true - } - } + if remapTriggers(idMap, exit.OnTraverse) { + exitChanged = true + } + if exit.Condition != nil && remapCondition(idMap, exit.Condition) { + exitChanged = true } if exitChanged { r.Exits[dir] = exit changed = true } } - for i := range r.Triggers { - if remapInt(idMap, &r.Triggers[i].Room) { + + // Every trigger-shaped block on the room shares the Step list + Condition + // structure. Remap their steps and conditions uniformly. + for i := range r.OnEnter { + c := false + if remapStepList(idMap, r.OnEnter[i].Steps) { + c = true + } + if remapCondition(idMap, r.OnEnter[i].Condition) { + c = true + } + changed = changed || c + } + for i := range r.OnExit { + c := false + if remapStepList(idMap, r.OnExit[i].Steps) { + c = true + } + if remapCondition(idMap, r.OnExit[i].Condition) { + c = true + } + changed = changed || c + } + for i := range r.OnFlagChange { + c := false + if remapStepList(idMap, r.OnFlagChange[i].Steps) { + c = true + } + if remapCondition(idMap, r.OnFlagChange[i].Condition) { + c = true + } + changed = changed || c + } + for i := range r.OnGlobalFlagChange { + c := false + if remapStepList(idMap, r.OnGlobalFlagChange[i].Steps) { + c = true + } + if remapCondition(idMap, r.OnGlobalFlagChange[i].Condition) { + c = true + } + changed = changed || c + } + + // Conditional room descriptions carry a Condition. + for i := range r.Description { + if remapCondition(idMap, r.Description[i].Condition) { changed = true } - for j := range r.Triggers[i].Steps { - s := &r.Triggers[i].Steps[j] - if remapInt(idMap, &s.Teleport) { + } + + // Per-room local objects: on_use/on_look steps + object desc conditions. + for i := range r.Objects { + if r.Objects[i].Local != nil { + loc := r.Objects[i].Local + if remapTriggers(idMap, loc.OnUse) { changed = true } - if s.SpawnMob != nil && remapIntSlice(idMap, s.SpawnMob.DespawnRooms) { + if remapTriggers(idMap, loc.OnLook) { changed = true } + for j := range loc.Description { + if remapCondition(idMap, loc.Description[j].Condition) { + changed = true + } + } } } - for i := range r.OnEnter { - s := &r.OnEnter[i] + + for i := range r.Mobs { + if remapIntSlice(idMap, r.Mobs[i].WanderRooms) { + changed = true + } + } + return changed +} + +// remapTriggers remaps step fields across a []behavior.Trigger (each trigger's +// Steps + per-step Condition + trigger-level Condition). +func remapTriggers(idMap map[int]int, list []behavior.Trigger) bool { + changed := false + for i := range list { + c := false + if remapStepList(idMap, list[i].Steps) { + c = true + } + if remapCondition(idMap, list[i].Condition) { + c = true + } + changed = changed || c + } + return changed +} + +// remapStepList remaps Step.Teleport and Step.SpawnMob.DespawnRooms plus every +// step's per-step Condition.Room across a single Trigger's Steps. +func remapStepList(idMap map[int]int, steps []behavior.Step) bool { + changed := false + for i := range steps { + s := &steps[i] if remapInt(idMap, &s.Teleport) { changed = true } if s.SpawnMob != nil && remapIntSlice(idMap, s.SpawnMob.DespawnRooms) { changed = true } + if remapCondition(idMap, s.Condition) { + changed = true + } } - for i := range r.Mobs { - if remapIntSlice(idMap, r.Mobs[i].WanderRooms) { + return changed +} + +// remapCondition walks a Condition tree and remaps any Room field (a +// structural room reference) it finds, recursing into AllOf/AnyOf. +func remapCondition(idMap map[int]int, c *behavior.Condition) bool { + if c == nil { + return false + } + changed := false + if c.Room != 0 && remapInt(idMap, &c.Room) { + changed = true + } + for i := range c.AllOf { + if remapCondition(idMap, &c.AllOf[i]) { + changed = true + } + } + for i := range c.AnyOf { + if remapCondition(idMap, &c.AnyOf[i]) { changed = true } } diff --git a/internal/world/room_migrate_test.go b/internal/world/room_migrate_test.go index c54adf2..96f74ae 100644 --- a/internal/world/room_migrate_test.go +++ b/internal/world/room_migrate_test.go @@ -2,29 +2,42 @@ package world import ( "testing" - "thehouseoficarus/internal/behavior" ) func TestRoomRewriteRoomIDs(t *testing.T) { r := &Room{ Exits: map[ExitDir]ExitDef{ - North: {Room: 10, OnTraverse: []behavior.Interaction{{Action: &behavior.StepAction{ - NodeAction: behavior.NodeAction{SetGlobalFlags: map[string]any{"last": 10}, Teleport: 110}, - }}}}, - South: {Room: 99}, - }, - Triggers: []TriggerDef{ - { - Room: 30, - Steps: []behavior.StepAction{ - {NodeAction: behavior.NodeAction{Teleport: 40}, SpawnMob: &behavior.SpawnMobConfig{DespawnRooms: []int{50, 60}}}, - }, + North: { + Room: 10, + OnTraverse: []behavior.Trigger{{ + Condition: &behavior.Condition{Room: 60}, // projectile, ensures trigger-condition Room remaps too + Steps: []behavior.Step{{ + SetGlobalFlags: map[string]any{"last": 10}, + Teleport: 110, + }}, + }}, + Condition: &behavior.Condition{Room: 40}, }, + South: {Room: 99}, }, - OnEnter: []behavior.StepAction{ - {NodeAction: behavior.NodeAction{Teleport: 70}, SpawnMob: &behavior.SpawnMobConfig{DespawnRooms: []int{80}}}, - }, + OnEnter: []behavior.Trigger{{ + Condition: &behavior.Condition{Room: 20}, + Steps: []behavior.Step{{ + Teleport: 70, + SpawnMob: &behavior.SpawnMobConfig{DespawnRooms: []int{80}}, + }}, + }}, + OnExit: []behavior.Trigger{{ + Steps: []behavior.Step{{Teleport: 50}}, + }}, + OnFlagChange: []behavior.Trigger{{ + Condition: &behavior.Condition{Room: 30}, + Steps: []behavior.Step{{ + Teleport: 40, + SpawnMob: &behavior.SpawnMobConfig{DespawnRooms: []int{50, 60}}, + }}, + }}, Mobs: []RoomMob{ {ID: "guard", WanderRooms: []int{90, 100}}, }, @@ -38,40 +51,48 @@ func TestRoomRewriteRoomIDs(t *testing.T) { t.Fatal("expected changed=true") } - checkExit := func(dir ExitDir, want int) { - t.Helper() - if got := r.Exits[dir].Room; got != want { - t.Errorf("exit %s room = %d, want %d", dir, got, want) - } - } - checkExit(North, 11) - checkExit(South, 99) // unchanged (not in idMap) - - // Author set_flags values are NOT structural room references and must not - // be remapped (the old swapid regex wrongly rewrote these). - if v := r.Exits[North].OnTraverse[0].Action.SetGlobalFlags["last"]; v != 10 { + if got := r.Exits[North].Room; got != 11 { + t.Errorf("exit north room = %d, want 11", got) + } + if got := r.Exits[South].Room; got != 99 { + t.Errorf("exit south room = %d, want 99 (unchanged)", got) + } + // Author set_flags values are NOT structural room references and must not remap. + if v := r.Exits[North].OnTraverse[0].Steps[0].SetGlobalFlags["last"]; v != 10 { t.Errorf("set_global_flags value remapped: got %v, want 10", v) } - // Exit on_traverse teleport IS a structural room reference and must remap. - if r.Exits[North].OnTraverse[0].Action.Teleport != 111 { - t.Errorf("exit on_traverse teleport = %d, want 111", r.Exits[North].OnTraverse[0].Action.Teleport) + if got := r.Exits[North].OnTraverse[0].Steps[0].Teleport; got != 111 { + t.Errorf("on_traverse teleport = %d, want 111", got) } - - if r.Triggers[0].Room != 31 { - t.Errorf("trigger room = %d, want 31", r.Triggers[0].Room) + // Exit-level condition Room remaps. + if got := r.Exits[North].Condition.Room; got != 41 { + t.Errorf("exit condition room = %d, want 41", got) + } + // Trigger-level + per-step Condition.Room remaps. + if got := r.Exits[North].OnTraverse[0].Condition.Room; got != 61 { + t.Errorf("on_traverse trigger condition room = %d, want 61", got) } - if r.Triggers[0].Steps[0].Teleport != 41 { - t.Errorf("trigger teleport = %d, want 41", r.Triggers[0].Steps[0].Teleport) + if got := r.OnEnter[0].Condition.Room; got != 21 { + t.Errorf("on_enter trigger condition room = %d, want 21", got) } - if got := r.Triggers[0].Steps[0].SpawnMob.DespawnRooms; len(got) != 2 || got[0] != 51 || got[1] != 61 { - t.Errorf("trigger despawn_rooms = %v, want [51 61]", got) + if got := r.OnFlagChange[0].Condition.Room; got != 31 { + t.Errorf("on_flag_change condition room = %d, want 31", got) } - if r.OnEnter[0].Teleport != 71 { - t.Errorf("on_enter teleport = %d, want 71", r.OnEnter[0].Teleport) + if got := r.OnEnter[0].Steps[0].Teleport; got != 71 { + t.Errorf("on_enter teleport = %d, want 71", got) } - if got := r.OnEnter[0].SpawnMob.DespawnRooms; len(got) != 1 || got[0] != 81 { + if got := r.OnEnter[0].Steps[0].SpawnMob.DespawnRooms; len(got) != 1 || got[0] != 81 { t.Errorf("on_enter despawn_rooms = %v, want [81]", got) } + if got := r.OnExit[0].Steps[0].Teleport; got != 51 { + t.Errorf("on_exit teleport = %d, want 51", got) + } + if got := r.OnFlagChange[0].Steps[0].Teleport; got != 41 { + t.Errorf("on_flag_change teleport = %d, want 41", got) + } + if got := r.OnFlagChange[0].Steps[0].SpawnMob.DespawnRooms; len(got) != 2 || got[0] != 51 || got[1] != 61 { + t.Errorf("on_flag_change despawn_rooms = %v, want [51 61]", got) + } if got := r.Mobs[0].WanderRooms; len(got) != 2 || got[0] != 91 || got[1] != 101 { t.Errorf("mob wander_rooms = %v, want [91 101]", got) } @@ -81,7 +102,6 @@ func TestRoomRewriteRoomIDsNoChange(t *testing.T) { r := &Room{ Exits: map[ExitDir]ExitDef{North: {Room: 5}}, } - // idMap does not mention 5 -> nothing changes. if r.RewriteRoomIDs(map[int]int{99: 100}) { t.Error("expected changed=false when no references match") } @@ -100,7 +120,6 @@ func TestRoomRewriteRoomIDsSwapRoundTrips(t *testing.T) { if r.Exits[North].Room != 20 || r.Exits[South].Room != 10 { t.Errorf("after swap: north=%d south=%d, want north=20 south=10", r.Exits[North].Room, r.Exits[South].Room) } - // Applying the same swap again reverts (a swap is its own inverse). r.RewriteRoomIDs(swap) if r.Exits[North].Room != 10 || r.Exits[South].Room != 20 { t.Errorf("after double swap: north=%d south=%d, want north=10 south=20", r.Exits[North].Room, r.Exits[South].Room) @@ -114,7 +133,6 @@ func TestRoomRewriteRoomIDsIdempotentRename(t *testing.T) { if r.Exits[North].Room != 11 { t.Fatalf("after rename: room=%d, want 11", r.Exits[North].Room) } - // Second pass: 11 is not a key in idMap, so nothing changes. if r.RewriteRoomIDs(rename) { t.Error("expected changed=false on second pass of a rename idMap") } diff --git a/internal/world/trigger.go b/internal/world/trigger.go deleted file mode 100644 index de07c18..0000000 --- a/internal/world/trigger.go +++ /dev/null @@ -1,16 +0,0 @@ -package world - -import "thehouseoficarus/internal/behavior" - -type TriggerDef struct { - ID string `yaml:"id"` - OnPlayerFlag string `yaml:"on_player_flag"` - OnGlobalFlag string `yaml:"on_global_flag"` - Value any `yaml:"value"` - Room int `yaml:"room"` - Steps []behavior.StepAction `yaml:"steps"` -} - -func (t *TriggerDef) IsPlayerFlagTrigger() bool { return t.OnPlayerFlag != "" } - -func (t *TriggerDef) IsGlobalFlagTrigger() bool { return t.OnGlobalFlag != "" }
\ No newline at end of file diff --git a/internal/world/trigger_store.go b/internal/world/trigger_store.go deleted file mode 100644 index e50ae81..0000000 --- a/internal/world/trigger_store.go +++ /dev/null @@ -1,330 +0,0 @@ -package world - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "sync" - - "gopkg.in/yaml.v3" - "thehouseoficarus/internal/behavior" - "thehouseoficarus/internal/engine" -) - -type TriggerStore struct { - mu sync.Mutex - - globalByPlayerFlag map[string][]*TriggerDef - globalByGlobalFlag map[string][]*TriggerDef - roomByPlayerFlag map[int]map[string][]*TriggerDef - roomByGlobalFlag map[int]map[string][]*TriggerDef - - playerSeqs map[string]*PlayerTriggerSeq - globalSeqs map[string]*GlobalTriggerSeq -} - -type PlayerTriggerSeq struct { - PlayerName string - TriggerID string - RoomID int - Steps []behavior.StepAction - Wait int - Started bool - FlagValue any -} - -type GlobalTriggerSeq struct { - TriggerID string - RoomID int - Steps []behavior.StepAction - Wait int - FlagValue any -} - -func NewTriggerStore() *TriggerStore { - return &TriggerStore{ - globalByPlayerFlag: make(map[string][]*TriggerDef), - globalByGlobalFlag: make(map[string][]*TriggerDef), - roomByPlayerFlag: make(map[int]map[string][]*TriggerDef), - roomByGlobalFlag: make(map[int]map[string][]*TriggerDef), - playerSeqs: make(map[string]*PlayerTriggerSeq), - globalSeqs: make(map[string]*GlobalTriggerSeq), - } -} - -func (ts *TriggerStore) ClearTriggers() { - ts.mu.Lock() - defer ts.mu.Unlock() - ts.globalByPlayerFlag = make(map[string][]*TriggerDef) - ts.globalByGlobalFlag = make(map[string][]*TriggerDef) - ts.roomByPlayerFlag = make(map[int]map[string][]*TriggerDef) - ts.roomByGlobalFlag = make(map[int]map[string][]*TriggerDef) -} - -func (ts *TriggerStore) LoadGlobal(dataDir string) error { - dir := filepath.Join(dataDir, "triggers") - if _, err := os.Stat(dir); os.IsNotExist(err) { - return nil - } - - entries, err := os.ReadDir(dir) - if err != nil { - return err - } - - for _, entry := range entries { - if entry.IsDir() { - continue - } - path := filepath.Join(dir, entry.Name()) - ext := strings.ToLower(filepath.Ext(path)) - if ext != ".yaml" && ext != ".yml" { - continue - } - id := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) - data, err := os.ReadFile(path) - if err != nil { - continue - } - var trigger TriggerDef - if err := yaml.Unmarshal(data, &trigger); err != nil { - return fmt.Errorf("trigger %q: %w", path, err) - } - trigger.ID = id - ts.addGlobal(&trigger) - } - return nil -} - -func (ts *TriggerStore) SeedRoomTriggers(roomID int, triggers []TriggerDef) { - if len(triggers) == 0 { - return - } - ts.mu.Lock() - defer ts.mu.Unlock() - for i := range triggers { - t := &triggers[i] - if t.IsPlayerFlagTrigger() { - if ts.roomByPlayerFlag[roomID] == nil { - ts.roomByPlayerFlag[roomID] = make(map[string][]*TriggerDef) - } - ts.roomByPlayerFlag[roomID][t.OnPlayerFlag] = append( - ts.roomByPlayerFlag[roomID][t.OnPlayerFlag], t) - } - if t.IsGlobalFlagTrigger() { - if ts.roomByGlobalFlag[roomID] == nil { - ts.roomByGlobalFlag[roomID] = make(map[string][]*TriggerDef) - } - t.Room = roomID - ts.roomByGlobalFlag[roomID][t.OnGlobalFlag] = append( - ts.roomByGlobalFlag[roomID][t.OnGlobalFlag], t) - } - } -} - -func (ts *TriggerStore) addGlobal(t *TriggerDef) { - ts.mu.Lock() - defer ts.mu.Unlock() - if t.IsPlayerFlagTrigger() { - ts.globalByPlayerFlag[t.OnPlayerFlag] = append( - ts.globalByPlayerFlag[t.OnPlayerFlag], t) - } - if t.IsGlobalFlagTrigger() { - ts.globalByGlobalFlag[t.OnGlobalFlag] = append( - ts.globalByGlobalFlag[t.OnGlobalFlag], t) - } -} - -func (ts *TriggerStore) FirePlayer(playerName string, flagName string, flagValue any) []*PlayerTriggerSeq { - ts.mu.Lock() - defer ts.mu.Unlock() - - var started []*PlayerTriggerSeq - - for _, t := range ts.globalByPlayerFlag[flagName] { - if !ts.valueMatches(t.Value, flagValue) { - continue - } - seq := ts.startPlayerSeqLocked(playerName, t, 0, flagValue) - if seq != nil { - started = append(started, seq) - } - } - - return started -} - -func (ts *TriggerStore) FirePlayerInRoom(playerName string, roomID int, flagName string, flagValue any) []*PlayerTriggerSeq { - ts.mu.Lock() - defer ts.mu.Unlock() - - var started []*PlayerTriggerSeq - - for _, t := range ts.globalByPlayerFlag[flagName] { - if !ts.valueMatches(t.Value, flagValue) { - continue - } - seq := ts.startPlayerSeqLocked(playerName, t, roomID, flagValue) - if seq != nil { - started = append(started, seq) - } - } - - if roomFlags, ok := ts.roomByPlayerFlag[roomID]; ok { - for _, t := range roomFlags[flagName] { - if !ts.valueMatches(t.Value, flagValue) { - continue - } - seq := ts.startPlayerSeqLocked(playerName, t, roomID, flagValue) - if seq != nil { - started = append(started, seq) - } - } - } - - return started -} - -func (ts *TriggerStore) FireGlobal(flagName string, flagValue any) []*GlobalTriggerSeq { - ts.mu.Lock() - defer ts.mu.Unlock() - - var started []*GlobalTriggerSeq - - for _, t := range ts.globalByGlobalFlag[flagName] { - if !ts.valueMatches(t.Value, flagValue) { - continue - } - seq := ts.startGlobalSeqLocked(t, flagValue) - if seq != nil { - started = append(started, seq) - } - } - - for roomID, roomFlags := range ts.roomByGlobalFlag { - for _, t := range roomFlags[flagName] { - if !ts.valueMatches(t.Value, flagValue) { - continue - } - if t.Room == 0 { - t.Room = roomID - } - seq := ts.startGlobalSeqLocked(t, flagValue) - if seq != nil { - started = append(started, seq) - } - } - } - - return started -} - -func (ts *TriggerStore) startPlayerSeqLocked(playerName string, t *TriggerDef, roomID int, flagValue any) *PlayerTriggerSeq { - key := playerName + ":" + t.ID - if _, exists := ts.playerSeqs[key]; exists { - return nil - } - seq := &PlayerTriggerSeq{ - PlayerName: playerName, - TriggerID: t.ID, - RoomID: roomID, - Steps: make([]behavior.StepAction, len(t.Steps)), - FlagValue: flagValue, - } - copy(seq.Steps, t.Steps) - if len(seq.Steps) > 0 { - seq.Wait = seq.Steps[0].Delay - } - ts.playerSeqs[key] = seq - return seq -} - -func (ts *TriggerStore) startGlobalSeqLocked(t *TriggerDef, flagValue any) *GlobalTriggerSeq { - key := t.ID - if _, exists := ts.globalSeqs[key]; exists { - return nil - } - seq := &GlobalTriggerSeq{ - TriggerID: t.ID, - RoomID: t.Room, - Steps: make([]behavior.StepAction, len(t.Steps)), - FlagValue: flagValue, - } - copy(seq.Steps, t.Steps) - if len(seq.Steps) > 0 { - seq.Wait = seq.Steps[0].Delay - } - ts.globalSeqs[key] = seq - return seq -} - -func (ts *TriggerStore) valueMatches(want, got any) bool { - if want == nil { - return true - } - return engine.ValuesEqual(want, got) -} - -func (ts *TriggerStore) SnapshotPlayerSeqs() []*PlayerTriggerSeq { - ts.mu.Lock() - defer ts.mu.Unlock() - out := make([]*PlayerTriggerSeq, 0, len(ts.playerSeqs)) - for _, s := range ts.playerSeqs { - out = append(out, s) - } - return out -} - -func (ts *TriggerStore) SnapshotGlobalSeqs() []*GlobalTriggerSeq { - ts.mu.Lock() - defer ts.mu.Unlock() - out := make([]*GlobalTriggerSeq, 0, len(ts.globalSeqs)) - for _, s := range ts.globalSeqs { - out = append(out, s) - } - return out -} - -func (ts *TriggerStore) RemovePlayerSeq(playerName, triggerID string) { - ts.mu.Lock() - defer ts.mu.Unlock() - key := playerName + ":" + triggerID - delete(ts.playerSeqs, key) -} - -func (ts *TriggerStore) RemoveGlobalSeq(triggerID string) { - ts.mu.Lock() - defer ts.mu.Unlock() - delete(ts.globalSeqs, triggerID) -} - -func (ts *TriggerStore) AllGlobalTriggers() []*TriggerDef { - ts.mu.Lock() - defer ts.mu.Unlock() - var out []*TriggerDef - for _, triggers := range ts.globalByPlayerFlag { - out = append(out, triggers...) - } - for _, triggers := range ts.globalByGlobalFlag { - out = append(out, triggers...) - } - return out -} - -func (ts *TriggerStore) AllRoomTriggers() map[int][]*TriggerDef { - ts.mu.Lock() - defer ts.mu.Unlock() - out := make(map[int][]*TriggerDef) - for roomID, byFlag := range ts.roomByPlayerFlag { - for _, triggers := range byFlag { - out[roomID] = append(out[roomID], triggers...) - } - } - for roomID, byFlag := range ts.roomByGlobalFlag { - for _, triggers := range byFlag { - out[roomID] = append(out[roomID], triggers...) - } - } - return out -} diff --git a/migrate-messages b/migrate-messages Binary files differdeleted file mode 100755 index 21f3fc1..0000000 --- a/migrate-messages +++ /dev/null |
