diff options
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 +} |
