aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act.go47
-rw-r--r--internal/game/act_agility.go2
-rw-r--r--internal/game/act_effects.go203
-rw-r--r--internal/game/act_interaction_seq.go97
-rw-r--r--internal/game/act_interaction_seq_test.go216
-rw-r--r--internal/game/act_room.go178
-rw-r--r--internal/game/act_state.go2
-rw-r--r--internal/game/act_steal.go6
-rw-r--r--internal/game/act_talk.go4
-rw-r--r--internal/game/cmd_dig.go10
-rw-r--r--internal/game/cmd_goto.go5
-rw-r--r--internal/game/cmd_inspect.go26
-rw-r--r--internal/game/cmd_move.go45
-rw-r--r--internal/game/cmd_registry.go21
-rw-r--r--internal/game/cmd_reload.go6
-rw-r--r--internal/game/cmd_room_insert.go5
-rw-r--r--internal/game/cmd_room_remove_test.go9
-rw-r--r--internal/game/cmd_summon.go5
-rw-r--r--internal/game/cmd_use.go30
-rw-r--r--internal/game/cmd_verbs.go6
-rw-r--r--internal/game/combat_mob.go2
-rw-r--r--internal/game/combat_mob_test.go2
-rw-r--r--internal/game/core_course.go2
-rw-r--r--internal/game/core_flags.go12
-rw-r--r--internal/game/core_login_char.go17
-rw-r--r--internal/game/exit_migrate.go6
-rw-r--r--internal/game/exit_migrate_test.go8
-rw-r--r--internal/game/game.go33
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/sys_triggers.go608
30 files changed, 779 insertions, 836 deletions
diff --git a/internal/game/act.go b/internal/game/act.go
index 1fc2b1c..352f235 100644
--- a/internal/game/act.go
+++ b/internal/game/act.go
@@ -235,8 +235,6 @@ func (g *Game) AdvanceActions() {
g.advanceTalk(sess, p)
case behavior.TypeTriggerModule:
g.advanceTriggerModule(sess, p)
- case behavior.TypeInteractionSeq:
- g.advanceInteractionSeq(sess, p)
case behavior.TypeCombine:
g.advanceCombine(sess, p)
default:
@@ -302,6 +300,13 @@ func (g *Game) checkCondition(sess *net.Session, c *behavior.Condition) bool {
val, present := g.GlobalFlags.Get(c.GlobalFlag)
return flagMatches(present, val, c.Value, c.Not)
}
+ if c.Room != 0 {
+ has := p != nil && p.RoomID == c.Room
+ if c.Not {
+ return !has
+ }
+ return has
+ }
if c.HasItem != "" {
has := p != nil && p.HasItem(c.HasItem)
if c.Not {
@@ -319,6 +324,44 @@ func (g *Game) checkCondition(sess *net.Session, c *behavior.Condition) bool {
return true
}
+// checkConditionGlobal evaluates a condition without a player session.
+// Only global_flag predicates (plus all_of/any_of/not composition) are evaluated.
+// Room, player_flag, has_item, and min_credits are player-dependent: Room passes
+// (it is used as a reference-room selector, not a gate), and the rest fail.
+func (g *Game) checkConditionGlobal(c *behavior.Condition) bool {
+ if c.AllOf != nil {
+ for _, sub := range c.AllOf {
+ if !g.checkConditionGlobal(&sub) {
+ return false
+ }
+ }
+ return true
+ }
+ if c.AnyOf != nil {
+ for _, sub := range c.AnyOf {
+ if g.checkConditionGlobal(&sub) {
+ return true
+ }
+ }
+ return false
+ }
+
+ if c.GlobalFlag != "" {
+ val, present := g.GlobalFlags.Get(c.GlobalFlag)
+ return flagMatches(present, val, c.Value, c.Not)
+ }
+ if c.PlayerFlag != "" {
+ return c.Not
+ }
+ if c.HasItem != "" {
+ return c.Not
+ }
+ if c.MinCredits > 0 {
+ return c.Not
+ }
+ return true
+}
+
// flagMatches evaluates a global_flag/player_flag condition. The name is
// historical; it evaluates both flag types.
//
diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go
index a243b44..5fbff48 100644
--- a/internal/game/act_agility.go
+++ b/internal/game/act_agility.go
@@ -199,4 +199,4 @@ func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) *float64 {
chance = 0.60
}
return &chance
-} \ No newline at end of file
+}
diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go
index 9fce264..cb60c5d 100644
--- a/internal/game/act_effects.go
+++ b/internal/game/act_effects.go
@@ -10,74 +10,47 @@ import (
"thehouseoficarus/internal/player"
)
-// effectScope controls which subset of a StepAction's effects applyStepAction
-// will fire. The three scopes cover the three callers of the unified executor:
+// effectScope controls which subset of a Step's effects applyStep will fire.
//
-// - scopePlayer: inline triggers (talk nodes, on_use, on_look,
-// on_kill, exit traversal). Receives all
-// player-targeted effects (flags, items, heal, credits,
-// teleport, aps_node). Broadcast is allowed (so on_kill
-// can announce to the room); broadcast_global is not
-// (there's no per-tick sequence owner to author it).
-// - scopePlayerSeq: on_enter steps + trigger player sequences. Same as
-// scopePlayer plus broadcast_global (a sequence can
-// shout to the whole world) and spawn_mob/despawn_mob
-// (player-owned transient mobs).
-// - scopeGlobal: trigger global sequences. No player is involved; only
-// broadcasts, global flag mutations, and world-owned
-// spawn/despawn fire.
+// - scopePlayer: every player-bound trigger (on_use, on_look, on_kill,
+// on_enter, on_exit, on_traverse, on_flag_change). Receives
+// the full effect vocabulary: messages, broadcast,
+// broadcast_global, flags, items, heal, credits, mob
+// spawn/despawn (player-owned), teleport, aps_node.
+// - scopeGlobal: global-flag triggers (on_global_flag_change) with no
+// originating player. Only broadcasts, global flag
+// mutations, and world-owned mob spawn/despawn fire.
type effectScope int
const (
scopePlayer effectScope = iota
- scopePlayerSeq
scopeGlobal
)
-// applyStepAction is the single effect executor used by every conditional
-// interaction and timed step in the game: talk nodes, talk options,
-// on_use/on_look/on_kill interactions, exit traversal, on_enter steps, and
-// room/global trigger sequences. The previous fireEnterStep /
-// executeTriggerStep / executeGlobalTriggerStep / applyNodeAction executors
-// are all thin wrappers over this one.
+// applyStep is the single effect executor used by every trigger in the game:
+// talk nodes/options, on_use/on_look/on_kill/on_enter/on_exit/on_traverse
+// triggers, and on_flag_change/on_global_flag_change sequences. scopeGlobal
+// ignores the player entirely.
//
// flagValue (may be nil) is substituted into %v in any message/broadcast
-// template; playerName (derived from p when non-nil) is substituted into %p.
-// scopeGlobal ignores p entirely.
+// template; the player's name is substituted into %p.
//
-// The fixed effect order (matching the original executors, normalized):
-// 1. messages (player/seq scopes — skipped for scopeGlobal)
-// 2. broadcast → all in room (seq/global scopes)
-// 3. broadcast_global → all online (seq/global scopes)
-// 4. set_global_flags (scopeGlobal only; player scopes handle globals in step 5
-// via applyFlagMutations so they aren't set twice)
-// 5. set_player_flags (player/seq scopes; cascades via setPlayerFlag)
-// 6. take_item (player/seq scopes)
-// 7. give_item (player/seq scopes; honors 28-slot inventory limit)
-// 8. heal (player/seq scopes; clamps to MaxHP)
-// 9. credits (player/seq scopes; negative is gated by affordability)
-// 10. spawn_mob (seq/global scopes; player-owned when seq)
-// 11. despawn_mob (seq/global scopes; filtered by owner when seq)
-// 12. teleport (player/seq scopes; re-runs look + on_enter of target)
-// 13. aps_node (player/seq scopes; marks "aps_node_<roomID>" player flag)
-
-// writePlayerMessage expands the message template (player name, flag value),
-// colorizes inline {spec}text{/} tags under the caller's color mode, and writes
-// it to sess. It is the single path used by every per-player message emission
-// in the interaction system (applyStepAction step 1, the interaction sequencer,
-// and the advance handlers) so color tags render consistently.
-func (g *Game) writePlayerMessage(sess *net.Session, msg string, playerName string, flagValue any) {
- if sess == nil || msg == "" {
- return
- }
- rendered := expandTemplate(msg, playerName, flagValue)
- seqSpec := g.resolveColor(sess, "sequence")
- mode := g.colorMode(sess)
- rendered = color.ExpandTagsDefault(mode, seqSpec, rendered)
- sess.WriteLine(rendered)
-}
-
-func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) {
+// The fixed effect order:
+// 1. messages (player scope — skipped for scopeGlobal)
+// 2. broadcast -> all in room (both scopes)
+// 3. broadcast_global -> all online (both scopes)
+// 4. set_global_flags (scopeGlobal sets here; player scope folds globals
+// into step 5 via applyFlagMutations so they aren't set twice)
+// 5. set_player_flags (player scope; cascades via setPlayerFlag)
+// 6. take_item (player scope)
+// 7. give_item (player scope; honors 28-slot inventory limit)
+// 8. heal (player scope; clamps to MaxHP)
+// 9. credits (player scope; negative is gated by affordability)
+// 10. spawn_mob (player scope: player-owned; global scope: world-owned)
+// 11. despawn_mob (player scope: owner-filtered; global scope: all)
+// 12. teleport (player scope; re-runs look + on_enter of target)
+// 13. aps_node (player scope; marks "aps_node_<roomID>" player flag)
+func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Step, roomID int, sc effectScope, flagValue any) {
if step == nil {
return
}
@@ -87,20 +60,16 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi
playerName = p.Name
}
- // 1. messages — direct to the triggering session. Skipped for scopeGlobal
- // (no per-player session is the target). All Messages in the step emit in
- // order; per-message delays are ignored here (they're honored by the
- // interaction sequencer or handled as inter-step delays by on_enter/triggers).
+ // 1. messages — direct to the triggering session.
if sc != scopeGlobal {
for _, msg := range step.Messages {
- g.writePlayerMessage(sess, msg.Message, playerName, flagValue)
+ g.writePlayerMessage(sess, msg, playerName, flagValue)
}
}
- // 2 & 3. broadcast / broadcast_global — re-render color tags per recipient
- // so each player sees them in their own color mode.
- broadcastSpec := g.resolveColor(nil, "broadcast")
- if (sc == scopePlayerSeq || sc == scopeGlobal) && g.Hub != nil {
+ // 2 & 3. broadcast / broadcast_global — re-render color tags per recipient.
+ if g.Hub != nil && (step.Broadcast != "" || step.BroadcastGlobal != "") {
+ broadcastSpec := g.resolveColor(nil, "broadcast")
if step.Broadcast != "" {
raw := expandTemplate(step.Broadcast, playerName, flagValue)
for _, other := range g.Hub.PlayersInRoom(roomID) {
@@ -122,16 +91,13 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi
}
}
- // 4. set_global_flags — scopeGlobal has no player, so it sets globals here
- // (cascades via OnChange). Player scopes handle globals in step 5 alongside
- // player flags (applyFlagMutations sets both atomically per-scope), avoiding
- // a redundant second SetAll.
+ // 4. set_global_flags — scopeGlobal has no player, set globals here.
if sc == scopeGlobal && len(step.SetGlobalFlags) > 0 {
g.GlobalFlags.SetAll(step.SetGlobalFlags)
}
- // Global triggers have no player — stop after the world-scoped effects.
if sc == scopeGlobal {
+ // 10/11. world-owned spawn/despawn.
if step.SpawnMob != nil {
g.spawnWorldTriggerMob(step.SpawnMob, roomID)
}
@@ -141,13 +107,11 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi
return
}
- // Everything below needs a player.
if p == nil {
return
}
- // 5. set_player_flags — cascade via setPlayerFlag (may synchronously re-fire
- // other player-flag triggers).
+ // 5. set_player_flags (and globals, folded in so the SetAll happens once).
if len(step.SetGlobalFlags) > 0 || len(step.SetPlayerFlags) > 0 {
g.applyFlagMutations(p, step.SetGlobalFlags, step.SetPlayerFlags)
g.AccountStore.SaveCharacter(p)
@@ -186,7 +150,7 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi
}
}
- // 9. credits (signed; negative is gated)
+ // 9. credits (signed; negative is gated).
if step.Credits != 0 {
if step.Credits < 0 {
if p.Credits >= -step.Credits {
@@ -201,12 +165,11 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi
}
}
- // 10/11. spawn_mob / despawn_mob — sequence-only effects. Inline callers
- // (scopePlayer) leave these empty; nothing fires.
- if sc == scopePlayerSeq && step.SpawnMob != nil {
+ // 10/11. spawn_mob / despawn_mob (player-owned / owner-filtered).
+ if step.SpawnMob != nil {
g.spawnTriggerMob(sess, p, step.SpawnMob, roomID)
}
- if sc == scopePlayerSeq && step.DespawnMob != "" {
+ if step.DespawnMob != "" {
g.despawnTriggerMobs(step.DespawnMob, p.Name)
}
@@ -215,68 +178,58 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi
g.teleportPlayer(sess, p, step.Teleport)
}
- // 13. aps_node — marks this current room as a discovered APS node on the
- // player's datapad. Kept as a dedicated effect because the room-id
- // substitution is dynamic (the player's current room), not expressible
- // via a static set_player_flags entry.
+ // 13. aps_node — dynamic room-id flag, kept as a dedicated effect.
if step.ApsNode {
g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true)
g.AccountStore.SaveCharacter(p)
}
}
-// applyNodeAction is the inline-only convenience wrapper used by talk nodes,
-// talk options, and any historical caller that holds a *NodeAction rather
-// than a *StepAction. It wraps the NodeAction in a StepAction (sequence-only
-// fields empty) and dispatches to applyStepAction with scopePlayer scoped
-// to the player's current room.
-func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
- p := sess.Player
- if p == nil || na == nil {
+// writePlayerMessage expands the message template (player name, flag value),
+// colorizes inline {spec}text{/} tags under the caller's color mode, and writes
+// it to sess. It is the single path used by every per-player message emission
+// in the trigger system.
+func (g *Game) writePlayerMessage(sess *net.Session, msg string, playerName string, flagValue any) {
+ if sess == nil || msg == "" {
return
}
- g.applyStepAction(sess, p, &behavior.StepAction{NodeAction: *na}, p.RoomID, scopePlayer, nil)
+ rendered := expandTemplate(msg, playerName, flagValue)
+ seqSpec := g.resolveColor(sess, "sequence")
+ mode := g.colorMode(sess)
+ rendered = color.ExpandTagsDefault(mode, seqSpec, rendered)
+ sess.WriteLine(rendered)
}
-// applyInteraction fires the first matching entry in a list of conditional
-// interactions (on_use, on_look, on_kill, exit traversal). Returns true when
-// an entry matched and fired. The action runs at scopePlayer (or scopePlayerSeq
-// if allowSeq is set, for on_kill which may broadcast/spawn).
-//
-// itemMatch (optional) gates entries that carry an item_id: for on_look it
-// requires the player to have the item in inventory; for on_kill it requires
-// the player to be wielding it. nil means the item_id field is ignored (used
-// by callers without an item filter, e.g. exit traversal). Entries are walked
-// top-to-bottom; the first whose item filter, Condition, and the first-match-
-// wins rule all pass fires.
-func (g *Game) applyInteraction(sess *net.Session, p *player.Player, list []behavior.Interaction, roomID int, allowSeq bool, itemMatch func(string) bool) bool {
- sc := scopePlayer
- if allowSeq {
- sc = scopePlayerSeq
+// applyTalkStep fires a talk node/option's Action (a single Step)
+// synchronously at scopePlayer, scoped to the player's current room.
+func (g *Game) applyTalkStep(sess *net.Session, step *behavior.Step) {
+ p := sess.Player
+ if p == nil || step == nil {
+ return
}
- for _, it := range list {
- if it.Item != "" && itemMatch != nil && !itemMatch(it.Item) {
+ g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil)
+}
+
+// runTrigger runs the first matching entry in a trigger block. itemMatch
+// (optional) gates entries that carry an ItemID: for on_look it requires the
+// player to hold the item; for on_kill it requires wielding it. nil means the
+// ItemID field is ignored (on_enter/on_exit/on_traverse/on_flag_change). The
+// first entry whose item filter and Condition pass wins; its Steps run as a
+// scripted sequence. roomLocked controls the per-step room-lock: when true,
+// steps are skipped if the player has left roomID (used by on_enter so a
+// cutscene doesn't play into the wrong room). Returns true if a trigger
+// matched and fired.
+func (g *Game) runTrigger(sess *net.Session, p *player.Player, list []behavior.Trigger, roomID int, itemMatch func(string) bool, roomLocked bool) bool {
+ for i := range list {
+ t := &list[i]
+ if t.ItemID != "" && itemMatch != nil && !itemMatch(t.ItemID) {
continue
}
- if it.Condition != nil && !g.checkCondition(sess, it.Condition) {
+ if t.Condition != nil && !g.checkCondition(sess, t.Condition) {
continue
}
- g.runInteraction(sess, p, it.Action, roomID, sc, nil)
+ g.startSequence(sess, p, t.Steps, roomID, scopePlayer, nil, t.Lock, roomLocked, verbSeqKey(p, t.ItemID))
return true
}
return false
}
-
-// runInteraction fires a single interaction's Action. If the action carries
-// delayed Messages the player is locked for the duration (see the interaction
-// sequencer); otherwise the effects apply synchronously.
-func (g *Game) runInteraction(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) {
- if step == nil {
- return
- }
- if len(step.Messages) > 0 {
- g.startInteractionSeq(sess, p, step, roomID, sc, flagValue)
- return
- }
- g.applyStepAction(sess, p, step, roomID, sc, flagValue)
-} \ No newline at end of file
diff --git a/internal/game/act_interaction_seq.go b/internal/game/act_interaction_seq.go
deleted file mode 100644
index 8b515c4..0000000
--- a/internal/game/act_interaction_seq.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package game
-
-import (
- "thehouseoficarus/internal/behavior"
- "thehouseoficarus/internal/net"
- "thehouseoficarus/internal/player"
-)
-
-// interactionSeqData is the payload carried by p.Action.Data for an in-flight
-// delayed-message interaction sequence (on_use, on_look, on_kill, or
-// on_traverse). Messages are emitted one-by-one with per-message delays; the
-// trailing StepAction effects fire only after every message has been shown. If
-// the player is interrupted (move, quit, new action, combat) cancelAction
-// clears the pending Action and the trailing effects never run.
-type interactionSeqData struct {
- Msgs []behavior.DelayedMessage
- Idx int
- Step *behavior.StepAction
- RoomID int
- Scope effectScope
- FlagVal any
-}
-
-// startInteractionSeq fires the first delayed message (on the next tick,
-// matching the trigger scheduler's "delay = ticks to wait before this step
-// fires" rule with a floor of 1 so the first message ALWAYS arrives next tick
-// even when delay is 0). The trailing non-message effects run once the last
-// message has been emitted.
-func (g *Game) startInteractionSeq(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) {
- g.cancelAction(p)
-
- firstDelay := 0
- if len(step.Messages) > 0 {
- firstDelay = step.Messages[0].Delay
- }
- if firstDelay < 1 {
- firstDelay = 1
- }
-
- // Strip Messages from step before stashing — applyStepAction would otherwise
- // re-emit them when the trailing effects run.
- clone := *step
- clone.Messages = nil
-
- p.Action = &behavior.Action{
- Type: behavior.TypeInteractionSeq,
- WaitLeft: firstDelay,
- Data: &interactionSeqData{
- Msgs: step.Messages,
- Idx: 0,
- Step: &clone,
- RoomID: roomID,
- Scope: sc,
- FlagVal: flagValue,
- },
- }
-}
-
-// advanceInteractionSeq is the per-tick handler for TypeInteractionSeq
-// actions. Each invocation drains any zero-delay messages that are ready, emits
-// the current delayed message, then sets the wait for the next message (if any).
-// After the last message the trailing effects fire and the action is cancelled.
-func (g *Game) advanceInteractionSeq(sess *net.Session, p *player.Player) {
- d, ok := p.Action.Data.(*interactionSeqData)
- if !ok {
- g.cancelAction(p)
- return
- }
-
- var playerName string
- if p != nil {
- playerName = p.Name
- }
-
- // Drain zero-delay messages that have accumulated, then emit the current
- // (delayed) one. Matches the trigger scheduler's drain-zero-delay pattern.
- for d.Idx < len(d.Msgs) {
- msg := d.Msgs[d.Idx]
- d.Idx++
- g.writePlayerMessage(sess, msg.Message, playerName, d.FlagVal)
-
- if d.Idx >= len(d.Msgs) {
- break
- }
- next := d.Msgs[d.Idx].Delay
- if next > 0 {
- p.Action.WaitLeft = next
- return
- }
- }
-
- // All messages emitted — fire trailing effects.
- if d.Step != nil {
- g.applyStepAction(sess, p, d.Step, d.RoomID, d.Scope, d.FlagVal)
- }
- g.cancelAction(p)
-}
diff --git a/internal/game/act_interaction_seq_test.go b/internal/game/act_interaction_seq_test.go
deleted file mode 100644
index c3fc8fa..0000000
--- a/internal/game/act_interaction_seq_test.go
+++ /dev/null
@@ -1,216 +0,0 @@
-package game
-
-import (
- "io"
- "testing"
-
- "thehouseoficarus/internal/behavior"
- "thehouseoficarus/internal/combat"
- "thehouseoficarus/internal/net"
- "thehouseoficarus/internal/player"
-)
-
-type nopConn struct{}
-
-func (nopConn) ReadMessage() (string, error) { return "", io.EOF }
-func (nopConn) Write(p []byte) (int, error) { return len(p), nil }
-func (nopConn) Close() error { return nil }
-func (nopConn) SetEcho(bool) error { return nil }
-
-func newTestGame() *Game {
- return &Game{
- GlobalFlags: NewGlobalFlagStore(),
- Combat: combat.NewTracker(),
- }
-}
-
-func newTestGameWithStore(t *testing.T) *Game {
- g := newTestGame()
- g.AccountStore = player.NewAccountStore(t.TempDir())
- return g
-}
-
-func newTestSession(p *player.Player, acc *player.Account) *net.Session {
- if p.Options == nil {
- p.Options = make(map[string]any)
- }
- return &net.Session{Player: p, Conn: nopConn{}, State: net.StateGame, Account: acc}
-}
-
-func newTestPlayer(name string) *player.Player {
- return &player.Player{
- Name: name,
- RoomID: 100,
- Skills: map[player.SkillName]int{player.Hitpoints: player.XPForLevel(99)},
- HP: 50,
- Options: make(map[string]any),
- }
-}
-
-func TestRunInteractionNoMessagesAppliesInstantly(t *testing.T) {
- g := newTestGameWithStore(t)
- p := newTestPlayer("test")
- sess := newTestSession(p, nil)
-
- step := &behavior.StepAction{
- NodeAction: behavior.NodeAction{Heal: 10},
- }
-
- g.runInteraction(sess, p, step, 100, scopePlayer, nil)
-
- if p.Action != nil {
- t.Error("expected no pending action for message-less interaction")
- }
- if p.HP != 60 {
- t.Errorf("expected HP 60 after heal, got %d", p.HP)
- }
-}
-
-func TestRunInteractionWithMessagesStartsSequence(t *testing.T) {
- g := newTestGame()
- p := newTestPlayer("test")
- p.Credits = 100
- sess := newTestSession(p, nil)
-
- step := &behavior.StepAction{
- NodeAction: behavior.NodeAction{Credits: 50},
- Messages: []behavior.DelayedMessage{
- {Message: "You feel a strange energy.", Delay: 3},
- },
- }
-
- g.runInteraction(sess, p, step, 100, scopePlayer, nil)
-
- if p.Action == nil {
- t.Fatal("expected pending action for message-bearing interaction")
- }
- if p.Action.Type != behavior.TypeInteractionSeq {
- t.Errorf("expected TypeInteractionSeq, got %v", p.Action.Type)
- }
- if p.Action.WaitLeft != 3 {
- t.Errorf("expected WaitLeft 3, got %d", p.Action.WaitLeft)
- }
- if p.Credits != 100 {
- t.Errorf("expected credits unchanged (100), got %d", p.Credits)
- }
-}
-
-func TestAdvanceInteractionSeqDrainsZeroDelay(t *testing.T) {
- g := newTestGameWithStore(t)
- p := newTestPlayer("test")
- sess := newTestSession(p, nil)
-
- step := &behavior.StepAction{
- NodeAction: behavior.NodeAction{Heal: 10},
- Messages: []behavior.DelayedMessage{
- {Message: "First.", Delay: 0},
- {Message: "Second.", Delay: 0},
- {Message: "Third.", Delay: 0},
- },
- }
-
- g.startInteractionSeq(sess, p, step, 100, scopePlayer, nil)
-
- if p.Action == nil || p.Action.WaitLeft != 1 {
- t.Fatalf("expected WaitLeft 1 (delay 0 floored), got Action:%v", p.Action)
- }
-
- g.advanceInteractionSeq(sess, p)
-
- if p.Action != nil {
- t.Error("expected action cancelled after draining all messages")
- }
- if p.HP != 60 {
- t.Errorf("expected HP 60 after heal, got %d", p.HP)
- }
-}
-
-func TestAdvanceInteractionSeqHonorsDelay(t *testing.T) {
- g := newTestGameWithStore(t)
- p := newTestPlayer("test")
- sess := newTestSession(p, nil)
-
- step := &behavior.StepAction{
- NodeAction: behavior.NodeAction{Heal: 10},
- Messages: []behavior.DelayedMessage{
- {Message: "First.", Delay: 2},
- {Message: "Second.", Delay: 3},
- },
- }
-
- g.startInteractionSeq(sess, p, step, 100, scopePlayer, nil)
-
- if p.Action == nil || p.Action.WaitLeft != 2 {
- t.Fatalf("expected WaitLeft 2, got %v", p.Action)
- }
- p.Action.WaitLeft = 1
-
- g.advanceInteractionSeq(sess, p)
-
- if p.Action == nil {
- t.Fatal("expected action still running (second message pending)")
- }
- if p.Action.WaitLeft != 3 {
- t.Errorf("expected WaitLeft 3 (second message delay), got %d", p.Action.WaitLeft)
- }
- if p.HP != 50 {
- t.Error("heal should not have fired yet")
- }
-
- g.advanceInteractionSeq(sess, p)
-
- if p.Action != nil {
- t.Error("expected action cancelled after last message + heal")
- }
- if p.HP != 60 {
- t.Errorf("expected HP 60 after heal, got %d", p.HP)
- }
-}
-
-func TestInteractionSeqCancelPreventsTrailingEffects(t *testing.T) {
- g := newTestGame()
- p := newTestPlayer("test")
- sess := newTestSession(p, nil)
-
- step := &behavior.StepAction{
- NodeAction: behavior.NodeAction{Heal: 10},
- Messages: []behavior.DelayedMessage{
- {Message: "Hello.", Delay: 5},
- },
- }
-
- g.startInteractionSeq(sess, p, step, 100, scopePlayer, nil)
-
- if p.Action == nil {
- t.Fatal("expected pending action")
- }
-
- g.cancelAction(p)
-
- if p.Action != nil {
- t.Error("expected action to be cancelled")
- }
- if p.HP != 50 {
- t.Error("heal should not have fired after cancel")
- }
-}
-
-func TestApplyStepActionEmitsAllMessages(t *testing.T) {
- g := newTestGameWithStore(t)
- p := newTestPlayer("test")
- sess := newTestSession(p, nil)
-
- step := &behavior.StepAction{
- NodeAction: behavior.NodeAction{Heal: 10},
- Messages: []behavior.DelayedMessage{
- {Message: "A.", Delay: 99},
- {Message: "B.", Delay: 99},
- },
- }
-
- g.applyStepAction(sess, p, step, 100, scopePlayer, nil)
-
- if p.HP != 60 {
- t.Errorf("expected HP 60 after heal, got %d", p.HP)
- }
-}
diff --git a/internal/game/act_room.go b/internal/game/act_room.go
index a6acc21..889ce85 100644
--- a/internal/game/act_room.go
+++ b/internal/game/act_room.go
@@ -4,26 +4,13 @@ import (
"fmt"
"strings"
- "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
)
-// enterSeq is an in-flight on_enter stepped sequence for a single player. It is
-// driven one step at a time by EnterSeqTick.
-type enterSeq struct {
- sess *net.Session
- playerName string
- roomID int
- steps []behavior.StepAction
- wait int
-}
-
-// runEnterSteps evaluates a room's on_enter script for the player. Steps whose
-// condition fails are dropped. If none of the surviving steps are "timed"
-// (any non-zero effect field) the messages are printed synchronously.
-// Otherwise the surviving steps become a scheduled enter sequence driven
-// by EnterSeqTick.
+// runEnterSteps evaluates a room's on_enter block for the player. The first
+// trigger whose Condition passes wins; its Steps run as a scripted sequence
+// (room-locked, so steps are skipped if the player immediately leaves).
func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
room, err := g.World.LoadRoom(roomID)
if err != nil || len(room.OnEnter) == 0 {
@@ -33,154 +20,29 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
if p == nil {
return
}
-
- var steps []behavior.StepAction
- sequenced := false
- for _, step := range room.OnEnter {
- if step.Condition != nil && !g.checkCondition(sess, step.Condition) {
- continue
- }
- steps = append(steps, step)
- if step.IsTimed() {
- sequenced = true
- }
- }
- if len(steps) == 0 {
- return
- }
-
- if !sequenced {
- // Pure message-only steps — print synchronously, no side effects.
- for _, step := range steps {
- for _, msg := range step.Messages {
- if msg.Message != "" {
- sess.WriteLine(msg.Message)
- }
- }
- }
- return
- }
-
- g.startEnterSeq(sess, p, roomID, steps)
+ g.runTrigger(sess, p, room.OnEnter, roomID, nil, true)
}
-// startEnterSeq registers a scheduled on_enter sequence and persists a marker
-// on the player so the sequence can be resumed on reconnect if interrupted
-// (disconnect, server restart).
-func (g *Game) startEnterSeq(sess *net.Session, p *player.Player, roomID int, steps []behavior.StepAction) {
- g.cancelEnterSeq(p.Name)
- if p.EnterSeqRoom != roomID {
- p.EnterSeqRoom = roomID
- g.AccountStore.SaveCharacter(p)
- }
- g.enterMu.Lock()
- g.enterSeqs[p.Name] = &enterSeq{
- sess: sess,
- playerName: p.Name,
- roomID: roomID,
- steps: steps,
- wait: steps[0].Delay,
- }
- g.enterMu.Unlock()
-}
-
-// EnterSeqTick advances every in-flight enter sequence by one tick. Sequences
-// for a player who is no longer connected are paused (left for resume);
-// sequences are removed from the map on completion or cancellation.
-func (g *Game) EnterSeqTick() {
- g.enterMu.Lock()
- seqs := make([]*enterSeq, 0, len(g.enterSeqs))
- for _, s := range g.enterSeqs {
- seqs = append(seqs, s)
- }
- g.enterMu.Unlock()
-
- for _, seq := range seqs {
- if !g.isCharLive(seq.playerName, seq.sess) {
- continue
- }
-
- var jobs []behavior.StepAction
- done := false
-
- g.enterMu.Lock()
- if g.enterSeqs[seq.playerName] != seq {
- g.enterMu.Unlock()
- continue
- }
- seq.wait--
- for seq.wait <= 0 && len(seq.steps) > 0 {
- step := seq.steps[0]
- seq.steps = seq.steps[1:]
- jobs = append(jobs, step)
- if len(seq.steps) > 0 {
- seq.wait = seq.steps[0].Delay
- } else {
- seq.wait = 0
- }
- }
- if len(seq.steps) == 0 {
- done = true
- delete(g.enterSeqs, seq.playerName)
- }
- g.enterMu.Unlock()
-
- for i := range jobs {
- g.fireEnterStep(seq, &jobs[i])
- }
- if done {
- g.completeEnterSeq(seq)
- }
- }
-}
-
-// fireEnterStep runs one on_enter step's effects via the unified
-// applyStepAction executor. The room-lock check (player must still be in the
-// room that the sequence is being played for) lives here, before dispatch —
-// applyStepAction itself doesn't re-check it.
-func (g *Game) fireEnterStep(seq *enterSeq, step *behavior.StepAction) {
- p := seq.sess.Player
- if p == nil || p.RoomID != seq.roomID {
- return
- }
- g.applyStepAction(seq.sess, p, step, seq.roomID, scopePlayerSeq, nil)
-}
-
-func (g *Game) completeEnterSeq(seq *enterSeq) {
- if !g.isCharLive(seq.playerName, seq.sess) {
+// runExitSteps evaluates a room's on_exit block for the player. It fires the
+// first matching trigger as a non-room-locked sequence using the room being
+// left as the reference room (broadcasts/spawn resolve to the old room). It is
+// invoked before the player's RoomID is updated to the destination.
+func (g *Game) runExitSteps(sess *net.Session, roomID int) {
+ room, err := g.World.LoadRoom(roomID)
+ if err != nil || len(room.OnExit) == 0 {
return
}
- p := seq.sess.Player
+ p := sess.Player
if p == nil {
return
}
- if p.EnterSeqRoom == seq.roomID {
- p.EnterSeqRoom = 0
- g.AccountStore.SaveCharacter(p)
- }
- g.writePrompt(seq.sess)
-}
-
-// cancelEnterSeq drops any in-flight enter sequence for the player. It does not
-// touch the persisted EnterSeqRoom marker, so a sequence cancelled by
-// disconnect can still be resumed on reconnect.
-func (g *Game) cancelEnterSeq(name string) {
- g.enterMu.Lock()
- delete(g.enterSeqs, name)
- g.enterMu.Unlock()
-}
-
-func (g *Game) isCharLive(name string, sess *net.Session) bool {
- g.charsMu.Lock()
- defer g.charsMu.Unlock()
- return g.loggedInChars[name] == sess
+ g.runTrigger(sess, p, room.OnExit, roomID, nil, false)
}
// applyFlagMutations sets global and player flags. It is the player-scoped
-// (scopePlayer / scopePlayerSeq) flag step of applyStepAction, called once
-// per non-global step that carries set_global_flags and/or set_player_flags.
-// scopeGlobal sets globals directly in applyStepAction step 4 instead (so
-// world-scoped cascades don't reach back into this helper).
+// flag step of applyStep, called once per step that carries set_global_flags
+// and/or set_player_flags. scopeGlobal sets globals directly in applyStep
+// instead (so world-scoped cascades don't reach back into this helper).
func (g *Game) applyFlagMutations(p *player.Player, setGlobalFlags, setPlayerFlags map[string]any) {
g.GlobalFlags.SetAll(setGlobalFlags)
if len(setPlayerFlags) > 0 {
@@ -213,3 +75,11 @@ func (g *Game) BroadcastRespawns() {
}
}
}
+
+// isCharLive reports whether the named player is currently connected via the
+// given session (used by older helpers that still reference it).
+func (g *Game) isCharLive(name string, sess *net.Session) bool {
+ g.charsMu.Lock()
+ defer g.charsMu.Unlock()
+ return g.loggedInChars[name] == sess
+}
diff --git a/internal/game/act_state.go b/internal/game/act_state.go
index 130b088..73ecd57 100644
--- a/internal/game/act_state.go
+++ b/internal/game/act_state.go
@@ -81,8 +81,6 @@ func (g *Game) playerActionDisplay(p *player.Player) string {
return "constructing some " + a.TargetName
case behavior.TypeTriggerModule:
return "triggering " + a.TargetName
- case behavior.TypeInteractionSeq:
- return "using " + a.TargetName
}
}
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index d081d8b..bf0ce14 100644
--- a/internal/game/act_steal.go
+++ b/internal/game/act_steal.go
@@ -37,17 +37,17 @@ var stallGuardTalk = &behavior.TalkConfig{
},
"bribe": {
Messages: []string{"Smart choice. Hand over 500 credits and we'll forget this happened."},
- Action: &behavior.NodeAction{Credits: -500},
+ Action: &behavior.Step{Credits: -500},
Options: []behavior.TalkOption{{Text: "\"Fine, take it.\""}},
},
"jail": {
Messages: []string{"Off to the detention cell with you!"},
- Action: &behavior.NodeAction{Teleport: 162},
+ Action: &behavior.Step{Teleport: 162},
Options: []behavior.TalkOption{{Text: "(You are dragged away)"}},
},
"fight": {
Messages: []string{"Then defend yourself!"},
- Action: &behavior.NodeAction{SetGlobalFlags: map[string]any{"guard_hostile": true}},
+ Action: &behavior.Step{SetGlobalFlags: map[string]any{"guard_hostile": true}},
Options: []behavior.TalkOption{{Text: "(The guard attacks!)"}},
},
},
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index e08fbd2..8de58df 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -56,8 +56,8 @@ func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.Object
}
}
-func (g *Game) handleNodeAction(sess *net.Session, na *behavior.NodeAction) (intercepted bool) {
- g.applyNodeAction(sess, na)
+func (g *Game) handleNodeAction(sess *net.Session, na *behavior.Step) (intercepted bool) {
+ g.applyTalkStep(sess, na)
if v, ok := g.GlobalFlags.Get("guard_hostile"); ok && v != nil {
g.GlobalFlags.Delete("guard_hostile")
diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go
index e3bc2b5..3fda55e 100644
--- a/internal/game/cmd_dig.go
+++ b/internal/game/cmd_dig.go
@@ -90,10 +90,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
}
p.Action = nil
g.cancelRest(p.Name)
- g.cancelEnterSeq(p.Name)
- if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom {
- p.EnterSeqRoom = 0
- }
+ g.cancelSequences(p.Name)
if ss, ok := g.safespot.Get(p.Name); ok {
g.forceLeaveSafespot(sess, p, &ss, "")
}
@@ -189,10 +186,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
}
p.Action = nil
g.cancelRest(p.Name)
- g.cancelEnterSeq(p.Name)
- if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom {
- p.EnterSeqRoom = 0
- }
+ g.cancelSequences(p.Name)
if ss, ok := g.safespot.Get(p.Name); ok {
g.forceLeaveSafespot(sess, p, &ss, "")
}
diff --git a/internal/game/cmd_goto.go b/internal/game/cmd_goto.go
index 41e7b37..1771f8d 100644
--- a/internal/game/cmd_goto.go
+++ b/internal/game/cmd_goto.go
@@ -37,10 +37,7 @@ func (g *Game) executeGoto(sess *net.Session, args []string, rawInput string) {
}
p.Action = nil
g.cancelRest(p.Name)
- g.cancelEnterSeq(p.Name)
- if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom {
- p.EnterSeqRoom = 0
- }
+ g.cancelSequences(p.Name)
if ss, ok := g.safespot.Get(p.Name); ok {
g.forceLeaveSafespot(sess, p, &ss, "")
}
diff --git a/internal/game/cmd_inspect.go b/internal/game/cmd_inspect.go
index 7488591..2c1b611 100644
--- a/internal/game/cmd_inspect.go
+++ b/internal/game/cmd_inspect.go
@@ -85,19 +85,21 @@ func (g *Game) executeInspect(sess *net.Session, args []string, rawInput string)
}
if len(room.OnEnter) > 0 {
- sess.WriteLine(fmt.Sprintf("\nOn-Enter Steps: %d", len(room.OnEnter)))
+ sess.WriteLine(fmt.Sprintf("\nOn-Enter Triggers: %d", len(room.OnEnter)))
}
-
- if len(room.Triggers) > 0 {
- sess.WriteLine(fmt.Sprintf("\nRoom Triggers: %d", len(room.Triggers)))
- for _, trigger := range room.Triggers {
- kind := ""
- if trigger.OnGlobalFlag != "" {
- kind = fmt.Sprintf("global flag %q", trigger.OnGlobalFlag)
- } else if trigger.OnPlayerFlag != "" {
- kind = fmt.Sprintf("player flag %q", trigger.OnPlayerFlag)
- }
- sess.WriteLine(fmt.Sprintf(" %s: %s (%d steps)", trigger.ID, kind, len(trigger.Steps)))
+ if len(room.OnExit) > 0 {
+ sess.WriteLine(fmt.Sprintf("\nOn-Exit Triggers: %d", len(room.OnExit)))
+ }
+ if len(room.OnFlagChange) > 0 {
+ sess.WriteLine(fmt.Sprintf("\nOn-Flag-Change Triggers: %d", len(room.OnFlagChange)))
+ for _, t := range room.OnFlagChange {
+ sess.WriteLine(fmt.Sprintf(" player flag %q (%d steps)", t.OnPlayerFlag, len(t.Steps)))
+ }
+ }
+ if len(room.OnGlobalFlagChange) > 0 {
+ sess.WriteLine(fmt.Sprintf("\nOn-Global-Flag-Change Triggers: %d", len(room.OnGlobalFlagChange)))
+ for _, t := range room.OnGlobalFlagChange {
+ sess.WriteLine(fmt.Sprintf(" global flag %q (%d steps)", t.OnGlobalFlag, len(t.Steps)))
}
}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 4391b1f..2967e79 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -73,7 +73,7 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
// fire in completeMove once the player arrives. ExitDef.Condition (above,
// evaluated by exitDisplayState) gates whether the exit is passable at
// all; each on_traverse entry has its own additional Condition gate.
- p.MovePendingInteraction = g.matchExitInteraction(sess, p, exitDef)
+ p.MovePendingTrigger = g.matchExitTrigger(sess, p, exitDef)
inCombat := g.Combat.Get(p.Name) != nil
@@ -101,17 +101,17 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
}
}
-// matchExitInteraction returns the first on_traverse Interaction on exitDef
-// whose Condition (if any) passes for this player, or nil if the exit has no
-// on_traverse entries. The matched interaction's Action fires (with its optional
-// Message) once the player arrives in the target room.
-func (g *Game) matchExitInteraction(sess *net.Session, p *player.Player, exitDef world.ExitDef) *behavior.Interaction {
+// matchExitTrigger returns the first on_traverse Trigger on exitDef whose
+// Condition (and item filter, n/a for traverse) passes for this player, or nil
+// if the exit has no matching on_traverse entry. The matched trigger's Steps
+// run as a sequence once the player arrives in the target room.
+func (g *Game) matchExitTrigger(sess *net.Session, p *player.Player, exitDef world.ExitDef) *behavior.Trigger {
for i := range exitDef.OnTraverse {
- it := &exitDef.OnTraverse[i]
- if it.Condition != nil && !g.checkCondition(sess, it.Condition) {
+ t := &exitDef.OnTraverse[i]
+ if t.Condition != nil && !g.checkCondition(sess, t.Condition) {
continue
}
- return it
+ return t
}
return nil
}
@@ -152,7 +152,7 @@ func (g *Game) moveTicks(p *player.Player, multiplier float64) int {
func (g *Game) completeMove(sess *net.Session, p *player.Player) {
exitDir := p.MoveDirection
targetID := p.MoveTarget
- pendingInteraction := p.MovePendingInteraction
+ pendingTrigger := p.MovePendingTrigger
p.ClearMoveState()
@@ -174,19 +174,26 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) {
}
}
}
+
+ // Interruptable: an unlocked in-flight verb sequence is cancelled by the
+ // move. (Locked sequences block movement entirely at the command router.)
+ g.cancelUnlockedSequences(p.Name)
+
+ // Fire the on_exit block of the room being left BEFORE updating RoomID, so
+ // the exit scene resolves against the old room.
+ if oldRoom != targetID {
+ g.runExitSteps(sess, oldRoom)
+ }
+
p.RoomID = targetID
p.HazardTimer = 0
p.Stats.RecordRoomVisit(targetID)
- g.cancelEnterSeq(p.Name)
- if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom {
- p.EnterSeqRoom = 0
- }
- // Fire the on_traverse interaction (if any matched at classification
- // time) AFTER p.RoomID is set to the new room, so effect fields like
- // aps_node / teleport operate on the destination as their reference frame.
- if pendingInteraction != nil {
- g.runInteraction(sess, p, pendingInteraction.Action, p.RoomID, scopePlayer, nil)
+ // Fire the on_traverse trigger (if any matched at classification time)
+ // AFTER p.RoomID is set to the new room, so effects like aps_node/teleport
+ // operate on the destination as their reference frame.
+ if pendingTrigger != nil {
+ g.runTrigger(sess, p, []behavior.Trigger{*pendingTrigger}, p.RoomID, nil, false)
}
g.AccountStore.SaveCharacter(p)
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index e2c34aa..d03993a 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -150,7 +150,7 @@ var commandRegistry = map[string]commandDef{
"goto": {(*Game).executeGoto, ClassInstant},
"summon": {(*Game).executeSummon, ClassInstant},
"dig": {(*Game).executeDig, ClassInstant},
- "setglobalflag": {(*Game).executeSetGlobalFlag, ClassInstant},
+ "setglobalflag": {(*Game).executeSetGlobalFlag, ClassInstant},
"setplayerflag": {(*Game).executeSetPlayerFlag, ClassInstant},
"reload": {(*Game).executeReload, ClassInstant},
"shutdown": {(*Game).executeShutdown, ClassInstant},
@@ -177,6 +177,24 @@ func classifyCommand(cmd string) CommandClass {
}
func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawInput string) {
+ p := sess.Player
+
+ // Locked-sequence busy gate: a locked in-flight trigger sequence blocks every
+ // verb except quit/logout (and god-mode admins) until it completes.
+ if p != nil && !p.GodMode && g.playerLocked(p.Name) && cmd != "quit" && cmd != "logout" {
+ sess.WriteLine("You're busy — wait for the current action to finish.")
+ return
+ }
+
+ // Unlocked sequences are interruptable: any active/free verb cancels them
+ // before running. (Instant info commands like look/inventory/help do not.)
+ if p != nil {
+ switch classifyCommand(cmd) {
+ case ClassActive, ClassFree:
+ g.cancelUnlockedSequences(p.Name)
+ }
+ }
+
if def, ok := commandRegistry[cmd]; ok {
def.handler(g, sess, args, rawInput)
return
@@ -187,7 +205,6 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
return
}
- p := sess.Player
if a, ok := verbAliases[cmd]; ok {
g.cancelAction(p)
switch a {
diff --git a/internal/game/cmd_reload.go b/internal/game/cmd_reload.go
index 2b8230a..3fa6054 100644
--- a/internal/game/cmd_reload.go
+++ b/internal/game/cmd_reload.go
@@ -29,9 +29,9 @@ func (g *Game) executeReload(sess *net.Session, args []string, rawInput string)
g.World.RebuildRoomIndex(g.DataDir)
g.World.ClearHazardCache()
- g.TriggerStore.ClearTriggers()
- g.TriggerStore.LoadGlobal(g.DataDir)
- g.seedRoomTriggers()
+ // Abort all in-flight sequences, then rebuild the flag-trigger index.
+ g.abortAllSequences()
+ g.loadAllFlagTriggers()
sess.WriteLine("All caches reloaded.")
}
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index 15b05ef..132469d 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -177,10 +177,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
}
p.Action = nil
g.cancelRest(p.Name)
- g.cancelEnterSeq(p.Name)
- if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom {
- p.EnterSeqRoom = 0
- }
+ g.cancelSequences(p.Name)
if ss, ok := g.safespot.Get(p.Name); ok {
g.forceLeaveSafespot(sess, p, &ss, "")
}
diff --git a/internal/game/cmd_room_remove_test.go b/internal/game/cmd_room_remove_test.go
index a13af8a..8bd8470 100644
--- a/internal/game/cmd_room_remove_test.go
+++ b/internal/game/cmd_room_remove_test.go
@@ -17,9 +17,12 @@ import (
type removeTestConn struct{ buf []byte }
func (c *removeTestConn) ReadMessage() (string, error) { return "", nil }
-func (c *removeTestConn) Write(b []byte) (int, error) { c.buf = append(c.buf, b...); return len(b), nil }
-func (c *removeTestConn) Close() error { return nil }
-func (c *removeTestConn) SetEcho(bool) error { return nil }
+func (c *removeTestConn) Write(b []byte) (int, error) {
+ c.buf = append(c.buf, b...)
+ return len(b), nil
+}
+func (c *removeTestConn) Close() error { return nil }
+func (c *removeTestConn) SetEcho(bool) error { return nil }
func (c *removeTestConn) output() string { return string(c.buf) }
diff --git a/internal/game/cmd_summon.go b/internal/game/cmd_summon.go
index 3aa8d7b..9383e31 100644
--- a/internal/game/cmd_summon.go
+++ b/internal/game/cmd_summon.go
@@ -52,10 +52,7 @@ func (g *Game) executeSummon(sess *net.Session, args []string, rawInput string)
}
tp.Action = nil
g.cancelRest(tp.Name)
- g.cancelEnterSeq(tp.Name)
- if tp.EnterSeqRoom != 0 && tp.EnterSeqRoom == oldRoom {
- tp.EnterSeqRoom = 0
- }
+ g.cancelSequences(tp.Name)
if ss, ok := g.safespot.Get(tp.Name); ok {
g.forceLeaveSafespot(targetSess, tp, &ss, "")
}
diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go
index a2cb135..975eddb 100644
--- a/internal/game/cmd_use.go
+++ b/internal/game/cmd_use.go
@@ -90,17 +90,10 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string)
g.startAction(sess, bt, def.Name)
return true
}
- for _, ui := range def.OnUse {
- if ui.Item != "" {
- continue
- }
- if ui.Condition != nil && !g.checkCondition(sess, ui.Condition) {
- continue
- }
- g.cancelAction(p)
- name := def.Name
- g.broadcastAction(sess, "%s uses the %s.", p.Name, name)
- g.runInteraction(sess, p, ui.Action, p.RoomID, scopePlayer, nil)
+ g.cancelAction(p)
+ name := def.Name
+ g.broadcastAction(sess, "%s uses the %s.", p.Name, name)
+ if g.runTrigger(sess, p, def.OnUse, p.RoomID, func(id string) bool { return false }, false) {
return true
}
if len(def.OnUse) > 0 {
@@ -276,17 +269,10 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName,
return
}
- for _, ui := range def.OnUse {
- if ui.Item != itemAID {
- continue
- }
- if ui.Condition != nil && !g.checkCondition(sess, ui.Condition) {
- continue
- }
- g.cancelAction(p)
- name := def.Name
- g.broadcastAction(sess, "%s uses the %s.", p.Name, name)
- g.runInteraction(sess, p, ui.Action, p.RoomID, scopePlayer, nil)
+ g.cancelAction(p)
+ name := def.Name
+ g.broadcastAction(sess, "%s uses the %s.", p.Name, name)
+ if g.runTrigger(sess, p, def.OnUse, p.RoomID, func(id string) bool { return id == itemAID }, false) {
return
}
diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go
index 6c20004..24957ab 100644
--- a/internal/game/cmd_verbs.go
+++ b/internal/game/cmd_verbs.go
@@ -76,7 +76,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
}
for _, ui := range def.OnUse {
- if ui.Item == "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) {
+ if ui.ItemID == "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) {
addUnique(&sectionObjs, &seen, "use "+name)
break
}
@@ -107,8 +107,8 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
}
for _, ui := range def.OnUse {
- if ui.Item != "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) {
- if itemDef, err := g.ItemStore.Load(ui.Item); err == nil {
+ if ui.ItemID != "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) {
+ if itemDef, err := g.ItemStore.Load(ui.ItemID); err == nil {
addUnique(&sectionObjs, &seen, "use "+itemDef.Name+" on "+name)
}
}
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index 0395049..9affede 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -221,7 +221,7 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
// task mob's HP to zero is the "kill" that completes the work). itemMatch
// is the "wielding" gate — on_kill entries with an item_id only fire if
// the player currently has that item equipped in a weapon-hand slot.
- g.applyInteraction(sess, p, mob.OnKill, p.RoomID, true, p.IsWielding)
+ g.runTrigger(sess, p, mob.OnKill, p.RoomID, p.IsWielding, false)
g.scheduleMobRespawn(mob)
g.writePrompt(sess)
}
diff --git a/internal/game/combat_mob_test.go b/internal/game/combat_mob_test.go
index 28427b7..70c744b 100644
--- a/internal/game/combat_mob_test.go
+++ b/internal/game/combat_mob_test.go
@@ -48,7 +48,7 @@ func TestMobStrongestRangedScience(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
mob := &world.MobInstance{
- AttackTypes: c.types,
+ AttackTypes: c.types,
MaxRangedHit: c.maxRanged,
MaxScienceHit: c.maxScience,
SciencePercentBonus: c.sciencePct,
diff --git a/internal/game/core_course.go b/internal/game/core_course.go
index 6394b99..232e6dc 100644
--- a/internal/game/core_course.go
+++ b/internal/game/core_course.go
@@ -252,4 +252,4 @@ func (cs *CourseStore) loadAllLocked() {
})
obstacleVerbs = localVerbs
-} \ No newline at end of file
+}
diff --git a/internal/game/core_flags.go b/internal/game/core_flags.go
index c78f3c3..dbde5d7 100644
--- a/internal/game/core_flags.go
+++ b/internal/game/core_flags.go
@@ -41,20 +41,10 @@ func (g *Game) setPlayerFlag(p *player.Player, key string, val any) {
old, existed := p.Flags[key]
p.Flags[key] = val
if !existed || !engine.ValuesEqual(old, val) {
- g.firePlayerFlagTrigger(p, key, val)
+ g.firePlayerFlagTriggers(p, key, val)
}
}
-func (g *Game) firePlayerFlagTrigger(p *player.Player, flagName string, flagValue any) {
- g.charsMu.Lock()
- sess := g.loggedInChars[p.Name]
- g.charsMu.Unlock()
- if sess == nil {
- return
- }
- g.TriggerStore.FirePlayerInRoom(p.Name, p.RoomID, flagName, flagValue)
-}
-
func intFromFlag(flags map[string]any, key string) int {
val, ok := flags[key]
if !ok {
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index 90c06be..38499e2 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -112,21 +112,8 @@ func (g *Game) connectCharacter(sess *net.Session, name string) {
g.doLook(sess)
g.checkAggro(sess)
- // Resume an interrupted on_enter sequence (e.g. disconnected mid-sequence).
- // Only fires when a persisted marker matches the room the player is in, so
- // ordinary on_enter scripts never run on login.
- if p.EnterSeqRoom != 0 {
- if p.EnterSeqRoom == p.RoomID {
- g.runEnterSteps(sess, p.RoomID)
- }
- g.enterMu.Lock()
- _, active := g.enterSeqs[p.Name]
- g.enterMu.Unlock()
- if !active {
- p.EnterSeqRoom = 0
- g.AccountStore.SaveCharacter(p)
- }
- }
+ // Resume an interrupted LOCKED trigger sequence saved across disconnect.
+ g.resumePendingSequence(sess, p)
sess.WritePrompt("> ")
}
diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go
index 2872f34..ff905f6 100644
--- a/internal/game/exit_migrate.go
+++ b/internal/game/exit_migrate.go
@@ -9,8 +9,8 @@ import (
// rewritePlayerRoomIDs remaps room-ID-embedded state in a single player per
// idMap (oldID -> newID): discovered-exit player flag keys
-// (hidden_exit_<id>_<dir>), current RoomID, EnterSeqRoom, MapSymbols keys, and
-// Stats.RoomsVisited bitset. Returns whether any field was changed.
+// (hidden_exit_<id>_<dir>), current RoomID, PendingSequence.RoomID, MapSymbols
+// keys, and Stats.RoomsVisited bitset. Returns whether any field was changed.
//
// idMap is assumed to be a bijection (see Room.RewriteRoomIDs); the flag-key
// and int-map rekeys use delete-all-then-set-all so a swap (A<->B) cannot
@@ -23,7 +23,7 @@ func rewritePlayerRoomIDs(p *player.Player, idMap map[int]int) bool {
if remapScalar(idMap, &p.RoomID) {
changed = true
}
- if p.EnterSeqRoom != 0 && remapScalar(idMap, &p.EnterSeqRoom) {
+ if p.PendingSequence != nil && p.PendingSequence.RoomID != 0 && remapScalar(idMap, &p.PendingSequence.RoomID) {
changed = true
}
if len(p.Flags) > 0 && rewriteDiscoveredExitFlags(p.Flags, idMap) {
diff --git a/internal/game/exit_migrate_test.go b/internal/game/exit_migrate_test.go
index f86b85b..1ca33f7 100644
--- a/internal/game/exit_migrate_test.go
+++ b/internal/game/exit_migrate_test.go
@@ -9,8 +9,8 @@ import (
func TestRewritePlayerRoomIDsRename(t *testing.T) {
p := &player.Player{
- RoomID: 10,
- EnterSeqRoom: 20,
+ RoomID: 10,
+ PendingSequence: &player.PendingSequence{RoomID: 20, Key: "k"},
Flags: map[string]any{
"hidden_exit_10_north": 1,
"hidden_exit_20_down": 1,
@@ -31,8 +31,8 @@ func TestRewritePlayerRoomIDsRename(t *testing.T) {
if p.RoomID != 100 {
t.Errorf("RoomID = %d, want 100", p.RoomID)
}
- if p.EnterSeqRoom != 200 {
- t.Errorf("EnterSeqRoom = %d, want 200", p.EnterSeqRoom)
+ if p.PendingSequence == nil || p.PendingSequence.RoomID != 200 {
+ t.Errorf("PendingSequence.RoomID = %v, want 200", p.PendingSequence)
}
wantFlags := map[string]any{
diff --git a/internal/game/game.go b/internal/game/game.go
index f34e649..eed61f5 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -50,9 +50,9 @@ type Game struct {
Deps
Hub *net.Hub
- GlobalFlags *GlobalFlagStore
+ GlobalFlags *GlobalFlagStore
Combat *combat.Tracker
- TriggerStore *world.TriggerStore
+ flagIndex *flagTriggerIndex
queue *CommandQueue
safespot *SafespotManager
ValidationConfig config.ValidationConfig
@@ -66,8 +66,9 @@ type Game struct {
hackingStates map[string]*hacking.Session
pendingDepletions []pendingDepletion
farmTickCounter int
- enterMu sync.Mutex
- enterSeqs map[string]*enterSeq
+ seqMu sync.Mutex
+ sequences map[string]*sequence
+ globalSeqs []*sequence
shutdownCancel chan struct{}
shutdownActive bool
@@ -88,9 +89,9 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali
ColorConfig: colorConfig,
DataDir: dataDir,
},
- GlobalFlags: NewGlobalFlagStore(),
+ GlobalFlags: NewGlobalFlagStore(),
Combat: combat.NewTracker(),
- TriggerStore: world.NewTriggerStore(),
+ flagIndex: newFlagTriggerIndex(),
queue: NewCommandQueue(),
safespot: NewSafespotManager(),
ValidationConfig: valConfig,
@@ -99,14 +100,14 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali
restTimers: make(map[string]uint64),
guardWatchTimers: make(map[string]int),
hackingStates: make(map[string]*hacking.Session),
- enterSeqs: make(map[string]*enterSeq),
+ sequences: make(map[string]*sequence),
}
g.CourseStore.SetWorld(g.World)
g.CourseStore.LoadAll()
g.LoadMods()
g.LoadTechs()
g.buildCraftIndex()
- g.seedRoomTriggers()
+ g.loadAllFlagTriggers()
g.ValidateAndLog()
return g
}
@@ -116,9 +117,9 @@ func (g *Game) SetHub(hub *net.Hub) {
hub.OnRemove(func(sess *net.Session) {
if p := sess.Player; p != nil {
g.restoreGodPlayer(p)
+ g.persistLockedOnDisconnect(p)
g.AccountStore.SaveCharacter(p)
p.DeactivateAllTechs()
- g.cancelEnterSeq(p.Name)
g.charsMu.Lock()
delete(g.loggedInChars, p.Name)
g.charsMu.Unlock()
@@ -129,7 +130,7 @@ func (g *Game) SetHub(hub *net.Hub) {
}
})
g.GlobalFlags.OnChange(func(name string, value any) {
- g.TriggerStore.FireGlobal(name, value)
+ g.fireGlobalFlagTriggers(name, value)
})
}
@@ -353,18 +354,6 @@ func (g *Game) buildCraftIndex() {
g.CraftIndex.Build(items)
}
-func (g *Game) seedRoomTriggers() {
- g.TriggerStore.LoadGlobal(g.DataDir)
- roomIndex := g.World.RoomIndex()
- for id := range roomIndex {
- room, err := g.World.LoadRoom(id)
- if err != nil || len(room.Triggers) == 0 {
- continue
- }
- g.TriggerStore.SeedRoomTriggers(id, room.Triggers)
- }
-}
-
type pendingDepletion struct {
instanceKey string
objDefID string
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 24a1df5..3f7141e 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -155,7 +155,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
}
if len(def.OnLook) > 0 {
- g.applyInteraction(sess, p, def.OnLook, p.RoomID, false, p.HasItem)
+ g.runTrigger(sess, p, def.OnLook, p.RoomID, p.HasItem, false)
}
if def.Safespot != nil {
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index 6a1e224..a1955e7 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -2,7 +2,12 @@ package game
import (
"fmt"
+ "os"
+ "path/filepath"
"strings"
+ "sync"
+
+ "gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/engine"
@@ -10,11 +15,349 @@ import (
"thehouseoficarus/internal/player"
)
-func (g *Game) TriggerSeqTick() {
- g.processPlayerTriggerSequences()
- g.processGlobalTriggerSequences()
+// sequence is an in-flight scripted trigger sequence (one Trigger's Steps
+// running over time). It is driven one step at a time by SequenceTick. Every
+// trigger in the game — on_use, on_look, on_kill, on_enter, on_exit,
+// on_traverse, on_flag_change, on_global_flag_change — produces a sequence.
+type sequence struct {
+ sess *net.Session // nil for purely-global sequences
+ player *player.Player
+ playerName string
+ roomID int // reference room for broadcasts/spawn/teleport
+ steps []behavior.Step
+ wait int // ticks before the next step fires
+ scope effectScope // scopePlayer | scopeGlobal
+ flagVal any
+ key string // dedup key
+ locked bool // atomic + resumable on disconnect
+ roomLocked bool // skip steps whose player has left roomID
+}
+
+// verbSeqKey builds a dedup key for a verb-triggered sequence so two of the
+// same verb can't run simultaneously for one player.
+func verbSeqKey(p *player.Player, itemID string) string {
+ if p == nil {
+ return ""
+ }
+ if itemID != "" {
+ return p.Name + ":verb:" + itemID
+ }
+ return p.Name + ":verb"
+}
+
+// startSequence registers a sequence (deduped by key). If a sequence with the
+// same key is already in flight for the player, the new one is dropped (no
+// re-entrancy).
+func (g *Game) startSequence(sess *net.Session, p *player.Player, steps []behavior.Step, roomID int, sc effectScope, flagVal any, locked, roomLocked bool, key string) {
+ if len(steps) == 0 && sess != nil {
+ return
+ }
+ seq := &sequence{
+ sess: sess,
+ player: p,
+ roomID: roomID,
+ steps: append([]behavior.Step(nil), steps...),
+ scope: sc,
+ flagVal: flagVal,
+ key: key,
+ locked: locked,
+ roomLocked: roomLocked,
+ }
+ if p != nil {
+ seq.playerName = p.Name
+ }
+ if len(steps) > 0 {
+ seq.wait = steps[0].Wait
+ }
+ g.seqMu.Lock()
+ if key != "" {
+ if _, exists := g.sequences[key]; exists {
+ g.seqMu.Unlock()
+ return
+ }
+ g.sequences[key] = seq
+ } else {
+ g.globalSeqs = append(g.globalSeqs, seq)
+ }
+ g.seqMu.Unlock()
+ if locked && p != nil {
+ p.PendingSequence = &player.PendingSequence{
+ Key: key,
+ RoomID: roomID,
+ Steps: append([]behavior.Step(nil), steps...),
+ Wait: seq.wait,
+ }
+ g.AccountStore.SaveCharacter(p)
+ }
+}
+
+// SequenceTick advances every in-flight sequence by one tick, draining
+// consecutive zero-wait steps in a single tick (matching the original
+// on_enter/trigger schedulers). Player-bound sequences for a player who is no
+// longer connected are dropped (unlocked) or persisted (locked). Completed
+// sequences are removed.
+func (g *Game) SequenceTick() {
+ g.seqMu.Lock()
+ playerSeqs := make([]*sequence, 0, len(g.sequences))
+ for _, s := range g.sequences {
+ playerSeqs = append(playerSeqs, s)
+ }
+ globalSeqs := append([]*sequence(nil), g.globalSeqs...)
+ g.seqMu.Unlock()
+
+ for _, seq := range playerSeqs {
+ g.advanceSequence(seq)
+ }
+ for _, seq := range globalSeqs {
+ g.advanceSequence(seq)
+ }
+}
+
+func (g *Game) advanceSequence(seq *sequence) {
+ // Player-bound: handle disconnect and live-session gating.
+ if seq.scope == scopePlayer {
+ g.charsMu.Lock()
+ sess := g.loggedInChars[seq.playerName]
+ g.charsMu.Unlock()
+ if sess == nil || sess.Player == nil {
+ // Disconnected. Unlocked sequences are dropped; locked ones were
+ // already snapshotted to PendingSequence at disconnect time, so
+ // drop the in-memory copy too.
+ g.removeSequence(seq)
+ return
+ }
+ p := sess.Player
+ // Room-lock: skip steps whose player is no longer in the reference room.
+ inRoom := p.RoomID == seq.roomID
+ if seq.roomLocked && !inRoom {
+ // Still decrement the wait so timing advances, but don't fire.
+ // Drop a step when its wait elapses; resume firing on return.
+ seq.wait--
+ if seq.wait <= 0 && len(seq.steps) > 0 {
+ seq.steps = seq.steps[1:]
+ if len(seq.steps) > 0 {
+ seq.wait = seq.steps[0].Wait
+ }
+ }
+ if len(seq.steps) == 0 {
+ g.completePlayerSequence(seq, p)
+ }
+ return
+ }
+
+ seq.wait--
+ var jobs []behavior.Step
+ for seq.wait <= 0 && len(seq.steps) > 0 {
+ step := seq.steps[0]
+ seq.steps = seq.steps[1:]
+ jobs = append(jobs, step)
+ if len(seq.steps) > 0 {
+ seq.wait = seq.steps[0].Wait
+ } else {
+ seq.wait = 0
+ }
+ }
+ for i := range jobs {
+ g.fireStep(seq, sess, p, &jobs[i])
+ }
+ if len(seq.steps) == 0 {
+ g.completePlayerSequence(seq, p)
+ }
+ return
+ }
+
+ // Global sequence: no player/room.
+ seq.wait--
+ var jobs []behavior.Step
+ for seq.wait <= 0 && len(seq.steps) > 0 {
+ step := seq.steps[0]
+ seq.steps = seq.steps[1:]
+ jobs = append(jobs, step)
+ if len(seq.steps) > 0 {
+ seq.wait = seq.steps[0].Wait
+ } else {
+ seq.wait = 0
+ }
+ }
+ for i := range jobs {
+ g.fireGlobalStep(seq, &jobs[i])
+ }
+ if len(seq.steps) == 0 {
+ g.completeGlobalSequence(seq)
+ }
+}
+
+// fireStep runs one player-scoped step, gated by its per-step Condition.
+func (g *Game) fireStep(seq *sequence, sess *net.Session, p *player.Player, step *behavior.Step) {
+ if step.Condition != nil && !g.checkCondition(sess, step.Condition) {
+ return
+ }
+ g.applyStep(sess, p, step, seq.roomID, scopePlayer, seq.flagVal)
+}
+
+// fireGlobalStep runs one global-scoped step.
+func (g *Game) fireGlobalStep(seq *sequence, step *behavior.Step) {
+ g.applyStep(nil, nil, step, seq.roomID, scopeGlobal, seq.flagVal)
+}
+
+func (g *Game) completePlayerSequence(seq *sequence, p *player.Player) {
+ g.removeSequence(seq)
+ if p != nil && p.PendingSequence != nil && p.PendingSequence.Key == seq.key {
+ p.PendingSequence = nil
+ g.AccountStore.SaveCharacter(p)
+ }
+ if sess := seq.sess; sess != nil && sess.State == net.StateGame {
+ g.writePrompt(sess)
+ }
+}
+
+func (g *Game) completeGlobalSequence(seq *sequence) {
+ g.removeSequence(seq)
+}
+
+func (g *Game) removeSequence(seq *sequence) {
+ g.seqMu.Lock()
+ if seq.key != "" {
+ delete(g.sequences, seq.key)
+ } else {
+ for i, s := range g.globalSeqs {
+ if s == seq {
+ g.globalSeqs = append(g.globalSeqs[:i], g.globalSeqs[i+1:]...)
+ break
+ }
+ }
+ }
+ g.seqMu.Unlock()
+}
+
+// playerLocked reports whether the player has a locked in-flight sequence (and
+// is therefore "busy" — verbs are blocked except quit).
+func (g *Game) playerLocked(name string) bool {
+ g.seqMu.Lock()
+ defer g.seqMu.Unlock()
+ for _, s := range g.sequences {
+ if s.playerName == name && s.locked {
+ return true
+ }
+ }
+ return false
}
+// cancelUnlockedSequences drops all unlocked in-flight sequences for the
+// player (an unlocked sequence is interruptable by any verb). Locked sequences
+// are never cancelled by verbs.
+func (g *Game) cancelUnlockedSequences(name string) {
+ g.seqMu.Lock()
+ var toRemove []string
+ for k, s := range g.sequences {
+ if s.playerName == name && !s.locked {
+ toRemove = append(toRemove, k)
+ }
+ }
+ for _, k := range toRemove {
+ delete(g.sequences, k)
+ }
+ g.seqMu.Unlock()
+}
+
+// cancelSequences drops ALL in-flight sequences for the player (used on admin
+// teleport / forced movement that bypasses the busy lock).
+func (g *Game) cancelSequences(name string) {
+ g.seqMu.Lock()
+ var toRemove []string
+ for k, s := range g.sequences {
+ if s.playerName == name {
+ toRemove = append(toRemove, k)
+ }
+ }
+ for _, k := range toRemove {
+ delete(g.sequences, k)
+ }
+ g.seqMu.Unlock()
+}
+
+// abortAllSequences drops every in-flight sequence (player and global) and
+// clears persisted PendingSequence markers. Used on admin reload.
+func (g *Game) abortAllSequences() {
+ g.seqMu.Lock()
+ g.sequences = make(map[string]*sequence)
+ g.globalSeqs = nil
+ g.seqMu.Unlock()
+ g.charsMu.Lock()
+ chars := g.loggedInChars
+ g.charsMu.Unlock()
+ for _, sess := range chars {
+ if sess != nil && sess.Player != nil && sess.Player.PendingSequence != nil {
+ sess.Player.PendingSequence = nil
+ }
+ }
+}
+
+// persistLockedOnDisconnect snapshots the most recent locked sequence
+// to PendingSequence (the most recent one wins) and drops all of the player's
+// in-memory sequences. Called from the disconnect hook.
+func (g *Game) persistLockedOnDisconnect(p *player.Player) {
+ if p == nil {
+ return
+ }
+ g.seqMu.Lock()
+ var locked *sequence
+ var keysToRemove []string
+ for k, s := range g.sequences {
+ if s.playerName == p.Name {
+ if s.locked {
+ locked = s
+ }
+ keysToRemove = append(keysToRemove, k)
+ }
+ }
+ for _, k := range keysToRemove {
+ delete(g.sequences, k)
+ }
+ g.seqMu.Unlock()
+ if locked != nil {
+ p.PendingSequence = &player.PendingSequence{
+ Key: locked.key,
+ RoomID: locked.roomID,
+ Steps: append([]behavior.Step(nil), locked.steps...),
+ Wait: locked.wait,
+ }
+ g.AccountStore.SaveCharacter(p)
+ }
+}
+
+// resumePendingSequence re-arms a locked sequence from a player's
+// PendingSequence snapshot on reconnect.
+func (g *Game) resumePendingSequence(sess *net.Session, p *player.Player) {
+ if p == nil || p.PendingSequence == nil {
+ return
+ }
+ ps := p.PendingSequence
+ p.PendingSequence = nil
+ seq := &sequence{
+ sess: sess,
+ player: p,
+ playerName: p.Name,
+ roomID: ps.RoomID,
+ steps: append([]behavior.Step(nil), ps.Steps...),
+ wait: ps.Wait,
+ scope: scopePlayer,
+ flagVal: nil,
+ key: ps.Key,
+ locked: true,
+ roomLocked: false,
+ }
+ g.seqMu.Lock()
+ if seq.key != "" {
+ g.sequences[seq.key] = seq
+ } else {
+ g.globalSeqs = append(g.globalSeqs, seq)
+ }
+ g.seqMu.Unlock()
+}
+
+// TransientMobTick drives the despawn-on-leave lifecycle for transient mobs
+// spawned by trigger steps (carried over unchanged from the old system).
func (g *Game) TransientMobTick() {
if g.Hub == nil {
return
@@ -61,94 +404,6 @@ func (g *Game) TransientMobTick() {
}
}
-func (g *Game) processPlayerTriggerSequences() {
- seqs := g.TriggerStore.SnapshotPlayerSeqs()
- for _, seq := range seqs {
- g.charsMu.Lock()
- sess := g.loggedInChars[seq.PlayerName]
- g.charsMu.Unlock()
- if sess == nil || sess.Player == nil {
- g.TriggerStore.RemovePlayerSeq(seq.PlayerName, seq.TriggerID)
- continue
- }
- p := sess.Player
- if p.RoomID != seq.RoomID {
- continue
- }
-
- seq.Wait--
- if seq.Wait > 0 {
- continue
- }
-
- var jobs []behavior.StepAction
- for len(seq.Steps) > 0 && seq.Wait <= 0 {
- step := seq.Steps[0]
- seq.Steps = seq.Steps[1:]
- jobs = append(jobs, step)
- if len(seq.Steps) > 0 {
- seq.Wait = seq.Steps[0].Delay
- }
- }
-
- for i := range jobs {
- g.executeTriggerStep(sess, p, &jobs[i], seq.RoomID, seq.FlagValue)
- }
-
- if len(seq.Steps) == 0 {
- g.TriggerStore.RemovePlayerSeq(seq.PlayerName, seq.TriggerID)
- if p.RoomID == seq.RoomID && sess.State == net.StateGame {
- g.writePrompt(sess)
- }
- }
- }
-}
-
-func (g *Game) processGlobalTriggerSequences() {
- seqs := g.TriggerStore.SnapshotGlobalSeqs()
- for _, seq := range seqs {
- seq.Wait--
- if seq.Wait > 0 {
- continue
- }
-
- var jobs []behavior.StepAction
- for len(seq.Steps) > 0 && seq.Wait <= 0 {
- step := seq.Steps[0]
- seq.Steps = seq.Steps[1:]
- jobs = append(jobs, step)
- if len(seq.Steps) > 0 {
- seq.Wait = seq.Steps[0].Delay
- }
- }
-
- for i := range jobs {
- g.executeGlobalTriggerStep(&jobs[i], seq.RoomID, seq.FlagValue)
- }
-
- if len(seq.Steps) == 0 {
- g.TriggerStore.RemoveGlobalSeq(seq.TriggerID)
- }
- }
-}
-
-// executeTriggerStep is a thin wrapper over applyStepAction for trigger player
-// sequences (full player-targeted effects + sequence/broadcast/spawn fields).
-func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, flagValue any) {
- // On every step, evaluate the per-step condition (a trigger can now gate
- // individual steps via the Condition field inherited from StepAction).
- if step.Condition != nil && !g.checkCondition(sess, step.Condition) {
- return
- }
- g.applyStepAction(sess, p, step, roomID, scopePlayerSeq, flagValue)
-}
-
-// executeGlobalTriggerStep is a thin wrapper over applyStepAction for global
-// trigger sequences (no player; only broadcasts/global-flags/spawn/despawn).
-func (g *Game) executeGlobalTriggerStep(step *behavior.StepAction, roomID int, flagValue any) {
- g.applyStepAction(nil, nil, step, roomID, scopeGlobal, flagValue)
-}
-
func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *behavior.SpawnMobConfig, roomID int) {
if cfg.ID == "" {
return
@@ -218,3 +473,174 @@ func expandTemplate(tmpl string, playerName string, flagValue any) string {
}
return s
}
+
+// ---- Flag-change trigger index (replaces the old TriggerStore) ----
+//
+// All on_player_flag / on_global_flag triggers (room-embedded and global-file)
+// are indexed by flag name and listen everywhere. Room scoping is opt-in via
+// the trigger's Condition.Room.
+
+type flagTriggerIndex struct {
+ mu sync.Mutex
+ playerFlag map[string][]*behavior.Trigger
+ globalFlag map[string][]*behavior.Trigger
+}
+
+func newFlagTriggerIndex() *flagTriggerIndex {
+ return &flagTriggerIndex{
+ playerFlag: make(map[string][]*behavior.Trigger),
+ globalFlag: make(map[string][]*behavior.Trigger),
+ }
+}
+
+func (fi *flagTriggerIndex) clear() {
+ fi.mu.Lock()
+ defer fi.mu.Unlock()
+ fi.playerFlag = make(map[string][]*behavior.Trigger)
+ fi.globalFlag = make(map[string][]*behavior.Trigger)
+}
+
+func (fi *flagTriggerIndex) addPlayerFlag(t *behavior.Trigger) {
+ fi.mu.Lock()
+ defer fi.mu.Unlock()
+ fi.playerFlag[t.OnPlayerFlag] = append(fi.playerFlag[t.OnPlayerFlag], t)
+}
+
+func (fi *flagTriggerIndex) addGlobalFlag(t *behavior.Trigger) {
+ fi.mu.Lock()
+ defer fi.mu.Unlock()
+ fi.globalFlag[t.OnGlobalFlag] = append(fi.globalFlag[t.OnGlobalFlag], t)
+}
+
+// loadGlobalTriggers loads file-backed triggers from dataDir/triggers (one
+// Trigger per yaml file; the filename stem is the trigger id).
+func (g *Game) loadGlobalTriggers(dataDir string) error {
+ dir := filepath.Join(dataDir, "triggers")
+ if _, err := os.Stat(dir); os.IsNotExist(err) {
+ return nil
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ path := filepath.Join(dir, entry.Name())
+ ext := strings.ToLower(filepath.Ext(path))
+ if ext != ".yaml" && ext != ".yml" {
+ continue
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ continue
+ }
+ var t behavior.Trigger
+ if err := yamlUnmarshal(data, &t); err != nil {
+ return fmt.Errorf("trigger %q: %w", path, err)
+ }
+ stem := strings.TrimSuffix(entry.Name(), ext)
+ if t.OnPlayerFlag != "" {
+ tc := copyTrigger(t)
+ tc.DedupKey = "file:" + stem
+ g.flagIndex.addPlayerFlag(tc)
+ }
+ if t.OnGlobalFlag != "" {
+ tc := copyTrigger(t)
+ tc.DedupKey = "file:" + stem
+ g.flagIndex.addGlobalFlag(tc)
+ }
+ }
+ return nil
+}
+
+func copyTrigger(t behavior.Trigger) *behavior.Trigger {
+ tcopy := t
+ return &tcopy
+}
+
+// seedRoomFlagTriggers indexes the on_flag_change / on_global_flag_change
+// blocks of every room.
+func (g *Game) seedRoomFlagTriggers() {
+ roomIndex := g.World.RoomIndex()
+ for id := range roomIndex {
+ room, err := g.World.LoadRoom(id)
+ if err != nil {
+ continue
+ }
+ for i := range room.OnFlagChange {
+ t := room.OnFlagChange[i]
+ if t.OnPlayerFlag != "" {
+ t.DedupKey = fmt.Sprintf("room:%d:on_flag_change:%d", id, i)
+ g.flagIndex.addPlayerFlag(&room.OnFlagChange[i])
+ }
+ }
+ for i := range room.OnGlobalFlagChange {
+ if room.OnGlobalFlagChange[i].OnGlobalFlag != "" {
+ room.OnGlobalFlagChange[i].DedupKey = fmt.Sprintf("room:%d:on_global_flag_change:%d", id, i)
+ g.flagIndex.addGlobalFlag(&room.OnGlobalFlagChange[i])
+ }
+ }
+ }
+}
+
+func (g *Game) loadAllFlagTriggers() {
+ g.flagIndex.clear()
+ _ = g.loadGlobalTriggers(g.DataDir)
+ g.seedRoomFlagTriggers()
+}
+
+// firePlayerFlagTriggers starts sequences for every on_flag_change trigger
+// subscribed to flagName whose Value (if any) matches and whose Condition
+// passes for the player. Called from setPlayerFlag after an actual change.
+func (g *Game) firePlayerFlagTriggers(p *player.Player, flagName string, flagValue any) {
+ g.charsMu.Lock()
+ sess := g.loggedInChars[p.Name]
+ g.charsMu.Unlock()
+ if sess == nil {
+ return
+ }
+ g.flagIndex.mu.Lock()
+ triggers := append([]*behavior.Trigger(nil), g.flagIndex.playerFlag[flagName]...)
+ g.flagIndex.mu.Unlock()
+ for i := range triggers {
+ t := triggers[i]
+ if t.Value != nil && !engine.ValuesEqual(t.Value, flagValue) {
+ continue
+ }
+ if t.Condition != nil && !g.checkCondition(sess, t.Condition) {
+ continue
+ }
+ g.startSequence(sess, p, t.Steps, p.RoomID, scopePlayer, flagValue, t.Lock, false, p.Name+":flag:"+flagName+":"+t.DedupKey)
+ }
+}
+
+// fireGlobalFlagTriggers starts sequences for every on_global_flag_change
+// trigger subscribed to flagName whose Value (if any) matches. Global-flag
+// triggers have no originating player and run at scopeGlobal.
+func (g *Game) fireGlobalFlagTriggers(flagName string, flagValue any) {
+ g.flagIndex.mu.Lock()
+ triggers := append([]*behavior.Trigger(nil), g.flagIndex.globalFlag[flagName]...)
+ g.flagIndex.mu.Unlock()
+ for i := range triggers {
+ t := triggers[i]
+ if t.Value != nil && !engine.ValuesEqual(t.Value, flagValue) {
+ continue
+ }
+ if t.Condition != nil && !g.checkConditionGlobal(t.Condition) {
+ continue
+ }
+ roomID := 0
+ if t.Condition != nil && t.Condition.Room != 0 {
+ roomID = t.Condition.Room
+ }
+ key := fmt.Sprintf("global:%s:%d:%s", flagName, roomID, t.DedupKey)
+ g.startSequence(nil, nil, t.Steps, roomID, scopeGlobal, flagValue, false, false, key)
+ }
+}
+
+// yamlUnmarshal is a small indirection so this file need not import yaml directly.
+func yamlUnmarshal(data []byte, out *behavior.Trigger) error {
+ return yaml.Unmarshal(data, out)
+}