aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-08 19:59:14 -0400
committerhistoria <[not public]>2026-07-08 19:59:14 -0400
commit09d325dcf5e779eab5550d3fd3377bde50101428 (patch)
treea433be903aabbf1d2eadce2aacdac12a9eb8dde0 /internal/game
parent9184377301c2604e003f36426788c032fb0ca524 (diff)
downloadthehouseoficarus-09d325dcf5e779eab5550d3fd3377bde50101428.tar.gz
feat: unify on use, on look, and on kill. all support same conditions/actions now.
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act_effects.go268
-rw-r--r--internal/game/act_room.go92
-rw-r--r--internal/game/act_talk.go76
-rw-r--r--internal/game/act_use_interaction.go8
-rw-r--r--internal/game/cmd_move.go35
-rw-r--r--internal/game/cmd_room_insert.go6
-rw-r--r--internal/game/cmd_room_remove.go6
-rw-r--r--internal/game/cmd_use.go6
-rw-r--r--internal/game/cmd_verbs.go4
-rw-r--r--internal/game/combat_mob.go5
-rw-r--r--internal/game/look_target.go4
-rw-r--r--internal/game/sys_triggers.go124
12 files changed, 354 insertions, 280 deletions
diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go
new file mode 100644
index 0000000..82d5022
--- /dev/null
+++ b/internal/game/act_effects.go
@@ -0,0 +1,268 @@
+package game
+
+import (
+ "fmt"
+ "strconv"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+// effectScope controls which subset of a StepAction's effects applyStepAction
+// will fire. The three scopes cover the three callers of the unified executor:
+//
+// - scopePlayer: inline triggers (talk nodes, on_use, on_look,
+// on_kill, exit traversal). Receives all
+// player-targeted effects (flags, items, heal, credits,
+// teleport, aps_node). Broadcast is allowed (so on_kill
+// can announce to the room); broadcast_global is not
+// (there's no per-tick sequence owner to author it).
+// - scopePlayerSeq: on_enter steps + trigger player sequences. Same as
+// scopePlayer plus broadcast_global (a sequence can
+// shout to the whole world) and spawn_mob/despawn_mob
+// (player-owned transient mobs).
+// - scopeGlobal: trigger global sequences. No player is involved; only
+// broadcasts, global flag mutations, and world-owned
+// spawn/despawn fire.
+type effectScope int
+
+const (
+ scopePlayer effectScope = iota
+ scopePlayerSeq
+ scopeGlobal
+)
+
+// applyStepAction is the single effect executor used by every conditional
+// interaction and timed step in the game: talk nodes, talk options,
+// on_use/on_look/on_kill interactions, exit traversal, on_enter steps, and
+// room/global trigger sequences. The previous fireEnterStep /
+// executeTriggerStep / executeGlobalTriggerStep / applyNodeAction executors
+// are all thin wrappers over this one.
+//
+// flagValue (may be nil) is substituted into %v in any message/broadcast
+// template; playerName (derived from p when non-nil) is substituted into %p.
+// scopeGlobal ignores p entirely.
+//
+// The fixed effect order (matching the original executors, normalized):
+// 1. message (player/seq scopes — skipped for scopeGlobal)
+// 2. broadcast → all in room (seq/global scopes)
+// 3. broadcast_global → all online (seq/global scopes)
+// 4. set_global_flags (scopeGlobal only; player scopes handle globals in step 5
+// via applyFlagMutations so they aren't set twice)
+// 5. set_player_flags (player/seq scopes; cascades via setPlayerFlag)
+// 6. take_item (player/seq scopes)
+// 7. give_item (player/seq scopes; honors 28-slot inventory limit)
+// 8. heal (player/seq scopes; clamps to MaxHP)
+// 9. credits (player/seq scopes; negative is gated by affordability)
+// 10. spawn_mob (seq/global scopes; player-owned when seq)
+// 11. despawn_mob (seq/global scopes; filtered by owner when seq)
+// 12. teleport (player/seq scopes; re-runs look + on_enter of target)
+// 13. aps_node (player/seq scopes; marks "aps_node_<roomID>" player flag)
+
+// writePlayerMessage expands the message template (player name, flag value),
+// colorizes inline {spec}text{/} tags under the caller's color mode, and writes
+// it to sess. It is the single path used by every per-player message emission
+// in the interaction system (applyStepAction step 1, applyInteraction's
+// Interaction.Message, completeMove's on_traverse Message, and
+// advanceUseInteraction's d.Message) so color tags render consistently.
+func (g *Game) writePlayerMessage(sess *net.Session, msg string, playerName string, flagValue any) {
+ if sess == nil || msg == "" {
+ return
+ }
+ rendered := expandTemplate(msg, playerName, flagValue)
+ seqSpec := g.resolveColor(sess, "sequence")
+ mode := g.colorMode(sess)
+ rendered = color.ExpandTagsDefault(mode, seqSpec, rendered)
+ sess.WriteLine(rendered)
+}
+
+func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) {
+ if step == nil {
+ return
+ }
+
+ var playerName string
+ if p != nil {
+ playerName = p.Name
+ }
+
+ // 1. message — direct to the triggering session. Skipped for scopeGlobal
+ // (no per-player session is the target).
+ if sc != scopeGlobal {
+ g.writePlayerMessage(sess, step.Message, playerName, flagValue)
+ }
+
+ // 2 & 3. broadcast / broadcast_global — re-render color tags per recipient
+ // so each player sees them in their own color mode.
+ broadcastSpec := g.resolveColor(nil, "broadcast")
+ if (sc == scopePlayerSeq || sc == scopeGlobal) && g.Hub != nil {
+ if step.Broadcast != "" {
+ raw := expandTemplate(step.Broadcast, playerName, flagValue)
+ for _, other := range g.Hub.PlayersInRoom(roomID) {
+ otherMode := g.colorMode(other)
+ rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, raw)
+ other.WriteLine(g.colorize(other, "broadcast", rendered))
+ }
+ }
+ if step.BroadcastGlobal != "" {
+ raw := expandTemplate(step.BroadcastGlobal, playerName, flagValue)
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player == nil {
+ continue
+ }
+ otherMode := g.colorMode(other)
+ rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, raw)
+ other.WriteLine(g.colorize(other, "broadcast", rendered))
+ }
+ }
+ }
+
+ // 4. set_global_flags — scopeGlobal has no player, so it sets globals here
+ // (cascades via OnChange). Player scopes handle globals in step 5 alongside
+ // player flags (applyFlagMutations sets both atomically per-scope), avoiding
+ // a redundant second SetAll.
+ if sc == scopeGlobal && len(step.SetGlobalFlags) > 0 {
+ g.GlobalFlags.SetAll(step.SetGlobalFlags)
+ }
+
+ // Global triggers have no player — stop after the world-scoped effects.
+ if sc == scopeGlobal {
+ if step.SpawnMob != nil {
+ g.spawnWorldTriggerMob(step.SpawnMob, roomID)
+ }
+ if step.DespawnMob != "" {
+ g.despawnTriggerMobs(step.DespawnMob, "")
+ }
+ return
+ }
+
+ // Everything below needs a player.
+ if p == nil {
+ return
+ }
+
+ // 5. set_player_flags — cascade via setPlayerFlag (may synchronously re-fire
+ // other player-flag triggers).
+ if len(step.SetGlobalFlags) > 0 || len(step.SetPlayerFlags) > 0 {
+ g.applyFlagMutations(p, step.SetGlobalFlags, step.SetPlayerFlags)
+ g.AccountStore.SaveCharacter(p)
+ }
+
+ // 6. take_item
+ if step.TakeItem != "" {
+ if p.HasItem(step.TakeItem) {
+ p.RemoveItem(step.TakeItem, 1)
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+
+ // 7. give_item
+ if step.GiveItem != "" {
+ slot := p.FirstFreeSlot()
+ if slot == -1 {
+ if sess != nil {
+ 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)
+ }
+ }
+
+ // 8. heal
+ if step.Heal > 0 {
+ p.HP += step.Heal
+ if maxHP := p.MaxHP(); p.HP > maxHP {
+ p.HP = maxHP
+ }
+ g.AccountStore.SaveCharacter(p)
+ if sess != nil {
+ sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal))
+ }
+ }
+
+ // 9. credits (signed; negative is gated)
+ if step.Credits != 0 {
+ if step.Credits < 0 {
+ if p.Credits >= -step.Credits {
+ p.Credits += step.Credits
+ g.AccountStore.SaveCharacter(p)
+ } else if sess != nil {
+ sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -step.Credits, p.Credits))
+ }
+ } else {
+ p.Credits += step.Credits
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+
+ // 10/11. spawn_mob / despawn_mob — sequence-only effects. Inline callers
+ // (scopePlayer) leave these empty; nothing fires.
+ if sc == scopePlayerSeq && step.SpawnMob != nil {
+ g.spawnTriggerMob(sess, p, step.SpawnMob, roomID)
+ }
+ if sc == scopePlayerSeq && step.DespawnMob != "" {
+ g.despawnTriggerMobs(step.DespawnMob, p.Name)
+ }
+
+ // 12. teleport
+ if step.Teleport > 0 && sess != nil {
+ g.teleportPlayer(sess, p, step.Teleport)
+ }
+
+ // 13. aps_node — marks this current room as a discovered APS node on the
+ // player's datapad. Kept as a dedicated effect because the room-id
+ // substitution is dynamic (the player's current room), not expressible
+ // via a static set_player_flags entry.
+ if step.ApsNode {
+ g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true)
+ g.AccountStore.SaveCharacter(p)
+ }
+}
+
+// applyNodeAction is the inline-only convenience wrapper used by talk nodes,
+// talk options, and any historical caller that holds a *NodeAction rather
+// than a *StepAction. It wraps the NodeAction in a StepAction (sequence-only
+// fields empty) and dispatches to applyStepAction with scopePlayer scoped
+// to the player's current room.
+func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
+ p := sess.Player
+ if p == nil || na == nil {
+ return
+ }
+ g.applyStepAction(sess, p, &behavior.StepAction{NodeAction: *na}, p.RoomID, scopePlayer, nil)
+}
+
+// applyInteraction fires the first matching entry in a list of conditional
+// interactions (on_use, on_look, on_kill, exit traversal). Returns true when
+// an entry matched and fired. The action runs at scopePlayer (or scopePlayerSeq
+// if allowSeq is set, for on_kill which may broadcast/spawn). The
+// interaction's Message is written to the player first (if non-empty), then
+// the Action fires via applyStepAction.
+//
+// itemMatch (optional) gates entries that carry an item_id: for on_look it
+// requires the player to have the item in inventory; for on_kill it requires
+// the player to be wielding it. nil means the item_id field is ignored (used
+// by callers without an item filter, e.g. exit traversal). Entries are walked
+// top-to-bottom; the first whose item filter, Condition, and the first-match-
+// wins rule all pass fires.
+func (g *Game) applyInteraction(sess *net.Session, p *player.Player, list []behavior.Interaction, roomID int, allowSeq bool, itemMatch func(string) bool) bool {
+ sc := scopePlayer
+ if allowSeq {
+ sc = scopePlayerSeq
+ }
+ for _, it := range list {
+ if it.Item != "" && itemMatch != nil && !itemMatch(it.Item) {
+ continue
+ }
+ if it.Condition != nil && !g.checkCondition(sess, it.Condition) {
+ continue
+ }
+ g.writePlayerMessage(sess, it.Message, p.Name, nil)
+ g.applyStepAction(sess, p, it.Action, roomID, sc, nil)
+ return true
+ }
+ return false
+} \ No newline at end of file
diff --git a/internal/game/act_room.go b/internal/game/act_room.go
index da74d6e..2b08bfa 100644
--- a/internal/game/act_room.go
+++ b/internal/game/act_room.go
@@ -4,10 +4,9 @@ import (
"fmt"
"strings"
- "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
- "thehouseoficarus/internal/world"
)
// enterSeq is an in-flight on_enter stepped sequence for a single player. It is
@@ -16,13 +15,13 @@ type enterSeq struct {
sess *net.Session
playerName string
roomID int
- steps []world.EnterStep
+ steps []behavior.StepAction
wait int
}
// runEnterSteps evaluates a room's on_enter script for the player. Steps whose
// condition fails are dropped. If none of the surviving steps are "timed"
-// (no delay, no flag mutation) the messages are printed synchronously.
+// (any non-zero effect field) the messages are printed synchronously.
// Otherwise the surviving steps become a scheduled enter sequence driven
// by EnterSeqTick.
func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
@@ -35,7 +34,7 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
return
}
- var steps []world.EnterStep
+ var steps []behavior.StepAction
sequenced := false
for _, step := range room.OnEnter {
if step.Condition != nil && !g.checkCondition(sess, step.Condition) {
@@ -51,6 +50,7 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
}
if !sequenced {
+ // Pure message-only steps — print synchronously, no side effects.
for _, step := range steps {
if step.Message != "" {
sess.WriteLine(step.Message)
@@ -65,7 +65,7 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
// startEnterSeq registers a scheduled on_enter sequence and persists a marker
// on the player so the sequence can be resumed on reconnect if interrupted
// (disconnect, server restart).
-func (g *Game) startEnterSeq(sess *net.Session, p *player.Player, roomID int, steps []world.EnterStep) {
+func (g *Game) startEnterSeq(sess *net.Session, p *player.Player, roomID int, steps []behavior.StepAction) {
g.cancelEnterSeq(p.Name)
if p.EnterSeqRoom != roomID {
p.EnterSeqRoom = roomID
@@ -98,7 +98,7 @@ func (g *Game) EnterSeqTick() {
continue
}
- var jobs []world.EnterStep
+ var jobs []behavior.StepAction
done := false
g.enterMu.Lock()
@@ -123,8 +123,8 @@ func (g *Game) EnterSeqTick() {
}
g.enterMu.Unlock()
- for _, step := range jobs {
- g.fireEnterStep(seq, step)
+ for i := range jobs {
+ g.fireEnterStep(seq, &jobs[i])
}
if done {
g.completeEnterSeq(seq)
@@ -132,71 +132,16 @@ func (g *Game) EnterSeqTick() {
}
}
-func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) {
+// fireEnterStep runs one on_enter step's effects via the unified
+// applyStepAction executor. The room-lock check (player must still be in the
+// room that the sequence is being played for) lives here, before dispatch —
+// applyStepAction itself doesn't re-check it.
+func (g *Game) fireEnterStep(seq *enterSeq, step *behavior.StepAction) {
p := seq.sess.Player
if p == nil || p.RoomID != seq.roomID {
return
}
- seqSpec := g.resolveColor(seq.sess, "sequence")
- broadcastSpec := g.resolveColor(seq.sess, "broadcast")
- mode := g.colorMode(seq.sess)
- if step.Message != "" {
- msg := expandTemplate(step.Message, p.Name, nil)
- msg = color.ExpandTagsDefault(mode, seqSpec, msg)
- seq.sess.WriteLine(msg)
- }
- if step.Broadcast != "" && g.Hub != nil {
- msg := expandTemplate(step.Broadcast, p.Name, nil)
- msg = color.ExpandTagsDefault(mode, broadcastSpec, msg)
- for _, other := range g.Hub.PlayersInRoom(seq.roomID) {
- other.WriteLine(g.colorize(other, "broadcast", msg))
- }
- }
- if step.BroadcastGlobal != "" && g.Hub != nil {
- msg := expandTemplate(step.BroadcastGlobal, p.Name, nil)
- msg = color.ExpandTagsDefault(mode, broadcastSpec, msg)
- for _, other := range g.Hub.AllSessions() {
- if other.Player != nil {
- other.WriteLine(g.colorize(other, "broadcast", msg))
- }
- }
- }
- if len(step.SetGlobalFlags) > 0 || len(step.SetPlayerFlags) > 0 {
- g.applyFlagMutations(p, step.SetGlobalFlags, step.SetPlayerFlags)
- g.AccountStore.SaveCharacter(p)
- }
- if step.SpawnMob != nil {
- g.spawnTriggerMob(seq.sess, p, step.SpawnMob, seq.roomID)
- }
- if step.DespawnMob != "" {
- g.despawnTriggerMobs(step.DespawnMob, p.Name)
- }
- if step.GiveItem != "" {
- slot := p.FirstFreeSlot()
- if slot == -1 {
- seq.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)
- seq.sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal))
- }
- if step.Teleport > 0 {
- g.teleportPlayer(seq.sess, p, step.Teleport)
- }
+ g.applyStepAction(seq.sess, p, step, seq.roomID, scopePlayerSeq, nil)
}
func (g *Game) completeEnterSeq(seq *enterSeq) {
@@ -229,8 +174,11 @@ func (g *Game) isCharLive(name string, sess *net.Session) bool {
return g.loggedInChars[name] == sess
}
-// applyFlagMutations sets global and player flags, shared by on_enter steps and
-// exit traversal.
+// applyFlagMutations sets global and player flags. It is the player-scoped
+// (scopePlayer / scopePlayerSeq) flag step of applyStepAction, called once
+// per non-global step that carries set_global_flags and/or set_player_flags.
+// scopeGlobal sets globals directly in applyStepAction step 4 instead (so
+// world-scoped cascades don't reach back into this helper).
func (g *Game) applyFlagMutations(p *player.Player, setGlobalFlags, setPlayerFlags map[string]any) {
g.GlobalFlags.SetAll(setGlobalFlags)
if len(setPlayerFlags) > 0 {
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index ca6054c..e08fbd2 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -2,7 +2,6 @@ package game
import (
"fmt"
- "strconv"
"strings"
"thehouseoficarus/internal/behavior"
@@ -331,78 +330,3 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
return
}
}
-
-func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
- p := sess.Player
- if p == nil {
- return
- }
-
- if na.ReputationCost > 0 {
- rep := getPlayerFlagInt(p, "assassin_reputation")
- if rep < na.ReputationCost {
- sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep))
- return
- }
- g.setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost)
- g.AccountStore.SaveCharacter(p)
- }
-
- for k, v := range na.SetGlobalFlags {
- g.GlobalFlags.Set(k, v)
- }
- for k, v := range na.SetPlayerFlags {
- g.setPlayerFlag(p, k, v)
- }
- if na.TakeItem != "" {
- if p.HasItem(na.TakeItem) {
- p.RemoveItem(na.TakeItem, 1)
- }
- }
- if na.GiveItem != "" {
- slot := p.FirstFreeSlot()
- if slot == -1 {
- sess.WriteLine("Your inventory is too full to receive that.")
- } else {
- p.SetInvSlot(slot, &player.InventorySlot{ItemID: na.GiveItem, Quantity: 1})
- g.AccountStore.SaveCharacter(p)
- }
- }
- if na.Teleport > 0 {
- p.RoomID = na.Teleport
- p.Stats.RecordRoomVisit(na.Teleport)
- g.AccountStore.SaveCharacter(p)
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
- g.seedRoomObjects(p.RoomID)
- if g.Hub != nil {
- g.Hub.EnterRoom(sess, p.RoomID)
- }
- g.doLook(sess)
- g.runEnterSteps(sess, p.RoomID)
- }
- if na.Heal > 0 {
- p.HP += na.Heal
- if maxHP := p.MaxHP(); p.HP > maxHP {
- p.HP = maxHP
- }
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", na.Heal))
- }
- if na.Credits != 0 {
- if na.Credits < 0 {
- if p.Credits >= -na.Credits {
- p.Credits += na.Credits
- g.AccountStore.SaveCharacter(p)
- } else {
- sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -na.Credits, p.Credits))
- }
- } else {
- p.Credits += na.Credits
- g.AccountStore.SaveCharacter(p)
- }
- }
- if na.ApsNode {
- g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true)
- }
-}
diff --git a/internal/game/act_use_interaction.go b/internal/game/act_use_interaction.go
index b96aaa3..9c64b56 100644
--- a/internal/game/act_use_interaction.go
+++ b/internal/game/act_use_interaction.go
@@ -12,12 +12,8 @@ func (g *Game) advanceUseInteraction(sess *net.Session, p *player.Player) {
g.cancelAction(p)
return
}
- if d.Message != "" {
- sess.WriteLine(d.Message)
- }
- if d.Action != nil {
- g.applyNodeAction(sess, d.Action)
- }
+ g.writePlayerMessage(sess, d.Message, p.Name, nil)
+ g.applyStepAction(sess, p, d.Action, p.RoomID, scopePlayer, nil)
g.broadcastAction(sess, "%s uses the %s.", p.Name, d.ObjName)
g.cancelAction(p)
}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 2f27eed..d05146a 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -4,11 +4,13 @@ import (
"fmt"
"strings"
+ "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/object"
"thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
)
func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
@@ -67,8 +69,11 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
}
}
- p.MovePendingGlobalFlags = exitDef.SetGlobalFlags
- p.MovePendingPlayerFlags = exitDef.SetPlayerFlags
+ // Stash the first on_traverse interaction whose condition passes — it will
+ // fire in completeMove once the player arrives. ExitDef.Condition (above,
+ // evaluated by exitDisplayState) gates whether the exit is passable at
+ // all; each on_traverse entry has its own additional Condition gate.
+ p.MovePendingInteraction = g.matchExitInteraction(sess, p, exitDef)
inCombat := g.Combat.Get(p.Name) != nil
@@ -96,6 +101,21 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
}
}
+// matchExitInteraction returns the first on_traverse Interaction on exitDef
+// whose Condition (if any) passes for this player, or nil if the exit has no
+// on_traverse entries. The matched interaction's Action fires (with its optional
+// Message) once the player arrives in the target room.
+func (g *Game) matchExitInteraction(sess *net.Session, p *player.Player, exitDef world.ExitDef) *behavior.Interaction {
+ for i := range exitDef.OnTraverse {
+ it := &exitDef.OnTraverse[i]
+ if it.Condition != nil && !g.checkCondition(sess, it.Condition) {
+ continue
+ }
+ return it
+ }
+ return nil
+}
+
var gracefulItems = map[string]bool{
"graceful_hat": true,
"graceful_torso": true,
@@ -132,8 +152,7 @@ func (g *Game) moveTicks(p *player.Player, multiplier float64) int {
func (g *Game) completeMove(sess *net.Session, p *player.Player) {
exitDir := p.MoveDirection
targetID := p.MoveTarget
- pendingGlobalFlags := p.MovePendingGlobalFlags
- pendingPlayerFlags := p.MovePendingPlayerFlags
+ pendingInteraction := p.MovePendingInteraction
p.ClearMoveState()
@@ -163,8 +182,12 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) {
if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom {
p.EnterSeqRoom = 0
}
- if len(pendingGlobalFlags) > 0 || len(pendingPlayerFlags) > 0 {
- g.applyFlagMutations(p, pendingGlobalFlags, pendingPlayerFlags)
+ // Fire the on_traverse interaction (if any matched at classification
+ // time) AFTER p.RoomID is set to the new room, so effect fields like
+ // aps_node / teleport operate on the destination as their reference frame.
+ if pendingInteraction != nil {
+ g.writePlayerMessage(sess, pendingInteraction.Message, p.Name, nil)
+ g.applyStepAction(sess, p, pendingInteraction.Action, p.RoomID, scopePlayer, nil)
}
g.AccountStore.SaveCharacter(p)
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index b248e49..15b05ef 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -138,8 +138,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
Room: newID,
Condition: exitDef.Condition,
BlockedMessage: exitDef.BlockedMessage,
- SetGlobalFlags: exitDef.SetGlobalFlags,
- SetPlayerFlags: exitDef.SetPlayerFlags,
+ OnTraverse: exitDef.OnTraverse,
Hidden: exitDef.Hidden,
AlwaysBlocked: exitDef.AlwaysBlocked,
}
@@ -158,8 +157,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
Room: newID,
Condition: targetExit.Condition,
BlockedMessage: targetExit.BlockedMessage,
- SetGlobalFlags: targetExit.SetGlobalFlags,
- SetPlayerFlags: targetExit.SetPlayerFlags,
+ OnTraverse: targetExit.OnTraverse,
Hidden: targetExit.Hidden,
AlwaysBlocked: targetExit.AlwaysBlocked,
}
diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go
index 28433ff..187ec34 100644
--- a/internal/game/cmd_room_remove.go
+++ b/internal/game/cmd_room_remove.go
@@ -171,8 +171,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) {
Room: cID,
Condition: aExit.Condition,
BlockedMessage: aExit.BlockedMessage,
- SetGlobalFlags: aExit.SetGlobalFlags,
- SetPlayerFlags: aExit.SetPlayerFlags,
+ OnTraverse: aExit.OnTraverse,
Hidden: aExit.Hidden,
AlwaysBlocked: aExit.AlwaysBlocked,
}
@@ -191,8 +190,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) {
Room: aID,
Condition: cOppExit.Condition,
BlockedMessage: cOppExit.BlockedMessage,
- SetGlobalFlags: cOppExit.SetGlobalFlags,
- SetPlayerFlags: cOppExit.SetPlayerFlags,
+ OnTraverse: cOppExit.OnTraverse,
Hidden: cOppExit.Hidden,
AlwaysBlocked: cOppExit.AlwaysBlocked,
}
diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go
index 22fc169..322e5fc 100644
--- a/internal/game/cmd_use.go
+++ b/internal/game/cmd_use.go
@@ -91,7 +91,7 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string)
g.startAction(sess, bt, def.Name)
return true
}
- for _, ui := range def.UseInteractions {
+ for _, ui := range def.OnUse {
if ui.Item != "" {
continue
}
@@ -111,7 +111,7 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string)
}
return true
}
- if len(def.UseInteractions) > 0 {
+ if len(def.OnUse) > 0 {
sess.WriteLine(fmt.Sprintf("Use what on the %s?", def.Name))
return true
}
@@ -284,7 +284,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName,
return
}
- for _, ui := range def.UseInteractions {
+ for _, ui := range def.OnUse {
if ui.Item != itemAID {
continue
}
diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go
index e32910a..6c20004 100644
--- a/internal/game/cmd_verbs.go
+++ b/internal/game/cmd_verbs.go
@@ -75,7 +75,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
addUnique(&sectionObjs, &seen, "talk "+name)
}
- for _, ui := range def.UseInteractions {
+ for _, ui := range def.OnUse {
if ui.Item == "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) {
addUnique(&sectionObjs, &seen, "use "+name)
break
@@ -106,7 +106,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
}
}
- for _, ui := range def.UseInteractions {
+ for _, ui := range def.OnUse {
if ui.Item != "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) {
if itemDef, err := g.ItemStore.Load(ui.Item); err == nil {
addUnique(&sectionObjs, &seen, "use "+itemDef.Name+" on "+name)
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index a3fc040..0395049 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -217,6 +217,11 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
g.announceKill(sess, p, mob, isTask)
g.awardKillDrops(sess, p, mob, isTask)
+ // On Kill fires for both combat kills and task-mob completion (draining a
+ // task mob's HP to zero is the "kill" that completes the work). itemMatch
+ // is the "wielding" gate — on_kill entries with an item_id only fire if
+ // the player currently has that item equipped in a weapon-hand slot.
+ g.applyInteraction(sess, p, mob.OnKill, p.RoomID, true, p.IsWielding)
g.scheduleMobRespawn(mob)
g.writePrompt(sess)
}
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 8b0a57c..24a1df5 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -154,8 +154,8 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine(color.ExpandTags(g.colorMode(sess), objDescText))
}
- if def.OnLook != nil {
- g.applyNodeAction(sess, def.OnLook)
+ if len(def.OnLook) > 0 {
+ g.applyInteraction(sess, p, def.OnLook, p.RoomID, false, p.HasItem)
}
if def.Safespot != nil {
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index b542fdd..2e8b877 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
- "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
@@ -82,7 +82,7 @@ func (g *Game) processPlayerTriggerSequences() {
continue
}
- var jobs []world.TriggerStep
+ var jobs []behavior.StepAction
for len(seq.Steps) > 0 && seq.Wait <= 0 {
step := seq.Steps[0]
seq.Steps = seq.Steps[1:]
@@ -92,9 +92,8 @@ func (g *Game) processPlayerTriggerSequences() {
}
}
- for _, step := range jobs {
- s := step
- g.executeTriggerStep(sess, p, &s, seq.RoomID, seq.FlagValue)
+ for i := range jobs {
+ g.executeTriggerStep(sess, p, &jobs[i], seq.RoomID, seq.FlagValue)
}
if len(seq.Steps) == 0 {
@@ -114,7 +113,7 @@ func (g *Game) processGlobalTriggerSequences() {
continue
}
- var jobs []world.TriggerStep
+ var jobs []behavior.StepAction
for len(seq.Steps) > 0 && seq.Wait <= 0 {
step := seq.Steps[0]
seq.Steps = seq.Steps[1:]
@@ -124,8 +123,8 @@ func (g *Game) processGlobalTriggerSequences() {
}
}
- for _, step := range jobs {
- g.executeGlobalTriggerStep(&step, seq.RoomID, seq.FlagValue)
+ for i := range jobs {
+ g.executeGlobalTriggerStep(&jobs[i], seq.RoomID, seq.FlagValue)
}
if len(seq.Steps) == 0 {
@@ -134,106 +133,21 @@ func (g *Game) processGlobalTriggerSequences() {
}
}
-func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *world.TriggerStep, roomID int, flagValue any) {
- seqSpec := g.resolveColor(sess, "sequence")
- broadcastSpec := g.resolveColor(sess, "broadcast")
- playerMode := g.colorMode(sess)
- if step.Message != "" {
- msg := expandTemplate(step.Message, p.Name, flagValue)
- msg = color.ExpandTagsDefault(playerMode, seqSpec, msg)
- sess.WriteLine(msg)
- }
- if step.Broadcast != "" && g.Hub != nil {
- msg := expandTemplate(step.Broadcast, p.Name, flagValue)
- for _, other := range g.Hub.PlayersInRoom(roomID) {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", rendered))
- }
- }
- if step.BroadcastGlobal != "" && g.Hub != nil {
- msg := expandTemplate(step.BroadcastGlobal, p.Name, flagValue)
- for _, other := range g.Hub.AllSessions() {
- if other.Player != nil {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", rendered))
- }
- }
- }
- if len(step.SetGlobalFlags) > 0 {
- g.GlobalFlags.SetAll(step.SetGlobalFlags)
- }
- 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)
+// executeTriggerStep is a thin wrapper over applyStepAction for trigger player
+// sequences (full player-targeted effects + sequence/broadcast/spawn fields).
+func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, flagValue any) {
+ // On every step, evaluate the per-step condition (a trigger can now gate
+ // individual steps via the Condition field inherited from StepAction).
+ if step.Condition != nil && !g.checkCondition(sess, step.Condition) {
+ return
}
+ g.applyStepAction(sess, p, step, roomID, scopePlayerSeq, flagValue)
}
-func (g *Game) executeGlobalTriggerStep(step *world.TriggerStep, roomID int, flagValue any) {
- if step.Broadcast != "" && g.Hub != nil {
- msg := expandTemplate(step.Broadcast, "", flagValue)
- broadcastSpec := g.resolveColor(nil, "broadcast")
- for _, other := range g.Hub.PlayersInRoom(roomID) {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", rendered))
- }
- }
- if step.BroadcastGlobal != "" && g.Hub != nil {
- msg := expandTemplate(step.BroadcastGlobal, "", flagValue)
- broadcastSpec := g.resolveColor(nil, "broadcast")
- for _, other := range g.Hub.AllSessions() {
- if other.Player != nil {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", rendered))
- }
- }
- }
- if len(step.SetGlobalFlags) > 0 {
- g.GlobalFlags.SetAll(step.SetGlobalFlags)
- }
- if step.SpawnMob != nil {
- g.spawnWorldTriggerMob(step.SpawnMob, roomID)
- }
- if step.DespawnMob != "" {
- g.despawnTriggerMobs(step.DespawnMob, "")
- }
+// executeGlobalTriggerStep is a thin wrapper over applyStepAction for global
+// trigger sequences (no player; only broadcasts/global-flags/spawn/despawn).
+func (g *Game) executeGlobalTriggerStep(step *behavior.StepAction, roomID int, flagValue any) {
+ g.applyStepAction(nil, nil, step, roomID, scopeGlobal, flagValue)
}
func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *world.SpawnMobConfig, roomID int) {