diff options
| author | historia <[not public]> | 2026-07-09 22:15:54 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-09 22:15:54 -0400 |
| commit | ecba7f726f70b37126d852c38c7e3eec7b04d730 (patch) | |
| tree | 9129215c6e5015336fde1116395336d82bb952d1 /internal/game | |
| parent | b3d4c616f59ad2519f3a0b77e3b47d6571cd2486 (diff) | |
| download | thehouseoficarus-ecba7f726f70b37126d852c38c7e3eec7b04d730.tar.gz | |
feat: old standalone trigger systems completely unified into trigger->condition->action system
Diffstat (limited to 'internal/game')
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(§ionObjs, &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(§ionObjs, &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) +} |
