aboutsummaryrefslogtreecommitdiff
path: root/internal/game/sys_triggers.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-09 22:15:54 -0400
committerhistoria <[not public]>2026-07-09 22:15:54 -0400
commitecba7f726f70b37126d852c38c7e3eec7b04d730 (patch)
tree9129215c6e5015336fde1116395336d82bb952d1 /internal/game/sys_triggers.go
parentb3d4c616f59ad2519f3a0b77e3b47d6571cd2486 (diff)
downloadthehouseoficarus-ecba7f726f70b37126d852c38c7e3eec7b04d730.tar.gz
feat: old standalone trigger systems completely unified into trigger->condition->action system
Diffstat (limited to 'internal/game/sys_triggers.go')
-rw-r--r--internal/game/sys_triggers.go608
1 files changed, 517 insertions, 91 deletions
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)
+}