aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--building_guide/behaviors.md120
-rw-r--r--building_guide/courses.md3
-rw-r--r--building_guide/mobs.md9
-rw-r--r--building_guide/objects.md102
-rw-r--r--building_guide/rooms.md216
-rw-r--r--building_guide/triggers.md713
-rw-r--r--cmd/migrate-messages/main.go245
-rw-r--r--cmd/thoi/main.go3
-rw-r--r--data/.admin_history.json2
-rw-r--r--data/objects/000_test_object.yaml13
-rw-r--r--data/objects/anvil.yaml18
-rw-r--r--data/objects/aps_tower.yaml9
-rw-r--r--data/objects/iron_gate.yaml13
-rw-r--r--data/objects/tutorial_lever.yaml26
-rw-r--r--data/rooms/intro/1001.yaml164
-rw-r--r--data/rooms/intro/1002.yaml44
-rw-r--r--data/rooms/intro/1003.yaml18
-rw-r--r--data/rooms/intro/1007.yaml13
-rw-r--r--internal/admin/api_map.go38
-rw-r--r--internal/admin/api_room_courses.go24
-rw-r--r--internal/admin/api_room_insert_remove_test.go12
-rw-r--r--internal/admin/api_rooms_bulk.go10
-rw-r--r--internal/admin/server.go2
-rw-r--r--internal/admin/static/admin.css36
-rw-r--r--internal/admin/static/intereditor.js1074
-rw-r--r--internal/admin/static/map.js229
-rw-r--r--internal/admin/static/mobeditor.js9
-rw-r--r--internal/admin/static/objecteditor.js41
-rw-r--r--internal/admin/templates/map.html2
-rw-r--r--internal/behavior/behavior.go189
-rw-r--r--internal/behavior/types.go59
-rw-r--r--internal/game/act.go47
-rw-r--r--internal/game/act_agility.go2
-rw-r--r--internal/game/act_effects.go203
-rw-r--r--internal/game/act_interaction_seq.go97
-rw-r--r--internal/game/act_interaction_seq_test.go216
-rw-r--r--internal/game/act_room.go178
-rw-r--r--internal/game/act_state.go2
-rw-r--r--internal/game/act_steal.go6
-rw-r--r--internal/game/act_talk.go4
-rw-r--r--internal/game/cmd_dig.go10
-rw-r--r--internal/game/cmd_goto.go5
-rw-r--r--internal/game/cmd_inspect.go26
-rw-r--r--internal/game/cmd_move.go45
-rw-r--r--internal/game/cmd_registry.go21
-rw-r--r--internal/game/cmd_reload.go6
-rw-r--r--internal/game/cmd_room_insert.go5
-rw-r--r--internal/game/cmd_room_remove_test.go9
-rw-r--r--internal/game/cmd_summon.go5
-rw-r--r--internal/game/cmd_use.go30
-rw-r--r--internal/game/cmd_verbs.go6
-rw-r--r--internal/game/combat_mob.go2
-rw-r--r--internal/game/combat_mob_test.go2
-rw-r--r--internal/game/core_course.go2
-rw-r--r--internal/game/core_flags.go12
-rw-r--r--internal/game/core_login_char.go17
-rw-r--r--internal/game/exit_migrate.go6
-rw-r--r--internal/game/exit_migrate_test.go8
-rw-r--r--internal/game/game.go33
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/sys_triggers.go608
-rw-r--r--internal/item/item.go6
-rw-r--r--internal/object/object.go26
-rw-r--r--internal/player/player.go28
-rw-r--r--internal/validate/checks.go291
-rw-r--r--internal/validate/validate.go1
-rw-r--r--internal/world/grid.go10
-rw-r--r--internal/world/insert_remove_test.go2
-rw-r--r--internal/world/mob.go22
-rw-r--r--internal/world/room.go64
-rw-r--r--internal/world/room_migrate.go162
-rw-r--r--internal/world/room_migrate_test.go104
-rw-r--r--internal/world/trigger.go16
-rw-r--r--internal/world/trigger_store.go330
-rwxr-xr-xmigrate-messagesbin3758740 -> 0 bytes
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">&#9650;</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">&#9660;</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">&#9650;</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">&#9660;</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">&#9650;</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">&#9660;</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">&#9650;</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">&#9660;</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(&sectionObjs, &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(&sectionObjs, &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
deleted file mode 100755
index 21f3fc1..0000000
--- a/migrate-messages
+++ /dev/null
Binary files differ