aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-25 20:33:47 -0400
committerhistoria <[not public]>2026-06-25 20:33:47 -0400
commit84d74ca4351a97148ad8339676f9df7946bc21fb (patch)
treea9f50ac56e8bbcdf2b307ef6f7143b67bce46c61
parentc55d0e4150b23f2c5c9f96bbc3d4dc2fc6dbaa36 (diff)
downloadthehouseoficarus-84d74ca4351a97148ad8339676f9df7946bc21fb.tar.gz
feat: trigger system for scripted events added.
-rw-r--r--building_guide/conditions.md3
-rw-r--r--building_guide/rooms.md22
-rw-r--r--building_guide/state.md7
-rw-r--r--building_guide/tips.md2
-rw-r--r--building_guide/triggers.md547
-rw-r--r--cmd/mud/main.go2
-rw-r--r--data/rooms/intro/1001.yaml20
-rw-r--r--data/triggers/announce_99.yaml6
-rw-r--r--internal/game/act_agility.go2
-rw-r--r--internal/game/act_farm.go80
-rw-r--r--internal/game/act_fletch.go2
-rw-r--r--internal/game/act_room.go3
-rw-r--r--internal/game/act_steal.go4
-rw-r--r--internal/game/act_talk.go11
-rw-r--r--internal/game/cmd_craft.go2
-rw-r--r--internal/game/combat_attack.go4
-rw-r--r--internal/game/combat_mob.go15
-rw-r--r--internal/game/core_flags.go16
-rw-r--r--internal/game/flagstore.go39
-rw-r--r--internal/game/game.go18
-rw-r--r--internal/game/production_menu.go2
-rw-r--r--internal/game/sys_assassin.go32
-rw-r--r--internal/game/triggers.go291
-rw-r--r--internal/validate/checks.go68
-rw-r--r--internal/validate/validate.go2
-rw-r--r--internal/world/mob.go45
-rw-r--r--internal/world/room.go1
-rw-r--r--internal/world/trigger.go61
-rw-r--r--internal/world/trigger_store.go340
29 files changed, 1567 insertions, 80 deletions
diff --git a/building_guide/conditions.md b/building_guide/conditions.md
index ea17ed6..d58b35c 100644
--- a/building_guide/conditions.md
+++ b/building_guide/conditions.md
@@ -1,6 +1,7 @@
## Conditions Reference
-Conditions are used in talk options, exit gates, on-enter scripts, and use_interactions checks.
+Conditions are used in talk options, exit gates, on-enter scripts, use_interactions
+checks, room descriptions, object descriptions, and trigger value matching.
### Simple conditions
diff --git a/building_guide/rooms.md b/building_guide/rooms.md
index 7028393..5a414a3 100644
--- a/building_guide/rooms.md
+++ b/building_guide/rooms.md
@@ -208,5 +208,27 @@ mobs:
- Hazards stack with mobs: an aggressive mob in a hazardous room hits you while the room
hazard also rolls against you.
+### Room triggers — scripted events when flags change
+
+A room can carry a `triggers:` block. Each trigger watches a player or world flag
+and fires a sequence of timed steps when that flag's value changes. See `triggers.md`
+for the full reference.
+
+```yaml
+triggers:
+ - on_player_flag: 1001_look_sign
+ 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
+```
+
+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.
+
---
diff --git a/building_guide/state.md b/building_guide/state.md
index 013e62a..2e0b5cc 100644
--- a/building_guide/state.md
+++ b/building_guide/state.md
@@ -4,5 +4,10 @@
**Player flags** (`set_player_flags`, checked with `player_flag`) are per-character. Quest progress, "has read the sign," "paid the toll" — these are different for each player. Saved to the character YAML and persist across logins.
----
+### Triggers — flag-change events
+
+Any flag change (world or player) can activate a **trigger** — a timed sequence of
+messages, broadcasts, spawns, and further flag mutations. See `triggers.md` for
+the full system. This is how you turn "player looked at the sign" into "spaceship
+landed and stairs opened."
diff --git a/building_guide/tips.md b/building_guide/tips.md
index 307ea1e..b4abeb9 100644
--- a/building_guide/tips.md
+++ b/building_guide/tips.md
@@ -19,3 +19,5 @@
9. **Shared depletion vs per-drop depletion.** Use `deplete_timer` for trees — the timer counts down while being chopped and regens when left alone. Use `depletes: true` on individual drops for rocks — they deplete on first successful gather.
10. **Mob wander config goes in the room YAML**, not the mob definition. This lets the same `man` wander differently in different rooms.
+
+11. **Use triggers to decouple cause and effect.** Objects set flags; triggers watch flags. This way the same "pull lever" action can spawn a boss in one room, open a door in another, and broadcast to the server — all by watching the same flag from different triggers. Room triggers keep local events local; global triggers (`data/triggers/`) handle server-wide events.
diff --git a/building_guide/triggers.md b/building_guide/triggers.md
new file mode 100644
index 0000000..fa97e3a
--- /dev/null
+++ b/building_guide/triggers.md
@@ -0,0 +1,547 @@
+## Triggers
+
+Triggers let you script a sequence of timed events that fire when a flag changes
+value. They come in two flavors:
+
+- **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.
+
+A trigger watches one flag (`on_player_flag` or `on_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.
+
+### Quick Start: Landing Sequence
+
+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:
+
+```yaml
+# data/rooms/intro/1001.yaml
+triggers:
+ - on_player_flag: 1001_look_sign
+ 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
+```
+
+The sign object itself is unchanged — its `on_look` simply sets the flag. The trigger
+and the object are cleanly separated:
+
+```yaml
+# data/objects/unique/1001_sign.yaml
+on_look:
+ set_player_flags:
+ 1001_look_sign: true
+```
+
+This separation is deliberate. The object sets flags. The trigger watches flags.
+Any system that sets a player flag (`on_look`, talk nodes, on-enter steps, exits,
+use interactions) can activate a trigger watching that flag.
+
+---
+
+### 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_flags` | world | Mutates world 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. |
+
+---
+
+### Room-Level Triggers
+
+Room triggers go in the room YAML under a `triggers:` list. The room is
+automatically the context for broadcasts and mob spawns.
+
+#### Example 1: Boss arena — puzzle unlocks a boss
+
+Player activates an altar (sets a player flag), triggering a boss spawn:
+
+```yaml
+# data/rooms/dungeon/boss_chamber.yaml
+triggers:
+ - on_player_flag: activated_altar
+ 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]
+```
+
+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.
+
+#### Example 2: 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
+triggers:
+ - on_player_flag: quest_ritual
+ 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."
+```
+
+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).
+
+#### Example 3: World-flag room trigger — shared environmental event
+
+A player pulls a lever (sets world flag `floodgate_open`). The room trigger
+broadcasts to everyone in the dam control room:
+
+```yaml
+# data/rooms/wilderness/dam_control.yaml
+triggers:
+ - on_flag: floodgate_open
+ steps:
+ - broadcast: "Ancient gears grind as the floodgate slowly opens..."
+ - delay: 15
+ broadcast: "Water thunders through the opening!"
+ - set_flags:
+ valley_flooded: true
+```
+
+Because this watches a **world** flag (`on_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
+
+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"`):
+
+```yaml
+# data/triggers/announce_99.yaml
+on_player_flag: announce_99_skill
+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.):
+
+```
+PlayerName has reached level 99 attack!
+```
+
+#### Example 6: World-first boss kill — global broadcast
+
+A boss mob's death sets world flag `world_boss_slain`. A global trigger
+announces it to everyone:
+
+```yaml
+# data/triggers/world_boss_slain.yaml
+on_flag: world_boss_slain
+steps:
+ - broadcast_global: "The Ancient One has been vanquished! The land stirs with new life."
+ - set_flags:
+ ancient_lands_access: true # opens a zone for everyone
+```
+
+#### Example 7: Global trigger with room context
+
+A global trigger can specify a `room` for broadcasts and mob spawns. This is
+useful when a world flag should trigger effects in a specific location:
+
+```yaml
+# data/triggers/obelisk_activated.yaml
+on_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
+```
+
+---
+
+### 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:
+
+```yaml
+triggers:
+ - on_player_flag: phase_1_done
+ steps:
+ - broadcast: "The first seal cracks."
+ - set_player_flags:
+ phase_2_started: true # triggers the next trigger
+
+ - on_player_flag: phase_2_started
+ steps:
+ - delay: 10
+ broadcast: "The second seal glows brighter..."
+ - delay: 10
+ spawn_mob: phase_2_adds
+```
+
+Both sequences run concurrently — phase 2's delay 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.
+
+---
+
+### 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.
+
+#### SpawnMobConfig
+
+`spawn_mob` accepts either a simple string (mob ID only) or a full config map:
+
+```yaml
+spawn_mob: altar_guardian
+
+# Equivalent to:
+spawn_mob:
+ id: altar_guardian
+```
+
+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. |
+
+#### Example 8: 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
+```
+
+#### Example 9: Persistent NPC — stays until killed
+
+```yaml
+spawn_mob:
+ id: wandering_merchant
+ # despawn_on_leave defaults to false — stays forever
+```
+
+#### Example 10: Despawning via trigger step
+
+Use `despawn_mob` in a step to remove previously spawned mobs:
+
+```yaml
+triggers:
+ - on_player_flag: puzzle_solved
+ steps:
+ - despawn_mob: puzzle_guardian # remove the puzzle mob
+ - delay: 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 world-flag triggers), it removes all matching mobs.
+
+#### Example 11: Fixed-lifetime mob
+
+A mob that exists for exactly 2 minutes regardless of owner location:
+
+```yaml
+steps:
+ - spawn_mob:
+ id: timed_challenge_mob
+ owner_only: true
+ - delay: 200 # 200 ticks = 2 minutes
+ despawn_mob: timed_challenge_mob
+ broadcast: "The challenge ends. The mob vanishes."
+```
+
+---
+
+### Template Variables
+
+Steps that send text support these variables:
+
+| Variable | Expands to |
+|---|---|
+| `%p` | Player name (only available for `on_player_flag` triggers) |
+| `%v` | Flag value at the time the trigger fired (e.g. the skill name for level-up announcements) |
+
+```yaml
+steps:
+ - broadcast_global: "%p has completed %v!"
+ # Renders: "Alice has completed the Gauntlet!"
+```
+
+---
+
+### Value-Matching Triggers
+
+By default a trigger fires when the watched flag becomes truthy. Add `value:` to
+require a specific value:
+
+```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_flags:
+ temple_open: true
+```
+
+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
+```
+
+---
+
+### Full Scenario: Multi-Room Investigation Quest
+
+A player investigates a murder scene across several rooms. Finding clues in order
+advances a numeric flag through stages. The last stage spawns a boss.
+
+**Step 1: The body — sets `investigation: 1`**
+
+```yaml
+# data/objects/unique/5001_body.yaml
+name: body
+on_look:
+ set_player_flags:
+ investigation: 1
+```
+
+**Step 2: Blood trail in room 5002** — room trigger fires on value 1
+
+```yaml
+# data/rooms/city/5002_alley.yaml
+triggers:
+ - on_player_flag: investigation
+ value: 1
+ steps:
+ - delay: 3
+ message: "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
+
+```yaml
+# data/rooms/city/5003_warehouse.yaml
+triggers:
+ - on_player_flag: investigation
+ value: 2
+ steps:
+ - delay: 3
+ message: "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
+
+```yaml
+# data/rooms/city/5004_docks.yaml
+triggers:
+ - on_player_flag: investigation
+ value: 3
+ steps:
+ - delay: 5
+ broadcast: "A shadow detaches itself from the warehouse wall..."
+ - delay: 5
+ broadcast: "The assassin steps into the light, blade drawn."
+ - spawn_mob:
+ id: assassin_boss
+ owner_only: true
+ despawn_on_leave: true
+ 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.
+
+---
+
+### Full Scenario: Server-Wide World Event
+
+A server event progresses through stages. A global trigger chains world flags to
+advance the event for everyone.
+
+**Phase 1 trigger:**
+
+```yaml
+# data/triggers/event_phase1.yaml
+on_flag: event_phase1_start
+steps:
+ - broadcast_global: "The sky darkens as an eclipse begins..."
+ - delay: 100
+ set_flags:
+ event_phase2_start: true
+```
+
+**Phase 2 trigger:**
+
+```yaml
+# data/triggers/event_phase2.yaml
+on_flag: event_phase2_start
+steps:
+ - broadcast_global: "Monsters pour from the shadows across the land!"
+ - delay: 300
+ set_flags:
+ event_phase3_start: true
+```
+
+**Phase 3 trigger — event ends:**
+
+```yaml
+# data/triggers/event_phase3.yaml
+on_flag: event_phase3_start
+steps:
+ - broadcast_global: "The eclipse passes. The monsters retreat."
+ - delay: 50
+ set_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` | `look sign` sets `1001_look_sign: 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 traversal | Walking through an exit sets `boarded_shuttle: true` |
+| Use interactions | `push button` sets `gate_open: 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. World-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.
+
+---
+
+### Validation
+
+Startup validation checks that trigger `spawn_mob.id` references an existing mob
+definition, `give_item`/`take_item` reference existing items, `teleport` and
+`despawn_rooms` reference existing rooms, and that global trigger IDs are unique.
+
+Validation does NOT check that the watched flags (`on_player_flag` / `on_flag`)
+are ever set — those are dynamic, set by runtime gameplay, and can't be statically
+verified.
diff --git a/cmd/mud/main.go b/cmd/mud/main.go
index 773d3c9..4427a7d 100644
--- a/cmd/mud/main.go
+++ b/cmd/mud/main.go
@@ -43,6 +43,8 @@ func main() {
g.MoveTick()
g.ProcessQueuedCommands()
g.EnterSeqTick()
+ g.TriggerSeqTick()
+ g.TransientMobTick()
g.World.Tick()
g.MobStore.Tick()
g.RegenTick()
diff --git a/data/rooms/intro/1001.yaml b/data/rooms/intro/1001.yaml
index fd7f2f3..df3b2ab 100644
--- a/data/rooms/intro/1001.yaml
+++ b/data/rooms/intro/1001.yaml
@@ -1,5 +1,10 @@
name: "Inside Transport Shuttle"
-description: "A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark."
+description:
+ - text: "A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. The piston-powered staircase is firmly closed at the rear of the craft."
+ condition:
+ player_flag: 1001_touchdown
+ not: true
+ - text: "A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark."
objects:
- id: 1001_sign
mobs:
@@ -8,8 +13,8 @@ exits:
down:
room: 1002
condition:
- player_flag: 1001_look_sign
- blocked_message: "The attendant points to the sign and insists you {10}look sign{/} before disembarking."
+ player_flag: 1001_touchdown
+ blocked_message: "The piston-powered staircase is firmly closed."
on_enter:
- condition:
player_flag: 1001_welcome
@@ -28,3 +33,12 @@ on_enter:
not: true
delay: 7
message: "{11}Type{/} {10}help newplayer{/} {11}for the new player's guide. That's it. Explore and have fun.{/}"
+triggers:
+ - on_player_flag: 1001_look_sign
+ 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
diff --git a/data/triggers/announce_99.yaml b/data/triggers/announce_99.yaml
new file mode 100644
index 0000000..a6f0e9e
--- /dev/null
+++ b/data/triggers/announce_99.yaml
@@ -0,0 +1,6 @@
+# Example global trigger: announces when a player reaches level 99 in any skill.
+# The game code sets the player flag 'announce_99_skill' to the skill name
+# (e.g. "attack", "strength") when a player hits level 99.
+on_player_flag: announce_99_skill
+steps:
+ - broadcast_global: "%p has reached level 99 %v!"
diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go
index 2136699..4a68580 100644
--- a/internal/game/act_agility.go
+++ b/internal/game/act_agility.go
@@ -103,7 +103,7 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) {
lapKey := "agility_laps_" + courseID
laps := getPlayerFlagInt(p, lapKey) + 1
- setPlayerFlag(p, lapKey, laps)
+ g.setPlayerFlag(p, lapKey, laps)
courseName := p.Action.TargetName
sess.WriteLine(g.colorize(sess, "broadcast",
diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go
index 62e08fd..a4d96b4 100644
--- a/internal/game/act_farm.go
+++ b/internal/game/act_farm.go
@@ -41,13 +41,13 @@ func farmFlagPrefix(defID string, index int) string {
func (g *Game) ensureFarmState(p *player.Player, prefix string) {
p.EnsureFlags()
if _, exists := p.Flags[prefix+"_seed"]; !exists {
- p.Flags[prefix+"_weeds"] = true
- p.Flags[prefix+"_seed"] = ""
- p.Flags[prefix+"_stage"] = 0
- p.Flags[prefix+"_watered"] = false
- p.Flags[prefix+"_diseased"] = false
- p.Flags[prefix+"_dead"] = false
- p.Flags[prefix+"_ready"] = false
+ g.setPlayerFlag(p, prefix+"_weeds", true)
+ g.setPlayerFlag(p, prefix+"_seed", "")
+ g.setPlayerFlag(p, prefix+"_stage", 0)
+ g.setPlayerFlag(p, prefix+"_watered", false)
+ g.setPlayerFlag(p, prefix+"_diseased", false)
+ g.setPlayerFlag(p, prefix+"_dead", false)
+ g.setPlayerFlag(p, prefix+"_ready", false)
}
}
@@ -111,8 +111,8 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) {
}
if diseased {
- p.Flags[prefix+"_dead"] = true
- p.Flags[prefix+"_diseased"] = false
+ g.setPlayerFlag(p, prefix+"_dead", true)
+ g.setPlayerFlag(p, prefix+"_diseased", false)
sess.WriteLine(g.colorize(sess, "farm_disease",
fmt.Sprintf("\nYour %s has died from disease!", g.seedDisplayName(sess, seedID))))
g.AccountStore.SaveCharacter(p)
@@ -135,23 +135,23 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) {
stage++
if stage >= maxStages {
- p.Flags[prefix+"_stage"] = stage
- p.Flags[prefix+"_ready"] = true
- p.Flags[prefix+"_watered"] = false
+ g.setPlayerFlag(p, prefix+"_stage", stage)
+ g.setPlayerFlag(p, prefix+"_ready", true)
+ g.setPlayerFlag(p, prefix+"_watered", false)
sess.WriteLine(g.colorize(sess, "farm_grow",
fmt.Sprintf("\nYour %s is fully grown and ready to harvest!",
g.seedDisplayName(sess, seedID))))
} else {
if !watered && rand.Float64() < 0.10 {
- p.Flags[prefix+"_stage"] = stage
- p.Flags[prefix+"_diseased"] = true
- p.Flags[prefix+"_watered"] = false
+ g.setPlayerFlag(p, prefix+"_stage", stage)
+ g.setPlayerFlag(p, prefix+"_diseased", true)
+ g.setPlayerFlag(p, prefix+"_watered", false)
sess.WriteLine(g.colorize(sess, "farm_disease",
fmt.Sprintf("\nYour %s has become diseased!",
g.seedDisplayName(sess, seedID))))
} else {
- p.Flags[prefix+"_stage"] = stage
- p.Flags[prefix+"_watered"] = false
+ g.setPlayerFlag(p, prefix+"_stage", stage)
+ g.setPlayerFlag(p, prefix+"_watered", false)
sess.WriteLine(g.colorize(sess, "farm_grow",
fmt.Sprintf("\nYour %s has grown to stage %d/%d.",
g.seedDisplayName(sess, seedID), stage, maxStages)))
@@ -182,13 +182,13 @@ func (g *Game) advancePlant(sess *net.Session, p *player.Player) {
g.ensureFarmState(p, prefix)
- p.Flags[prefix+"_weeds"] = false
- p.Flags[prefix+"_seed"] = seedID
- p.Flags[prefix+"_stage"] = 0
- p.Flags[prefix+"_watered"] = false
- p.Flags[prefix+"_diseased"] = false
- p.Flags[prefix+"_dead"] = false
- p.Flags[prefix+"_ready"] = false
+ g.setPlayerFlag(p, prefix+"_weeds", false)
+ g.setPlayerFlag(p, prefix+"_seed", seedID)
+ g.setPlayerFlag(p, prefix+"_stage", 0)
+ g.setPlayerFlag(p, prefix+"_watered", false)
+ g.setPlayerFlag(p, prefix+"_diseased", false)
+ g.setPlayerFlag(p, prefix+"_dead", false)
+ g.setPlayerFlag(p, prefix+"_ready", false)
if intXP := int(xp); intXP > 0 {
g.awardSkillXP(sess, p, player.Farming, intXP)
@@ -280,13 +280,13 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) {
}
g.ensureFarmState(p, prefix)
- p.Flags[prefix+"_seed"] = ""
- p.Flags[prefix+"_stage"] = 0
- p.Flags[prefix+"_ready"] = false
- p.Flags[prefix+"_watered"] = false
- p.Flags[prefix+"_weeds"] = true
- p.Flags[prefix+"_diseased"] = false
- p.Flags[prefix+"_dead"] = false
+ g.setPlayerFlag(p, prefix+"_seed", "")
+ g.setPlayerFlag(p, prefix+"_stage", 0)
+ g.setPlayerFlag(p, prefix+"_ready", false)
+ g.setPlayerFlag(p, prefix+"_watered", false)
+ g.setPlayerFlag(p, prefix+"_weeds", true)
+ g.setPlayerFlag(p, prefix+"_diseased", false)
+ g.setPlayerFlag(p, prefix+"_dead", false)
g.AccountStore.SaveCharacter(p)
@@ -314,13 +314,13 @@ func (g *Game) advanceRake(sess *net.Session, p *player.Player) {
}
prefix := data.Prefix
- p.Flags[prefix+"_weeds"] = false
- p.Flags[prefix+"_dead"] = false
- p.Flags[prefix+"_seed"] = ""
- p.Flags[prefix+"_stage"] = 0
- p.Flags[prefix+"_ready"] = false
- p.Flags[prefix+"_diseased"] = false
- p.Flags[prefix+"_watered"] = false
+ g.setPlayerFlag(p, prefix+"_weeds", false)
+ g.setPlayerFlag(p, prefix+"_dead", false)
+ g.setPlayerFlag(p, prefix+"_seed", "")
+ g.setPlayerFlag(p, prefix+"_stage", 0)
+ g.setPlayerFlag(p, prefix+"_ready", false)
+ g.setPlayerFlag(p, prefix+"_diseased", false)
+ g.setPlayerFlag(p, prefix+"_watered", false)
g.AccountStore.SaveCharacter(p)
@@ -340,7 +340,7 @@ func (g *Game) advanceWater(sess *net.Session, p *player.Player) {
}
prefix := data.Prefix
- p.Flags[prefix+"_watered"] = true
+ g.setPlayerFlag(p, prefix+"_watered", true)
g.AccountStore.SaveCharacter(p)
@@ -361,7 +361,7 @@ func (g *Game) advanceCure(sess *net.Session, p *player.Player) {
prefix := data.Prefix
p.RemoveItem("plant_cure", 1)
- p.Flags[prefix+"_diseased"] = false
+ g.setPlayerFlag(p, prefix+"_diseased", false)
g.AccountStore.SaveCharacter(p)
diff --git a/internal/game/act_fletch.go b/internal/game/act_fletch.go
index 78a19ad..e85f2c1 100644
--- a/internal/game/act_fletch.go
+++ b/internal/game/act_fletch.go
@@ -12,7 +12,7 @@ import (
func (g *Game) startFletchAction(sess *net.Session, p *player.Player, def *item.ItemDef, count int) {
p.EnsureFlags()
- p.Flags["last_fletch"] = def.ID
+ g.setPlayerFlag(p, "last_fletch", def.ID)
g.AccountStore.SaveCharacter(p)
if def.FirstCraft().Wait <= 0 {
diff --git a/internal/game/act_room.go b/internal/game/act_room.go
index 86c1737..fec7304 100644
--- a/internal/game/act_room.go
+++ b/internal/game/act_room.go
@@ -188,9 +188,8 @@ func (g *Game) isCharLive(name string, sess *net.Session) bool {
func (g *Game) applyFlagMutations(p *player.Player, setFlags, setPlayerFlags map[string]any) {
g.Flags.SetAll(setFlags)
if len(setPlayerFlags) > 0 {
- p.EnsureFlags()
for k, v := range setPlayerFlags {
- p.Flags[k] = v
+ g.setPlayerFlag(p, k, v)
}
}
}
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index 7371782..39df9e2 100644
--- a/internal/game/act_steal.go
+++ b/internal/game/act_steal.go
@@ -203,6 +203,10 @@ func (g *Game) startSteal(sess *net.Session, p *player.Player, targetType string
targetID = mob.InstanceID
mobInstanceID = mob.InstanceID
+ if mob.OwnerOnly && mob.Owner != "" && mob.Owner != p.Name {
+ sess.WriteLine("They don't seem interested in you.")
+ return
+ }
if stealTable == "" {
sess.WriteLine(fmt.Sprintf("You can't steal from the %s.", targetName))
return
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index f54290a..14361f8 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -14,6 +14,10 @@ import (
)
func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ if mob.OwnerOnly && mob.Owner != "" && mob.Owner != p.Name {
+ sess.WriteLine("They ignore you.")
+ return
+ }
cfg := mob.TalkConfig
if cfg == nil {
sess.WriteLine("This person has nothing to say.")
@@ -267,7 +271,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep))
return
}
- setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost)
+ g.setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost)
g.AccountStore.SaveCharacter(p)
}
@@ -275,8 +279,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
g.Flags.Set(k, v)
}
for k, v := range na.SetPlayerFlags {
- p.EnsureFlags()
- p.Flags[k] = v
+ g.setPlayerFlag(p, k, v)
}
if na.TakeItem != "" {
if p.HasItem(na.TakeItem) {
@@ -323,6 +326,6 @@ func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
g.processSawmill(sess, p)
}
if na.ApsNode {
- setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true)
+ g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true)
}
}
diff --git a/internal/game/cmd_craft.go b/internal/game/cmd_craft.go
index 254b938..0fe3d5b 100644
--- a/internal/game/cmd_craft.go
+++ b/internal/game/cmd_craft.go
@@ -164,7 +164,7 @@ func (g *Game) availableItems(p *player.Player, allItems []*item.ItemDef) []*ite
func (g *Game) startItem(sess *net.Session, p *player.Player, def *item.ItemDef, count int, flagKey string) {
p.EnsureFlags()
- p.Flags[flagKey] = def.ID
+ g.setPlayerFlag(p, flagKey, def.ID)
g.AccountStore.SaveCharacter(p)
g.startProductionFromItem(sess, p, def, count)
diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go
index ee7dee8..61eb9c1 100644
--- a/internal/game/combat_attack.go
+++ b/internal/game/combat_attack.go
@@ -130,6 +130,10 @@ func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobIn
}
func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ if mob.OwnerOnly && mob.Owner != "" && mob.Owner != p.Name {
+ sess.WriteLine("They don't seem interested in you.")
+ return
+ }
g.cancelRest(p.Name)
g.cancelBgAction(p)
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index e06e307..5558578 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -212,10 +212,17 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
respawnTicks = engine.ToTicks(30)
}
instanceID := mob.InstanceID
- g.Ticks.Subscribe(respawnTicks, func() bool {
- g.respawnMob(instanceID)
- return false
- })
+ if mob.SpawnedByTrigger {
+ g.Ticks.Subscribe(10, func() bool {
+ g.MobStore.RemoveInstance(instanceID)
+ return false
+ })
+ } else {
+ g.Ticks.Subscribe(respawnTicks, func() bool {
+ g.respawnMob(instanceID)
+ return false
+ })
+ }
g.writePrompt(sess)
}
}
diff --git a/internal/game/core_flags.go b/internal/game/core_flags.go
index 6595cc4..5e1c812 100644
--- a/internal/game/core_flags.go
+++ b/internal/game/core_flags.go
@@ -33,9 +33,23 @@ func getPlayerFlagString(p *player.Player, key string) string {
return s
}
-func setPlayerFlag(p *player.Player, key string, val any) {
+func (g *Game) setPlayerFlag(p *player.Player, key string, val any) {
p.EnsureFlags()
+ old, existed := p.Flags[key]
p.Flags[key] = val
+ if !existed || !valuesEqual(old, val) {
+ g.firePlayerFlagTrigger(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 {
diff --git a/internal/game/flagstore.go b/internal/game/flagstore.go
index 2790d89..bc72c60 100644
--- a/internal/game/flagstore.go
+++ b/internal/game/flagstore.go
@@ -2,18 +2,29 @@ package game
import "sync"
+// FlagChangeCallback is invoked when a world flag value actually changes
+// (old value differs from new, or new flag is created with a truthy value).
+type FlagChangeCallback func(name string, value any)
+
// FlagStore holds world flags — shared mutable state visible to all players
// (e.g. opened doors, quest state). Player-specific flags live on
// *player.Player.Flags instead. FlagStore is safe for concurrent use.
type FlagStore struct {
- mu sync.Mutex
- flags map[string]any
+ mu sync.Mutex
+ flags map[string]any
+ callbacks []FlagChangeCallback
}
func NewFlagStore() *FlagStore {
return &FlagStore{flags: make(map[string]any)}
}
+func (f *FlagStore) OnChange(cb FlagChangeCallback) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.callbacks = append(f.callbacks, cb)
+}
+
func (f *FlagStore) Get(name string) (any, bool) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -23,15 +34,35 @@ func (f *FlagStore) Get(name string) (any, bool) {
func (f *FlagStore) Set(name string, value any) {
f.mu.Lock()
- defer f.mu.Unlock()
+ old, existed := f.flags[name]
f.flags[name] = value
+ cbs := f.callbacks
+ f.mu.Unlock()
+
+ if !existed || !valuesEqual(old, value) {
+ for _, cb := range cbs {
+ cb(name, value)
+ }
+ }
}
func (f *FlagStore) SetAll(m map[string]any) {
f.mu.Lock()
- defer f.mu.Unlock()
+ changed := make(map[string]any)
for k, v := range m {
+ old, existed := f.flags[k]
f.flags[k] = v
+ if !existed || !valuesEqual(old, v) {
+ changed[k] = v
+ }
+ }
+ cbs := f.callbacks
+ f.mu.Unlock()
+
+ for k, v := range changed {
+ for _, cb := range cbs {
+ cb(k, v)
+ }
}
}
diff --git a/internal/game/game.go b/internal/game/game.go
index 320304f..acd2924 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -52,6 +52,7 @@ type Game struct {
Hub *net.Hub
Flags *FlagStore
Combat *combat.Tracker
+ TriggerStore *world.TriggerStore
queue *CommandQueue
safespot *SafespotManager
ValidationConfig config.ValidationConfig
@@ -84,6 +85,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali
},
Flags: NewFlagStore(),
Combat: combat.NewTracker(),
+ TriggerStore: world.NewTriggerStore(),
queue: NewCommandQueue(),
safespot: NewSafespotManager(),
ValidationConfig: valConfig,
@@ -97,6 +99,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali
g.LoadMods()
g.LoadTechs()
g.buildCraftIndex()
+ g.seedRoomTriggers()
g.ValidateAndLog()
return g
}
@@ -116,6 +119,9 @@ func (g *Game) SetHub(hub *net.Hub) {
}
}
})
+ g.Flags.OnChange(func(name string, value any) {
+ g.TriggerStore.FireWorld(name, value)
+ })
}
func (g *Game) HandleSession(sess *net.Session, input string) {
@@ -334,6 +340,18 @@ 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/production_menu.go b/internal/game/production_menu.go
index 7181a0e..c1d9a96 100644
--- a/internal/game/production_menu.go
+++ b/internal/game/production_menu.go
@@ -258,7 +258,7 @@ func (g *Game) handleProductChoice(sess *net.Session, input string) {
}
p.EnsureFlags()
- p.Flags[lastFlagKey] = sel.Item.ID
+ g.setPlayerFlag(p, lastFlagKey, sel.Item.ID)
g.AccountStore.SaveCharacter(p)
if background {
diff --git a/internal/game/sys_assassin.go b/internal/game/sys_assassin.go
index 1927399..720529c 100644
--- a/internal/game/sys_assassin.go
+++ b/internal/game/sys_assassin.go
@@ -51,9 +51,9 @@ func (g *Game) assignAssassinTask(sess *net.Session, p *player.Player) {
}
}
count := chosen.MinCount + rand.Intn(chosen.MaxCount-chosen.MinCount+1)
- setPlayerFlag(p, "assassin_task_mob", chosen.MobID)
- setPlayerFlag(p, "assassin_task_total", count)
- setPlayerFlag(p, "assassin_task_remaining", count)
+ g.setPlayerFlag(p, "assassin_task_mob", chosen.MobID)
+ g.setPlayerFlag(p, "assassin_task_total", count)
+ g.setPlayerFlag(p, "assassin_task_remaining", count)
g.AccountStore.SaveCharacter(p)
def, err := g.MobStore.LoadDef(chosen.MobID)
@@ -79,20 +79,20 @@ func (g *Game) onAssassinKill(sess *net.Session, p *player.Player, mob *world.Mo
sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp asm)", xp)))
}
remaining--
- setPlayerFlag(p, "assassin_task_remaining", remaining)
+ g.setPlayerFlag(p, "assassin_task_remaining", remaining)
if remaining <= 0 {
completed := getPlayerFlagInt(p, "assassin_tasks_completed") + 1
streak := getPlayerFlagInt(p, "assassin_streak") + 1
- setPlayerFlag(p, "assassin_tasks_completed", completed)
- setPlayerFlag(p, "assassin_streak", streak)
+ g.setPlayerFlag(p, "assassin_tasks_completed", completed)
+ g.setPlayerFlag(p, "assassin_streak", streak)
delete(p.Flags, "assassin_task_mob")
- setPlayerFlag(p, "assassin_task_remaining", 0)
- setPlayerFlag(p, "assassin_task_total", 0)
+ g.setPlayerFlag(p, "assassin_task_remaining", 0)
+ g.setPlayerFlag(p, "assassin_task_total", 0)
rep := 1
bonus := streakBonus(streak)
rep += bonus
currentRep := getPlayerFlagInt(p, "assassin_reputation")
- setPlayerFlag(p, "assassin_reputation", currentRep+rep)
+ g.setPlayerFlag(p, "assassin_reputation", currentRep+rep)
sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\n*** Assassin task complete! ***")))
if bonus > 0 {
sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Streak bonus! %d tasks in a row. +%d bonus reputation.", streak, bonus)))
@@ -116,11 +116,11 @@ func (g *Game) skipAssassinTask(sess *net.Session, p *player.Player) {
sess.WriteLine("You don't have enough Reputation to skip. (Need 30, have " + fmt.Sprint(rep) + ")")
return
}
- setPlayerFlag(p, "assassin_reputation", rep-30)
+ g.setPlayerFlag(p, "assassin_reputation", rep-30)
delete(p.Flags, "assassin_task_mob")
- setPlayerFlag(p, "assassin_task_remaining", 0)
- setPlayerFlag(p, "assassin_task_total", 0)
- setPlayerFlag(p, "assassin_streak", 0)
+ g.setPlayerFlag(p, "assassin_task_remaining", 0)
+ g.setPlayerFlag(p, "assassin_task_total", 0)
+ g.setPlayerFlag(p, "assassin_streak", 0)
g.AccountStore.SaveCharacter(p)
}
@@ -135,15 +135,15 @@ func (g *Game) extendAssassinTask(sess *net.Session, p *player.Player) {
sess.WriteLine("You don't have an active task to extend.")
return
}
- setPlayerFlag(p, "assassin_reputation", rep-30)
+ g.setPlayerFlag(p, "assassin_reputation", rep-30)
total := getPlayerFlagInt(p, "assassin_task_total")
remaining := getPlayerFlagInt(p, "assassin_task_remaining")
extension := total / 2
if extension < 5 {
extension = 5
}
- setPlayerFlag(p, "assassin_task_total", total+extension)
- setPlayerFlag(p, "assassin_task_remaining", remaining+extension)
+ g.setPlayerFlag(p, "assassin_task_total", total+extension)
+ g.setPlayerFlag(p, "assassin_task_remaining", remaining+extension)
g.AccountStore.SaveCharacter(p)
def, _ := g.MobStore.LoadDef(taskMob)
diff --git a/internal/game/triggers.go b/internal/game/triggers.go
new file mode 100644
index 0000000..76ce9cd
--- /dev/null
+++ b/internal/game/triggers.go
@@ -0,0 +1,291 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/engine"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) TriggerSeqTick() {
+ g.processPlayerTriggerSequences()
+ g.processWorldTriggerSequences()
+}
+
+func (g *Game) TransientMobTick() {
+ if g.Hub == nil {
+ return
+ }
+ for _, inst := range g.MobStore.AllInstances() {
+ if inst.Owner == "" || inst.HP <= 0 {
+ continue
+ }
+ if !inst.DespawnOnLeave {
+ continue
+ }
+
+ g.charsMu.Lock()
+ ownerSess := g.loggedInChars[inst.Owner]
+ g.charsMu.Unlock()
+ if ownerSess == nil || ownerSess.Player == nil {
+ continue
+ }
+
+ ownerRoom := ownerSess.Player.RoomID
+ allowed := inst.DespawnRooms
+ if len(allowed) == 0 {
+ allowed = []int{inst.HomeRoomID}
+ }
+ inAllowed := false
+ for _, r := range allowed {
+ if ownerRoom == r {
+ inAllowed = true
+ break
+ }
+ }
+ if inAllowed {
+ inst.DespawnCounter = inst.DespawnTickCount()
+ continue
+ }
+ inst.DecrementDespawnCounter()
+ if inst.DespawnCounter <= 0 {
+ g.MobStore.RemoveInstance(inst.InstanceID)
+ for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) {
+ sess.WriteLine(g.colorize(sess, "broadcast",
+ fmt.Sprintf("\n%s fades away.", mobDisplayName(inst, true))))
+ }
+ }
+ }
+}
+
+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 []world.TriggerStep
+ 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 _, step := range jobs {
+ g.executeTriggerStep(sess, p, &step, 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) processWorldTriggerSequences() {
+ seqs := g.TriggerStore.SnapshotWorldSeqs()
+ for _, seq := range seqs {
+ seq.Wait--
+ if seq.Wait > 0 {
+ continue
+ }
+
+ var jobs []world.TriggerStep
+ 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 _, step := range jobs {
+ g.executeWorldTriggerStep(&step, seq.RoomID, seq.FlagValue)
+ }
+
+ if len(seq.Steps) == 0 {
+ g.TriggerStore.RemoveWorldSeq(seq.TriggerID)
+ }
+ }
+}
+
+func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *world.TriggerStep, roomID int, flagValue any) {
+ if step.Message != "" {
+ msg := expandTemplate(step.Message, p.Name, flagValue)
+ sess.WriteLine(msg)
+ }
+ if step.Broadcast != "" && g.Hub != nil {
+ msg := expandTemplate(step.Broadcast, p.Name, flagValue)
+ for _, other := range g.Hub.PlayersInRoom(roomID) {
+ other.WriteLine(g.colorize(other, "broadcast", "\n"+msg))
+ }
+ }
+ if step.BroadcastGlobal != "" && g.Hub != nil {
+ msg := expandTemplate(step.BroadcastGlobal, p.Name, flagValue)
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", "\n"+msg))
+ }
+ }
+ }
+ if len(step.SetFlags) > 0 {
+ g.Flags.SetAll(step.SetFlags)
+ }
+ if len(step.SetPlayerFlags) > 0 {
+ for k, v := range step.SetPlayerFlags {
+ g.setPlayerFlag(p, k, v)
+ }
+ g.AccountStore.SaveCharacter(p)
+ }
+ if step.GiveItem != "" {
+ slot := p.FirstFreeSlot()
+ if slot == -1 {
+ sess.WriteLine("Your inventory is too full to receive that.")
+ } else {
+ p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1})
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+ if step.TakeItem != "" {
+ if p.HasItem(step.TakeItem) {
+ p.RemoveItem(step.TakeItem, 1)
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+ if step.Heal > 0 {
+ p.HP += step.Heal
+ if maxHP := p.MaxHP(); p.HP > maxHP {
+ p.HP = maxHP
+ }
+ g.AccountStore.SaveCharacter(p)
+ sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal))
+ }
+ if step.SpawnMob != nil {
+ g.spawnTriggerMob(sess, p, step.SpawnMob, roomID)
+ }
+ if step.DespawnMob != "" {
+ g.despawnTriggerMobs(step.DespawnMob, p.Name)
+ }
+ if step.Teleport > 0 {
+ g.teleportPlayer(sess, p, step.Teleport)
+ }
+}
+
+func (g *Game) executeWorldTriggerStep(step *world.TriggerStep, roomID int, flagValue any) {
+ if step.Broadcast != "" && g.Hub != nil {
+ msg := expandTemplate(step.Broadcast, "", flagValue)
+ for _, other := range g.Hub.PlayersInRoom(roomID) {
+ other.WriteLine(g.colorize(other, "broadcast", "\n"+msg))
+ }
+ }
+ if step.BroadcastGlobal != "" && g.Hub != nil {
+ msg := expandTemplate(step.BroadcastGlobal, "", flagValue)
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player != nil {
+ other.WriteLine(g.colorize(other, "broadcast", "\n"+msg))
+ }
+ }
+ }
+ if len(step.SetFlags) > 0 {
+ g.Flags.SetAll(step.SetFlags)
+ }
+ if step.SpawnMob != nil {
+ g.spawnWorldTriggerMob(step.SpawnMob, roomID)
+ }
+ if step.DespawnMob != "" {
+ g.despawnTriggerMobs(step.DespawnMob, "")
+ }
+}
+
+func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *world.SpawnMobConfig, roomID int) {
+ if cfg.ID == "" {
+ return
+ }
+ def, err := g.MobStore.LoadDef(cfg.ID)
+ if err != nil {
+ return
+ }
+ inst := g.MobStore.SpawnTransient(def, cfg, roomID, p.Name)
+ if inst == nil {
+ return
+ }
+ inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks))
+ sess.WriteLine(g.colorize(sess, "broadcast", "\n"+mobDisplayName(inst, true)+" appears!"))
+}
+
+func (g *Game) spawnWorldTriggerMob(cfg *world.SpawnMobConfig, roomID int) {
+ if cfg.ID == "" {
+ return
+ }
+ def, err := g.MobStore.LoadDef(cfg.ID)
+ if err != nil {
+ return
+ }
+ inst := g.MobStore.SpawnTransient(def, cfg, roomID, "")
+ if inst == nil {
+ return
+ }
+ inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks))
+ if g.Hub != nil {
+ for _, sess := range g.Hub.PlayersInRoom(roomID) {
+ sess.WriteLine(g.colorize(sess, "broadcast",
+ "\n"+mobDisplayName(inst, true)+" appears!"))
+ }
+ }
+}
+
+func (g *Game) despawnTriggerMobs(mobID string, owner string) {
+ for _, inst := range g.MobStore.AllInstances() {
+ if inst.DefID != mobID {
+ continue
+ }
+ if owner != "" && inst.Owner != owner {
+ continue
+ }
+ if inst.HP <= 0 {
+ g.MobStore.RemoveInstance(inst.InstanceID)
+ continue
+ }
+ if g.Hub != nil {
+ for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) {
+ sess.WriteLine(g.colorize(sess, "broadcast",
+ fmt.Sprintf("\n%s vanishes.", mobDisplayName(inst, true))))
+ }
+ }
+ g.MobStore.RemoveInstance(inst.InstanceID)
+ }
+}
+
+func expandTemplate(tmpl string, playerName string, flagValue any) string {
+ s := tmpl
+ if playerName != "" {
+ s = strings.ReplaceAll(s, "%p", playerName)
+ }
+ if flagValue != nil {
+ s = strings.ReplaceAll(s, "%v", fmt.Sprint(flagValue))
+ }
+ return s
+}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 6aa0a81..197b8d3 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -521,6 +521,74 @@ func validateCourses(s Source) []Issue {
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
+ }
+ for ti, trigger := range room.Triggers {
+ prefix := fmt.Sprintf("Room %d: trigger[%d]", id, ti)
+ for si, step := range trigger.Steps {
+ stepPrefix := fmt.Sprintf("%s: step[%d]", prefix, si)
+ if step.SpawnMob != nil && step.SpawnMob.ID != "" {
+ if _, ok := mobIDs[step.SpawnMob.ID]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: spawn_mob %q does not exist",
+ stepPrefix, step.SpawnMob.ID),
+ })
+ }
+ for _, wr := range step.SpawnMob.DespawnRooms {
+ if _, ok := roomIndex[wr]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: spawn_mob despawn_rooms references nonexistent room %d",
+ stepPrefix, wr),
+ })
+ }
+ }
+ }
+ if step.GiveItem != "" && !itemIDs[step.GiveItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: give_item %q does not exist",
+ stepPrefix, step.GiveItem),
+ })
+ }
+ if step.TakeItem != "" && !itemIDs[step.TakeItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: take_item %q does not exist",
+ stepPrefix, step.TakeItem),
+ })
+ }
+ if step.Teleport > 0 {
+ if _, ok := roomIndex[step.Teleport]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: teleport to nonexistent room %d",
+ stepPrefix, step.Teleport),
+ })
+ }
+ }
+ }
+ }
+ }
+
+ return issues
+}
+
func validateRoomWiring(s Source) []Issue {
var issues []Issue
roomIndex := s.World.RoomIndex()
diff --git a/internal/validate/validate.go b/internal/validate/validate.go
index 5557f72..3918c95 100644
--- a/internal/validate/validate.go
+++ b/internal/validate/validate.go
@@ -63,6 +63,7 @@ func Run(s Source) []Issue {
{"items", "item"}, {"rooms", "room"}, {"mobs", "mob"},
{"objects", "object"}, {"drops", "drop table"}, {"courses", "course"},
{"modules", "module"}, {"techs", "tech"}, {"help", "help topic"},
+ {"triggers", "trigger"},
} {
issues = append(issues, validateIDs(s.DataDir, dir.sub, dir.label)...)
}
@@ -75,6 +76,7 @@ func Run(s Source) []Issue {
issues = append(issues, validateCraftBlocks(s)...)
issues = append(issues, validateDropTables(s)...)
issues = append(issues, validateCourses(s)...)
+ issues = append(issues, validateRoomTriggers(s)...)
issues = append(issues, validateTechs(s)...)
issues = append(issues, validateRoomWiring(s)...)
diff --git a/internal/world/mob.go b/internal/world/mob.go
index f11d6fe..d3ea05b 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -127,6 +127,14 @@ type MobInstance struct {
CompleteMessage string
TalkConfig *behavior.TalkConfig
+
+ Owner string
+ OwnerOnly bool
+ SpawnedByTrigger bool
+ DespawnOnLeave bool
+ DespawnRooms []int
+ DespawnTicks float64
+ DespawnCounter int
}
func (m *MobInstance) IsTalkable() bool { return m.TalkConfig != nil }
@@ -215,6 +223,20 @@ func (m *MobInstance) StartRegen() {
}
}
+func (m *MobInstance) DespawnTickCount() int {
+ return m.DespawnCounter
+}
+
+func (m *MobInstance) DecrementDespawnCounter() {
+ if m.DespawnCounter > 0 {
+ m.DespawnCounter--
+ }
+}
+
+func (m *MobInstance) ResetDespawnCounter(ticks int) {
+ m.DespawnCounter = ticks
+}
+
type MobStore struct {
dataDir string
mu sync.Mutex
@@ -326,6 +348,29 @@ func (s *MobStore) RollIdleDescription(inst *MobInstance) {
inst.WanderTickCounter = 0
}
+func (s *MobStore) SpawnTransient(def *MobDef, cfg *SpawnMobConfig, roomID int, owner string) *MobInstance {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ instID := fmt.Sprintf("trigger_%s_%d_%d", cfg.ID, roomID, len(s.instances))
+ inst := NewMobInstance(def, instID, roomID, nil, 0)
+ inst.Owner = owner
+ inst.OwnerOnly = cfg.OwnerOnly
+ inst.SpawnedByTrigger = true
+ inst.DespawnOnLeave = cfg.DespawnOnLeave
+ inst.DespawnRooms = cfg.DespawnRooms
+ inst.DespawnTicks = cfg.DespawnTicks
+ inst.IdleDescription = pickIdleDescription(def.IdleDescriptions)
+ s.instances[instID] = inst
+ return inst
+}
+
+func (s *MobStore) RemoveInstance(instanceID string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.instances, instanceID)
+}
+
func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) {
type defWrapper struct {
def *MobDef
diff --git a/internal/world/room.go b/internal/world/room.go
index 71a36ed..22e7f79 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -75,6 +75,7 @@ type Room struct {
Mobs []RoomMob `yaml:"mobs"`
OnEnter []EnterStep `yaml:"on_enter"`
Hazard string `yaml:"hazard"`
+ Triggers []TriggerDef `yaml:"triggers"`
}
type RoomMob struct {
diff --git a/internal/world/trigger.go b/internal/world/trigger.go
new file mode 100644
index 0000000..7c06be4
--- /dev/null
+++ b/internal/world/trigger.go
@@ -0,0 +1,61 @@
+package world
+
+import (
+ "gopkg.in/yaml.v3"
+)
+
+type TriggerDef struct {
+ ID string `yaml:"id"`
+ OnPlayerFlag string `yaml:"on_player_flag"`
+ OnFlag string `yaml:"on_flag"`
+ Value any `yaml:"value"`
+ Room int `yaml:"room"`
+ Steps []TriggerStep `yaml:"steps"`
+}
+
+func (t *TriggerDef) IsPlayerFlagTrigger() bool { return t.OnPlayerFlag != "" }
+
+func (t *TriggerDef) IsWorldFlagTrigger() bool { return t.OnFlag != "" }
+
+type TriggerStep struct {
+ Delay int `yaml:"delay"`
+ Message string `yaml:"message"`
+ Broadcast string `yaml:"broadcast"`
+ BroadcastGlobal string `yaml:"broadcast_global"`
+ SetFlags map[string]any `yaml:"set_flags"`
+ SetPlayerFlags map[string]any `yaml:"set_player_flags"`
+ SpawnMob *SpawnMobConfig `yaml:"spawn_mob"`
+ GiveItem string `yaml:"give_item"`
+ TakeItem string `yaml:"take_item"`
+ Teleport int `yaml:"teleport"`
+ Heal int `yaml:"heal"`
+ DespawnMob string `yaml:"despawn_mob"`
+}
+
+type SpawnMobConfig struct {
+ ID string `yaml:"id"`
+ OwnerOnly bool `yaml:"owner_only"`
+ DespawnOnLeave bool `yaml:"despawn_on_leave"`
+ DespawnRooms []int `yaml:"despawn_rooms"`
+ DespawnTicks float64 `yaml:"despawn_ticks"`
+}
+
+func (s *SpawnMobConfig) UnmarshalYAML(value *yaml.Node) error {
+ if value.Kind == yaml.ScalarNode {
+ var id string
+ if err := value.Decode(&id); err != nil {
+ return err
+ }
+ s.ID = id
+ return nil
+ }
+ type raw SpawnMobConfig
+ return value.Decode((*raw)(s))
+}
+
+func (t TriggerStep) IsTimed() bool {
+ return t.Delay > 0 || len(t.SetFlags) > 0 || len(t.SetPlayerFlags) > 0 ||
+ t.Message != "" || t.Broadcast != "" || t.BroadcastGlobal != "" ||
+ t.SpawnMob != nil || t.GiveItem != "" || t.TakeItem != "" ||
+ t.Teleport != 0 || t.Heal != 0 || t.DespawnMob != ""
+}
diff --git a/internal/world/trigger_store.go b/internal/world/trigger_store.go
new file mode 100644
index 0000000..f00d256
--- /dev/null
+++ b/internal/world/trigger_store.go
@@ -0,0 +1,340 @@
+package world
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "gopkg.in/yaml.v3"
+)
+
+type TriggerStore struct {
+ mu sync.Mutex
+
+ globalByPlayerFlag map[string][]*TriggerDef
+ globalByWorldFlag map[string][]*TriggerDef
+ roomByPlayerFlag map[int]map[string][]*TriggerDef
+ roomByWorldFlag map[int]map[string][]*TriggerDef
+
+ playerSeqs map[string]*PlayerTriggerSeq
+ worldSeqs map[string]*WorldTriggerSeq
+}
+
+type PlayerTriggerSeq struct {
+ PlayerName string
+ TriggerID string
+ RoomID int
+ Steps []TriggerStep
+ Wait int
+ Started bool
+ FlagValue any
+}
+
+type WorldTriggerSeq struct {
+ TriggerID string
+ RoomID int
+ Steps []TriggerStep
+ Wait int
+ FlagValue any
+}
+
+func NewTriggerStore() *TriggerStore {
+ return &TriggerStore{
+ globalByPlayerFlag: make(map[string][]*TriggerDef),
+ globalByWorldFlag: make(map[string][]*TriggerDef),
+ roomByPlayerFlag: make(map[int]map[string][]*TriggerDef),
+ roomByWorldFlag: make(map[int]map[string][]*TriggerDef),
+ playerSeqs: make(map[string]*PlayerTriggerSeq),
+ worldSeqs: make(map[string]*WorldTriggerSeq),
+ }
+}
+
+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.IsWorldFlagTrigger() {
+ if ts.roomByWorldFlag[roomID] == nil {
+ ts.roomByWorldFlag[roomID] = make(map[string][]*TriggerDef)
+ }
+ t.Room = roomID
+ ts.roomByWorldFlag[roomID][t.OnFlag] = append(
+ ts.roomByWorldFlag[roomID][t.OnFlag], 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.IsWorldFlagTrigger() {
+ ts.globalByWorldFlag[t.OnFlag] = append(
+ ts.globalByWorldFlag[t.OnFlag], 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) FireWorld(flagName string, flagValue any) []*WorldTriggerSeq {
+ ts.mu.Lock()
+ defer ts.mu.Unlock()
+
+ var started []*WorldTriggerSeq
+
+ for _, t := range ts.globalByWorldFlag[flagName] {
+ if !ts.valueMatches(t.Value, flagValue) {
+ continue
+ }
+ seq := ts.startWorldSeqLocked(t, flagValue)
+ if seq != nil {
+ started = append(started, seq)
+ }
+ }
+
+ for roomID, roomFlags := range ts.roomByWorldFlag {
+ for _, t := range roomFlags[flagName] {
+ if !ts.valueMatches(t.Value, flagValue) {
+ continue
+ }
+ if t.Room == 0 {
+ t.Room = roomID
+ }
+ seq := ts.startWorldSeqLocked(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([]TriggerStep, 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) startWorldSeqLocked(t *TriggerDef, flagValue any) *WorldTriggerSeq {
+ key := t.ID
+ if _, exists := ts.worldSeqs[key]; exists {
+ return nil
+ }
+ seq := &WorldTriggerSeq{
+ TriggerID: t.ID,
+ RoomID: t.Room,
+ Steps: make([]TriggerStep, len(t.Steps)),
+ FlagValue: flagValue,
+ }
+ copy(seq.Steps, t.Steps)
+ if len(seq.Steps) > 0 {
+ seq.Wait = seq.Steps[0].Delay
+ }
+ ts.worldSeqs[key] = seq
+ return seq
+}
+
+func (ts *TriggerStore) valueMatches(want, got any) bool {
+ if want == nil {
+ return true
+ }
+ return 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) SnapshotWorldSeqs() []*WorldTriggerSeq {
+ ts.mu.Lock()
+ defer ts.mu.Unlock()
+ out := make([]*WorldTriggerSeq, 0, len(ts.worldSeqs))
+ for _, s := range ts.worldSeqs {
+ 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) RemoveWorldSeq(triggerID string) {
+ ts.mu.Lock()
+ defer ts.mu.Unlock()
+ delete(ts.worldSeqs, 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.globalByWorldFlag {
+ out = append(out, triggers...)
+ }
+ return out
+}
+
+func valuesEqual(a, b any) bool {
+ ai, aok := numericValue(a)
+ bi, bok := numericValue(b)
+ if aok && bok {
+ return ai == bi
+ }
+ return a == b
+}
+
+func numericValue(v any) (float64, bool) {
+ switch x := v.(type) {
+ case int:
+ return float64(x), true
+ case int64:
+ return float64(x), true
+ case float64:
+ return x, true
+ }
+ return 0, false
+}
+
+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.roomByWorldFlag {
+ for _, triggers := range byFlag {
+ out[roomID] = append(out[roomID], triggers...)
+ }
+ }
+ return out
+}