diff options
Diffstat (limited to 'internal/game/sys_triggers.go')
| -rw-r--r-- | internal/game/sys_triggers.go | 608 |
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) +} |
