package game import ( "fmt" "os" "path/filepath" "strings" "sync" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) // 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 } for _, inst := range g.MobStore.AllInstances() { if inst.Owner == "" || inst.HP <= 0 { continue } if !inst.DespawnOnLeave { continue } g.charsMu.Lock() ownerSess := g.loggedInChars[inst.Owner] g.charsMu.Unlock() if ownerSess == nil || ownerSess.Player == nil { continue } ownerRoom := ownerSess.Player.RoomID allowed := inst.DespawnRooms if len(allowed) == 0 { allowed = []int{inst.HomeRoomID} } inAllowed := false for _, r := range allowed { if ownerRoom == r { inAllowed = true break } } if inAllowed { inst.DespawnCounter = inst.DespawnTickCount() continue } inst.DecrementDespawnCounter() if inst.DespawnCounter <= 0 { g.MobStore.RemoveInstance(inst.InstanceID) for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) { sess.WriteLine(g.colorize(sess, "broadcast", fmt.Sprintf("%s fades away.", mobDisplayName(inst, true)))) } } } } func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *behavior.SpawnMobConfig, roomID int) { if cfg.ID == "" { return } def, err := g.MobStore.LoadDef(cfg.ID) if err != nil { return } inst := g.MobStore.SpawnTransient(def, cfg, roomID, p.Name) if inst == nil { return } inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks)) sess.WriteLine(g.colorize(sess, "broadcast", mobDisplayName(inst, true)+" appears!")) } func (g *Game) spawnWorldTriggerMob(cfg *behavior.SpawnMobConfig, roomID int) { if cfg.ID == "" { return } def, err := g.MobStore.LoadDef(cfg.ID) if err != nil { return } inst := g.MobStore.SpawnTransient(def, cfg, roomID, "") if inst == nil { return } inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks)) if g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(roomID) { sess.WriteLine(g.colorize(sess, "broadcast", mobDisplayName(inst, true)+" appears!")) } } } func (g *Game) despawnTriggerMobs(mobID string, owner string) { for _, inst := range g.MobStore.AllInstances() { if inst.DefID != mobID { continue } if owner != "" && inst.Owner != owner { continue } if inst.HP <= 0 { g.MobStore.RemoveInstance(inst.InstanceID) continue } if g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) { sess.WriteLine(g.colorize(sess, "broadcast", fmt.Sprintf("%s vanishes.", mobDisplayName(inst, true)))) } } g.MobStore.RemoveInstance(inst.InstanceID) } } func expandTemplate(tmpl string, playerName string, flagValue any) string { s := tmpl if playerName != "" { s = strings.ReplaceAll(s, "%p", playerName) } if flagValue != nil { s = strings.ReplaceAll(s, "%v", fmt.Sprint(flagValue)) } return s } // ---- Flag-change trigger index ---- // // All on_player_flag / on_global_flag triggers are global file-backed // triggers (stored in data/triggers/). 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). Triggers are // organised in player_flag/ and global_flag/ subdirectories. func (g *Game) loadGlobalTriggers(dataDir string) error { dir := filepath.Join(dataDir, "triggers") if _, err := os.Stat(dir); os.IsNotExist(err) { return nil } // Scan subdirectories as well as flat files in the root. var walkDir func(p string) error walkDir = func(p string) error { entries, err := os.ReadDir(p) if err != nil { return err } for _, entry := range entries { full := filepath.Join(p, entry.Name()) if entry.IsDir() { if err := walkDir(full); err != nil { return err } continue } ext := strings.ToLower(filepath.Ext(entry.Name())) if ext != ".yaml" && ext != ".yml" { continue } data, err := os.ReadFile(full) if err != nil { continue } var t behavior.Trigger if err := yamlUnmarshal(data, &t); err != nil { return fmt.Errorf("trigger %q: %w", full, 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 } return walkDir(dir) } func copyTrigger(t behavior.Trigger) *behavior.Trigger { tcopy := t return &tcopy } func (g *Game) loadAllFlagTriggers() { g.flagIndex.clear() _ = g.loadGlobalTriggers(g.DataDir) } // firePlayerFlagTriggers starts sequences for every on_flag_change trigger // subscribed to flagName 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.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. 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.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) }