From 84d74ca4351a97148ad8339676f9df7946bc21fb Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 25 Jun 2026 20:33:47 -0400 Subject: feat: trigger system for scripted events added. --- internal/game/act_agility.go | 2 +- internal/game/act_farm.go | 80 ++++----- internal/game/act_fletch.go | 2 +- internal/game/act_room.go | 3 +- internal/game/act_steal.go | 4 + internal/game/act_talk.go | 11 +- internal/game/cmd_craft.go | 2 +- internal/game/combat_attack.go | 4 + internal/game/combat_mob.go | 15 +- internal/game/core_flags.go | 16 +- internal/game/flagstore.go | 39 ++++- internal/game/game.go | 18 +++ internal/game/production_menu.go | 2 +- internal/game/sys_assassin.go | 32 ++-- internal/game/triggers.go | 291 +++++++++++++++++++++++++++++++++ internal/validate/checks.go | 68 ++++++++ internal/validate/validate.go | 2 + internal/world/mob.go | 45 ++++++ internal/world/room.go | 1 + internal/world/trigger.go | 61 +++++++ internal/world/trigger_store.go | 340 +++++++++++++++++++++++++++++++++++++++ 21 files changed, 963 insertions(+), 75 deletions(-) create mode 100644 internal/game/triggers.go create mode 100644 internal/world/trigger.go create mode 100644 internal/world/trigger_store.go (limited to 'internal') diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go index 2136699..4a68580 100644 --- a/internal/game/act_agility.go +++ b/internal/game/act_agility.go @@ -103,7 +103,7 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { lapKey := "agility_laps_" + courseID laps := getPlayerFlagInt(p, lapKey) + 1 - setPlayerFlag(p, lapKey, laps) + g.setPlayerFlag(p, lapKey, laps) courseName := p.Action.TargetName sess.WriteLine(g.colorize(sess, "broadcast", diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go index 62e08fd..a4d96b4 100644 --- a/internal/game/act_farm.go +++ b/internal/game/act_farm.go @@ -41,13 +41,13 @@ func farmFlagPrefix(defID string, index int) string { func (g *Game) ensureFarmState(p *player.Player, prefix string) { p.EnsureFlags() if _, exists := p.Flags[prefix+"_seed"]; !exists { - p.Flags[prefix+"_weeds"] = true - p.Flags[prefix+"_seed"] = "" - p.Flags[prefix+"_stage"] = 0 - p.Flags[prefix+"_watered"] = false - p.Flags[prefix+"_diseased"] = false - p.Flags[prefix+"_dead"] = false - p.Flags[prefix+"_ready"] = false + g.setPlayerFlag(p, prefix+"_weeds", true) + g.setPlayerFlag(p, prefix+"_seed", "") + g.setPlayerFlag(p, prefix+"_stage", 0) + g.setPlayerFlag(p, prefix+"_watered", false) + g.setPlayerFlag(p, prefix+"_diseased", false) + g.setPlayerFlag(p, prefix+"_dead", false) + g.setPlayerFlag(p, prefix+"_ready", false) } } @@ -111,8 +111,8 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) { } if diseased { - p.Flags[prefix+"_dead"] = true - p.Flags[prefix+"_diseased"] = false + g.setPlayerFlag(p, prefix+"_dead", true) + g.setPlayerFlag(p, prefix+"_diseased", false) sess.WriteLine(g.colorize(sess, "farm_disease", fmt.Sprintf("\nYour %s has died from disease!", g.seedDisplayName(sess, seedID)))) g.AccountStore.SaveCharacter(p) @@ -135,23 +135,23 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) { stage++ if stage >= maxStages { - p.Flags[prefix+"_stage"] = stage - p.Flags[prefix+"_ready"] = true - p.Flags[prefix+"_watered"] = false + g.setPlayerFlag(p, prefix+"_stage", stage) + g.setPlayerFlag(p, prefix+"_ready", true) + g.setPlayerFlag(p, prefix+"_watered", false) sess.WriteLine(g.colorize(sess, "farm_grow", fmt.Sprintf("\nYour %s is fully grown and ready to harvest!", g.seedDisplayName(sess, seedID)))) } else { if !watered && rand.Float64() < 0.10 { - p.Flags[prefix+"_stage"] = stage - p.Flags[prefix+"_diseased"] = true - p.Flags[prefix+"_watered"] = false + g.setPlayerFlag(p, prefix+"_stage", stage) + g.setPlayerFlag(p, prefix+"_diseased", true) + g.setPlayerFlag(p, prefix+"_watered", false) sess.WriteLine(g.colorize(sess, "farm_disease", fmt.Sprintf("\nYour %s has become diseased!", g.seedDisplayName(sess, seedID)))) } else { - p.Flags[prefix+"_stage"] = stage - p.Flags[prefix+"_watered"] = false + g.setPlayerFlag(p, prefix+"_stage", stage) + g.setPlayerFlag(p, prefix+"_watered", false) sess.WriteLine(g.colorize(sess, "farm_grow", fmt.Sprintf("\nYour %s has grown to stage %d/%d.", g.seedDisplayName(sess, seedID), stage, maxStages))) @@ -182,13 +182,13 @@ func (g *Game) advancePlant(sess *net.Session, p *player.Player) { g.ensureFarmState(p, prefix) - p.Flags[prefix+"_weeds"] = false - p.Flags[prefix+"_seed"] = seedID - p.Flags[prefix+"_stage"] = 0 - p.Flags[prefix+"_watered"] = false - p.Flags[prefix+"_diseased"] = false - p.Flags[prefix+"_dead"] = false - p.Flags[prefix+"_ready"] = false + g.setPlayerFlag(p, prefix+"_weeds", false) + g.setPlayerFlag(p, prefix+"_seed", seedID) + g.setPlayerFlag(p, prefix+"_stage", 0) + g.setPlayerFlag(p, prefix+"_watered", false) + g.setPlayerFlag(p, prefix+"_diseased", false) + g.setPlayerFlag(p, prefix+"_dead", false) + g.setPlayerFlag(p, prefix+"_ready", false) if intXP := int(xp); intXP > 0 { g.awardSkillXP(sess, p, player.Farming, intXP) @@ -280,13 +280,13 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) { } g.ensureFarmState(p, prefix) - p.Flags[prefix+"_seed"] = "" - p.Flags[prefix+"_stage"] = 0 - p.Flags[prefix+"_ready"] = false - p.Flags[prefix+"_watered"] = false - p.Flags[prefix+"_weeds"] = true - p.Flags[prefix+"_diseased"] = false - p.Flags[prefix+"_dead"] = false + g.setPlayerFlag(p, prefix+"_seed", "") + g.setPlayerFlag(p, prefix+"_stage", 0) + g.setPlayerFlag(p, prefix+"_ready", false) + g.setPlayerFlag(p, prefix+"_watered", false) + g.setPlayerFlag(p, prefix+"_weeds", true) + g.setPlayerFlag(p, prefix+"_diseased", false) + g.setPlayerFlag(p, prefix+"_dead", false) g.AccountStore.SaveCharacter(p) @@ -314,13 +314,13 @@ func (g *Game) advanceRake(sess *net.Session, p *player.Player) { } prefix := data.Prefix - p.Flags[prefix+"_weeds"] = false - p.Flags[prefix+"_dead"] = false - p.Flags[prefix+"_seed"] = "" - p.Flags[prefix+"_stage"] = 0 - p.Flags[prefix+"_ready"] = false - p.Flags[prefix+"_diseased"] = false - p.Flags[prefix+"_watered"] = false + g.setPlayerFlag(p, prefix+"_weeds", false) + g.setPlayerFlag(p, prefix+"_dead", false) + g.setPlayerFlag(p, prefix+"_seed", "") + g.setPlayerFlag(p, prefix+"_stage", 0) + g.setPlayerFlag(p, prefix+"_ready", false) + g.setPlayerFlag(p, prefix+"_diseased", false) + g.setPlayerFlag(p, prefix+"_watered", false) g.AccountStore.SaveCharacter(p) @@ -340,7 +340,7 @@ func (g *Game) advanceWater(sess *net.Session, p *player.Player) { } prefix := data.Prefix - p.Flags[prefix+"_watered"] = true + g.setPlayerFlag(p, prefix+"_watered", true) g.AccountStore.SaveCharacter(p) @@ -361,7 +361,7 @@ func (g *Game) advanceCure(sess *net.Session, p *player.Player) { prefix := data.Prefix p.RemoveItem("plant_cure", 1) - p.Flags[prefix+"_diseased"] = false + g.setPlayerFlag(p, prefix+"_diseased", false) g.AccountStore.SaveCharacter(p) diff --git a/internal/game/act_fletch.go b/internal/game/act_fletch.go index 78a19ad..e85f2c1 100644 --- a/internal/game/act_fletch.go +++ b/internal/game/act_fletch.go @@ -12,7 +12,7 @@ import ( func (g *Game) startFletchAction(sess *net.Session, p *player.Player, def *item.ItemDef, count int) { p.EnsureFlags() - p.Flags["last_fletch"] = def.ID + g.setPlayerFlag(p, "last_fletch", def.ID) g.AccountStore.SaveCharacter(p) if def.FirstCraft().Wait <= 0 { diff --git a/internal/game/act_room.go b/internal/game/act_room.go index 86c1737..fec7304 100644 --- a/internal/game/act_room.go +++ b/internal/game/act_room.go @@ -188,9 +188,8 @@ func (g *Game) isCharLive(name string, sess *net.Session) bool { func (g *Game) applyFlagMutations(p *player.Player, setFlags, setPlayerFlags map[string]any) { g.Flags.SetAll(setFlags) if len(setPlayerFlags) > 0 { - p.EnsureFlags() for k, v := range setPlayerFlags { - p.Flags[k] = v + g.setPlayerFlag(p, k, v) } } } diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go index 7371782..39df9e2 100644 --- a/internal/game/act_steal.go +++ b/internal/game/act_steal.go @@ -203,6 +203,10 @@ func (g *Game) startSteal(sess *net.Session, p *player.Player, targetType string targetID = mob.InstanceID mobInstanceID = mob.InstanceID + if mob.OwnerOnly && mob.Owner != "" && mob.Owner != p.Name { + sess.WriteLine("They don't seem interested in you.") + return + } if stealTable == "" { sess.WriteLine(fmt.Sprintf("You can't steal from the %s.", targetName)) return diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go index f54290a..14361f8 100644 --- a/internal/game/act_talk.go +++ b/internal/game/act_talk.go @@ -14,6 +14,10 @@ import ( ) func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobInstance) { + if mob.OwnerOnly && mob.Owner != "" && mob.Owner != p.Name { + sess.WriteLine("They ignore you.") + return + } cfg := mob.TalkConfig if cfg == nil { sess.WriteLine("This person has nothing to say.") @@ -267,7 +271,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) { sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep)) return } - setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost) + g.setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost) g.AccountStore.SaveCharacter(p) } @@ -275,8 +279,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) { g.Flags.Set(k, v) } for k, v := range na.SetPlayerFlags { - p.EnsureFlags() - p.Flags[k] = v + g.setPlayerFlag(p, k, v) } if na.TakeItem != "" { if p.HasItem(na.TakeItem) { @@ -323,6 +326,6 @@ func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) { g.processSawmill(sess, p) } if na.ApsNode { - setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true) + g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true) } } diff --git a/internal/game/cmd_craft.go b/internal/game/cmd_craft.go index 254b938..0fe3d5b 100644 --- a/internal/game/cmd_craft.go +++ b/internal/game/cmd_craft.go @@ -164,7 +164,7 @@ func (g *Game) availableItems(p *player.Player, allItems []*item.ItemDef) []*ite func (g *Game) startItem(sess *net.Session, p *player.Player, def *item.ItemDef, count int, flagKey string) { p.EnsureFlags() - p.Flags[flagKey] = def.ID + g.setPlayerFlag(p, flagKey, def.ID) g.AccountStore.SaveCharacter(p) g.startProductionFromItem(sess, p, def, count) diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go index ee7dee8..61eb9c1 100644 --- a/internal/game/combat_attack.go +++ b/internal/game/combat_attack.go @@ -130,6 +130,10 @@ func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobIn } func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { + if mob.OwnerOnly && mob.Owner != "" && mob.Owner != p.Name { + sess.WriteLine("They don't seem interested in you.") + return + } g.cancelRest(p.Name) g.cancelBgAction(p) diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go index e06e307..5558578 100644 --- a/internal/game/combat_mob.go +++ b/internal/game/combat_mob.go @@ -212,10 +212,17 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst respawnTicks = engine.ToTicks(30) } instanceID := mob.InstanceID - g.Ticks.Subscribe(respawnTicks, func() bool { - g.respawnMob(instanceID) - return false - }) + if mob.SpawnedByTrigger { + g.Ticks.Subscribe(10, func() bool { + g.MobStore.RemoveInstance(instanceID) + return false + }) + } else { + g.Ticks.Subscribe(respawnTicks, func() bool { + g.respawnMob(instanceID) + return false + }) + } g.writePrompt(sess) } } diff --git a/internal/game/core_flags.go b/internal/game/core_flags.go index 6595cc4..5e1c812 100644 --- a/internal/game/core_flags.go +++ b/internal/game/core_flags.go @@ -33,9 +33,23 @@ func getPlayerFlagString(p *player.Player, key string) string { return s } -func setPlayerFlag(p *player.Player, key string, val any) { +func (g *Game) setPlayerFlag(p *player.Player, key string, val any) { p.EnsureFlags() + old, existed := p.Flags[key] p.Flags[key] = val + if !existed || !valuesEqual(old, val) { + g.firePlayerFlagTrigger(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 { diff --git a/internal/game/flagstore.go b/internal/game/flagstore.go index 2790d89..bc72c60 100644 --- a/internal/game/flagstore.go +++ b/internal/game/flagstore.go @@ -2,18 +2,29 @@ package game import "sync" +// FlagChangeCallback is invoked when a world flag value actually changes +// (old value differs from new, or new flag is created with a truthy value). +type FlagChangeCallback func(name string, value any) + // FlagStore holds world flags — shared mutable state visible to all players // (e.g. opened doors, quest state). Player-specific flags live on // *player.Player.Flags instead. FlagStore is safe for concurrent use. type FlagStore struct { - mu sync.Mutex - flags map[string]any + mu sync.Mutex + flags map[string]any + callbacks []FlagChangeCallback } func NewFlagStore() *FlagStore { return &FlagStore{flags: make(map[string]any)} } +func (f *FlagStore) OnChange(cb FlagChangeCallback) { + f.mu.Lock() + defer f.mu.Unlock() + f.callbacks = append(f.callbacks, cb) +} + func (f *FlagStore) Get(name string) (any, bool) { f.mu.Lock() defer f.mu.Unlock() @@ -23,15 +34,35 @@ func (f *FlagStore) Get(name string) (any, bool) { func (f *FlagStore) Set(name string, value any) { f.mu.Lock() - defer f.mu.Unlock() + old, existed := f.flags[name] f.flags[name] = value + cbs := f.callbacks + f.mu.Unlock() + + if !existed || !valuesEqual(old, value) { + for _, cb := range cbs { + cb(name, value) + } + } } func (f *FlagStore) SetAll(m map[string]any) { f.mu.Lock() - defer f.mu.Unlock() + changed := make(map[string]any) for k, v := range m { + old, existed := f.flags[k] f.flags[k] = v + if !existed || !valuesEqual(old, v) { + changed[k] = v + } + } + cbs := f.callbacks + f.mu.Unlock() + + for k, v := range changed { + for _, cb := range cbs { + cb(k, v) + } } } diff --git a/internal/game/game.go b/internal/game/game.go index 320304f..acd2924 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -52,6 +52,7 @@ type Game struct { Hub *net.Hub Flags *FlagStore Combat *combat.Tracker + TriggerStore *world.TriggerStore queue *CommandQueue safespot *SafespotManager ValidationConfig config.ValidationConfig @@ -84,6 +85,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali }, Flags: NewFlagStore(), Combat: combat.NewTracker(), + TriggerStore: world.NewTriggerStore(), queue: NewCommandQueue(), safespot: NewSafespotManager(), ValidationConfig: valConfig, @@ -97,6 +99,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali g.LoadMods() g.LoadTechs() g.buildCraftIndex() + g.seedRoomTriggers() g.ValidateAndLog() return g } @@ -116,6 +119,9 @@ func (g *Game) SetHub(hub *net.Hub) { } } }) + g.Flags.OnChange(func(name string, value any) { + g.TriggerStore.FireWorld(name, value) + }) } func (g *Game) HandleSession(sess *net.Session, input string) { @@ -334,6 +340,18 @@ 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/production_menu.go b/internal/game/production_menu.go index 7181a0e..c1d9a96 100644 --- a/internal/game/production_menu.go +++ b/internal/game/production_menu.go @@ -258,7 +258,7 @@ func (g *Game) handleProductChoice(sess *net.Session, input string) { } p.EnsureFlags() - p.Flags[lastFlagKey] = sel.Item.ID + g.setPlayerFlag(p, lastFlagKey, sel.Item.ID) g.AccountStore.SaveCharacter(p) if background { diff --git a/internal/game/sys_assassin.go b/internal/game/sys_assassin.go index 1927399..720529c 100644 --- a/internal/game/sys_assassin.go +++ b/internal/game/sys_assassin.go @@ -51,9 +51,9 @@ func (g *Game) assignAssassinTask(sess *net.Session, p *player.Player) { } } count := chosen.MinCount + rand.Intn(chosen.MaxCount-chosen.MinCount+1) - setPlayerFlag(p, "assassin_task_mob", chosen.MobID) - setPlayerFlag(p, "assassin_task_total", count) - setPlayerFlag(p, "assassin_task_remaining", count) + g.setPlayerFlag(p, "assassin_task_mob", chosen.MobID) + g.setPlayerFlag(p, "assassin_task_total", count) + g.setPlayerFlag(p, "assassin_task_remaining", count) g.AccountStore.SaveCharacter(p) def, err := g.MobStore.LoadDef(chosen.MobID) @@ -79,20 +79,20 @@ func (g *Game) onAssassinKill(sess *net.Session, p *player.Player, mob *world.Mo sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp asm)", xp))) } remaining-- - setPlayerFlag(p, "assassin_task_remaining", remaining) + g.setPlayerFlag(p, "assassin_task_remaining", remaining) if remaining <= 0 { completed := getPlayerFlagInt(p, "assassin_tasks_completed") + 1 streak := getPlayerFlagInt(p, "assassin_streak") + 1 - setPlayerFlag(p, "assassin_tasks_completed", completed) - setPlayerFlag(p, "assassin_streak", streak) + g.setPlayerFlag(p, "assassin_tasks_completed", completed) + g.setPlayerFlag(p, "assassin_streak", streak) delete(p.Flags, "assassin_task_mob") - setPlayerFlag(p, "assassin_task_remaining", 0) - setPlayerFlag(p, "assassin_task_total", 0) + g.setPlayerFlag(p, "assassin_task_remaining", 0) + g.setPlayerFlag(p, "assassin_task_total", 0) rep := 1 bonus := streakBonus(streak) rep += bonus currentRep := getPlayerFlagInt(p, "assassin_reputation") - setPlayerFlag(p, "assassin_reputation", currentRep+rep) + g.setPlayerFlag(p, "assassin_reputation", currentRep+rep) sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\n*** Assassin task complete! ***"))) if bonus > 0 { sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Streak bonus! %d tasks in a row. +%d bonus reputation.", streak, bonus))) @@ -116,11 +116,11 @@ func (g *Game) skipAssassinTask(sess *net.Session, p *player.Player) { sess.WriteLine("You don't have enough Reputation to skip. (Need 30, have " + fmt.Sprint(rep) + ")") return } - setPlayerFlag(p, "assassin_reputation", rep-30) + g.setPlayerFlag(p, "assassin_reputation", rep-30) delete(p.Flags, "assassin_task_mob") - setPlayerFlag(p, "assassin_task_remaining", 0) - setPlayerFlag(p, "assassin_task_total", 0) - setPlayerFlag(p, "assassin_streak", 0) + g.setPlayerFlag(p, "assassin_task_remaining", 0) + g.setPlayerFlag(p, "assassin_task_total", 0) + g.setPlayerFlag(p, "assassin_streak", 0) g.AccountStore.SaveCharacter(p) } @@ -135,15 +135,15 @@ func (g *Game) extendAssassinTask(sess *net.Session, p *player.Player) { sess.WriteLine("You don't have an active task to extend.") return } - setPlayerFlag(p, "assassin_reputation", rep-30) + g.setPlayerFlag(p, "assassin_reputation", rep-30) total := getPlayerFlagInt(p, "assassin_task_total") remaining := getPlayerFlagInt(p, "assassin_task_remaining") extension := total / 2 if extension < 5 { extension = 5 } - setPlayerFlag(p, "assassin_task_total", total+extension) - setPlayerFlag(p, "assassin_task_remaining", remaining+extension) + g.setPlayerFlag(p, "assassin_task_total", total+extension) + g.setPlayerFlag(p, "assassin_task_remaining", remaining+extension) g.AccountStore.SaveCharacter(p) def, _ := g.MobStore.LoadDef(taskMob) diff --git a/internal/game/triggers.go b/internal/game/triggers.go new file mode 100644 index 0000000..76ce9cd --- /dev/null +++ b/internal/game/triggers.go @@ -0,0 +1,291 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) TriggerSeqTick() { + g.processPlayerTriggerSequences() + g.processWorldTriggerSequences() +} + +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("\n%s fades away.", mobDisplayName(inst, true)))) + } + } + } +} + +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 []world.TriggerStep + 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 _, step := range jobs { + g.executeTriggerStep(sess, p, &step, 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) processWorldTriggerSequences() { + seqs := g.TriggerStore.SnapshotWorldSeqs() + for _, seq := range seqs { + seq.Wait-- + if seq.Wait > 0 { + continue + } + + var jobs []world.TriggerStep + 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 _, step := range jobs { + g.executeWorldTriggerStep(&step, seq.RoomID, seq.FlagValue) + } + + if len(seq.Steps) == 0 { + g.TriggerStore.RemoveWorldSeq(seq.TriggerID) + } + } +} + +func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *world.TriggerStep, roomID int, flagValue any) { + if step.Message != "" { + msg := expandTemplate(step.Message, p.Name, flagValue) + sess.WriteLine(msg) + } + if step.Broadcast != "" && g.Hub != nil { + msg := expandTemplate(step.Broadcast, p.Name, flagValue) + for _, other := range g.Hub.PlayersInRoom(roomID) { + other.WriteLine(g.colorize(other, "broadcast", "\n"+msg)) + } + } + if step.BroadcastGlobal != "" && g.Hub != nil { + msg := expandTemplate(step.BroadcastGlobal, p.Name, flagValue) + for _, other := range g.Hub.AllSessions() { + if other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", "\n"+msg)) + } + } + } + if len(step.SetFlags) > 0 { + g.Flags.SetAll(step.SetFlags) + } + if len(step.SetPlayerFlags) > 0 { + for k, v := range step.SetPlayerFlags { + g.setPlayerFlag(p, k, v) + } + g.AccountStore.SaveCharacter(p) + } + if step.GiveItem != "" { + slot := p.FirstFreeSlot() + if slot == -1 { + sess.WriteLine("Your inventory is too full to receive that.") + } else { + p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1}) + g.AccountStore.SaveCharacter(p) + } + } + if step.TakeItem != "" { + if p.HasItem(step.TakeItem) { + p.RemoveItem(step.TakeItem, 1) + g.AccountStore.SaveCharacter(p) + } + } + if step.Heal > 0 { + p.HP += step.Heal + if maxHP := p.MaxHP(); p.HP > maxHP { + p.HP = maxHP + } + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal)) + } + if step.SpawnMob != nil { + g.spawnTriggerMob(sess, p, step.SpawnMob, roomID) + } + if step.DespawnMob != "" { + g.despawnTriggerMobs(step.DespawnMob, p.Name) + } + if step.Teleport > 0 { + g.teleportPlayer(sess, p, step.Teleport) + } +} + +func (g *Game) executeWorldTriggerStep(step *world.TriggerStep, roomID int, flagValue any) { + if step.Broadcast != "" && g.Hub != nil { + msg := expandTemplate(step.Broadcast, "", flagValue) + for _, other := range g.Hub.PlayersInRoom(roomID) { + other.WriteLine(g.colorize(other, "broadcast", "\n"+msg)) + } + } + if step.BroadcastGlobal != "" && g.Hub != nil { + msg := expandTemplate(step.BroadcastGlobal, "", flagValue) + for _, other := range g.Hub.AllSessions() { + if other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", "\n"+msg)) + } + } + } + if len(step.SetFlags) > 0 { + g.Flags.SetAll(step.SetFlags) + } + if step.SpawnMob != nil { + g.spawnWorldTriggerMob(step.SpawnMob, roomID) + } + if step.DespawnMob != "" { + g.despawnTriggerMobs(step.DespawnMob, "") + } +} + +func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *world.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", "\n"+mobDisplayName(inst, true)+" appears!")) +} + +func (g *Game) spawnWorldTriggerMob(cfg *world.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", + "\n"+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("\n%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 +} diff --git a/internal/validate/checks.go b/internal/validate/checks.go index 6aa0a81..197b8d3 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -521,6 +521,74 @@ func validateCourses(s Source) []Issue { return issues } +func validateRoomTriggers(s Source) []Issue { + var issues []Issue + roomIndex := s.World.RoomIndex() + itemIDs := s.Items.IDSet() + mobIDs := s.Mobs.AllDefIDs() + + for id := range roomIndex { + room, err := s.World.LoadRoom(id) + if err != nil || len(room.Triggers) == 0 { + continue + } + for ti, trigger := range room.Triggers { + prefix := fmt.Sprintf("Room %d: trigger[%d]", id, ti) + for si, step := range trigger.Steps { + stepPrefix := fmt.Sprintf("%s: step[%d]", prefix, si) + if step.SpawnMob != nil && step.SpawnMob.ID != "" { + if _, ok := mobIDs[step.SpawnMob.ID]; !ok { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: spawn_mob %q does not exist", + stepPrefix, step.SpawnMob.ID), + }) + } + for _, wr := range step.SpawnMob.DespawnRooms { + if _, ok := roomIndex[wr]; !ok { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: spawn_mob despawn_rooms references nonexistent room %d", + stepPrefix, wr), + }) + } + } + } + if step.GiveItem != "" && !itemIDs[step.GiveItem] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: give_item %q does not exist", + stepPrefix, step.GiveItem), + }) + } + if step.TakeItem != "" && !itemIDs[step.TakeItem] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: take_item %q does not exist", + stepPrefix, step.TakeItem), + }) + } + if step.Teleport > 0 { + if _, ok := roomIndex[step.Teleport]; !ok { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: teleport to nonexistent room %d", + stepPrefix, step.Teleport), + }) + } + } + } + } + } + + return issues +} + func validateRoomWiring(s Source) []Issue { var issues []Issue roomIndex := s.World.RoomIndex() diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 5557f72..3918c95 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -63,6 +63,7 @@ func Run(s Source) []Issue { {"items", "item"}, {"rooms", "room"}, {"mobs", "mob"}, {"objects", "object"}, {"drops", "drop table"}, {"courses", "course"}, {"modules", "module"}, {"techs", "tech"}, {"help", "help topic"}, + {"triggers", "trigger"}, } { issues = append(issues, validateIDs(s.DataDir, dir.sub, dir.label)...) } @@ -75,6 +76,7 @@ func Run(s Source) []Issue { issues = append(issues, validateCraftBlocks(s)...) issues = append(issues, validateDropTables(s)...) issues = append(issues, validateCourses(s)...) + issues = append(issues, validateRoomTriggers(s)...) issues = append(issues, validateTechs(s)...) issues = append(issues, validateRoomWiring(s)...) diff --git a/internal/world/mob.go b/internal/world/mob.go index f11d6fe..d3ea05b 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -127,6 +127,14 @@ type MobInstance struct { CompleteMessage string TalkConfig *behavior.TalkConfig + + Owner string + OwnerOnly bool + SpawnedByTrigger bool + DespawnOnLeave bool + DespawnRooms []int + DespawnTicks float64 + DespawnCounter int } func (m *MobInstance) IsTalkable() bool { return m.TalkConfig != nil } @@ -215,6 +223,20 @@ func (m *MobInstance) StartRegen() { } } +func (m *MobInstance) DespawnTickCount() int { + return m.DespawnCounter +} + +func (m *MobInstance) DecrementDespawnCounter() { + if m.DespawnCounter > 0 { + m.DespawnCounter-- + } +} + +func (m *MobInstance) ResetDespawnCounter(ticks int) { + m.DespawnCounter = ticks +} + type MobStore struct { dataDir string mu sync.Mutex @@ -326,6 +348,29 @@ func (s *MobStore) RollIdleDescription(inst *MobInstance) { inst.WanderTickCounter = 0 } +func (s *MobStore) SpawnTransient(def *MobDef, cfg *SpawnMobConfig, roomID int, owner string) *MobInstance { + s.mu.Lock() + defer s.mu.Unlock() + + instID := fmt.Sprintf("trigger_%s_%d_%d", cfg.ID, roomID, len(s.instances)) + inst := NewMobInstance(def, instID, roomID, nil, 0) + inst.Owner = owner + inst.OwnerOnly = cfg.OwnerOnly + inst.SpawnedByTrigger = true + inst.DespawnOnLeave = cfg.DespawnOnLeave + inst.DespawnRooms = cfg.DespawnRooms + inst.DespawnTicks = cfg.DespawnTicks + inst.IdleDescription = pickIdleDescription(def.IdleDescriptions) + s.instances[instID] = inst + return inst +} + +func (s *MobStore) RemoveInstance(instanceID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.instances, instanceID) +} + func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) { type defWrapper struct { def *MobDef diff --git a/internal/world/room.go b/internal/world/room.go index 71a36ed..22e7f79 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -75,6 +75,7 @@ type Room struct { Mobs []RoomMob `yaml:"mobs"` OnEnter []EnterStep `yaml:"on_enter"` Hazard string `yaml:"hazard"` + Triggers []TriggerDef `yaml:"triggers"` } type RoomMob struct { diff --git a/internal/world/trigger.go b/internal/world/trigger.go new file mode 100644 index 0000000..7c06be4 --- /dev/null +++ b/internal/world/trigger.go @@ -0,0 +1,61 @@ +package world + +import ( + "gopkg.in/yaml.v3" +) + +type TriggerDef struct { + ID string `yaml:"id"` + OnPlayerFlag string `yaml:"on_player_flag"` + OnFlag string `yaml:"on_flag"` + Value any `yaml:"value"` + Room int `yaml:"room"` + Steps []TriggerStep `yaml:"steps"` +} + +func (t *TriggerDef) IsPlayerFlagTrigger() bool { return t.OnPlayerFlag != "" } + +func (t *TriggerDef) IsWorldFlagTrigger() bool { return t.OnFlag != "" } + +type TriggerStep struct { + Delay int `yaml:"delay"` + Message string `yaml:"message"` + Broadcast string `yaml:"broadcast"` + BroadcastGlobal string `yaml:"broadcast_global"` + SetFlags map[string]any `yaml:"set_flags"` + SetPlayerFlags map[string]any `yaml:"set_player_flags"` + SpawnMob *SpawnMobConfig `yaml:"spawn_mob"` + GiveItem string `yaml:"give_item"` + TakeItem string `yaml:"take_item"` + Teleport int `yaml:"teleport"` + Heal int `yaml:"heal"` + DespawnMob string `yaml:"despawn_mob"` +} + +type SpawnMobConfig struct { + ID string `yaml:"id"` + OwnerOnly bool `yaml:"owner_only"` + DespawnOnLeave bool `yaml:"despawn_on_leave"` + DespawnRooms []int `yaml:"despawn_rooms"` + DespawnTicks float64 `yaml:"despawn_ticks"` +} + +func (s *SpawnMobConfig) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + var id string + if err := value.Decode(&id); err != nil { + return err + } + s.ID = id + return nil + } + type raw SpawnMobConfig + return value.Decode((*raw)(s)) +} + +func (t TriggerStep) IsTimed() bool { + return t.Delay > 0 || len(t.SetFlags) > 0 || len(t.SetPlayerFlags) > 0 || + t.Message != "" || t.Broadcast != "" || t.BroadcastGlobal != "" || + t.SpawnMob != nil || t.GiveItem != "" || t.TakeItem != "" || + t.Teleport != 0 || t.Heal != 0 || t.DespawnMob != "" +} diff --git a/internal/world/trigger_store.go b/internal/world/trigger_store.go new file mode 100644 index 0000000..f00d256 --- /dev/null +++ b/internal/world/trigger_store.go @@ -0,0 +1,340 @@ +package world + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "gopkg.in/yaml.v3" +) + +type TriggerStore struct { + mu sync.Mutex + + globalByPlayerFlag map[string][]*TriggerDef + globalByWorldFlag map[string][]*TriggerDef + roomByPlayerFlag map[int]map[string][]*TriggerDef + roomByWorldFlag map[int]map[string][]*TriggerDef + + playerSeqs map[string]*PlayerTriggerSeq + worldSeqs map[string]*WorldTriggerSeq +} + +type PlayerTriggerSeq struct { + PlayerName string + TriggerID string + RoomID int + Steps []TriggerStep + Wait int + Started bool + FlagValue any +} + +type WorldTriggerSeq struct { + TriggerID string + RoomID int + Steps []TriggerStep + Wait int + FlagValue any +} + +func NewTriggerStore() *TriggerStore { + return &TriggerStore{ + globalByPlayerFlag: make(map[string][]*TriggerDef), + globalByWorldFlag: make(map[string][]*TriggerDef), + roomByPlayerFlag: make(map[int]map[string][]*TriggerDef), + roomByWorldFlag: make(map[int]map[string][]*TriggerDef), + playerSeqs: make(map[string]*PlayerTriggerSeq), + worldSeqs: make(map[string]*WorldTriggerSeq), + } +} + +func (ts *TriggerStore) LoadGlobal(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 + } + id := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + data, err := os.ReadFile(path) + if err != nil { + continue + } + var trigger TriggerDef + if err := yaml.Unmarshal(data, &trigger); err != nil { + return fmt.Errorf("trigger %q: %w", path, err) + } + trigger.ID = id + ts.addGlobal(&trigger) + } + return nil +} + +func (ts *TriggerStore) SeedRoomTriggers(roomID int, triggers []TriggerDef) { + if len(triggers) == 0 { + return + } + ts.mu.Lock() + defer ts.mu.Unlock() + for i := range triggers { + t := &triggers[i] + if t.IsPlayerFlagTrigger() { + if ts.roomByPlayerFlag[roomID] == nil { + ts.roomByPlayerFlag[roomID] = make(map[string][]*TriggerDef) + } + ts.roomByPlayerFlag[roomID][t.OnPlayerFlag] = append( + ts.roomByPlayerFlag[roomID][t.OnPlayerFlag], t) + } + if t.IsWorldFlagTrigger() { + if ts.roomByWorldFlag[roomID] == nil { + ts.roomByWorldFlag[roomID] = make(map[string][]*TriggerDef) + } + t.Room = roomID + ts.roomByWorldFlag[roomID][t.OnFlag] = append( + ts.roomByWorldFlag[roomID][t.OnFlag], t) + } + } +} + +func (ts *TriggerStore) addGlobal(t *TriggerDef) { + ts.mu.Lock() + defer ts.mu.Unlock() + if t.IsPlayerFlagTrigger() { + ts.globalByPlayerFlag[t.OnPlayerFlag] = append( + ts.globalByPlayerFlag[t.OnPlayerFlag], t) + } + if t.IsWorldFlagTrigger() { + ts.globalByWorldFlag[t.OnFlag] = append( + ts.globalByWorldFlag[t.OnFlag], t) + } +} + +func (ts *TriggerStore) FirePlayer(playerName string, flagName string, flagValue any) []*PlayerTriggerSeq { + ts.mu.Lock() + defer ts.mu.Unlock() + + var started []*PlayerTriggerSeq + + for _, t := range ts.globalByPlayerFlag[flagName] { + if !ts.valueMatches(t.Value, flagValue) { + continue + } + seq := ts.startPlayerSeqLocked(playerName, t, 0, flagValue) + if seq != nil { + started = append(started, seq) + } + } + + return started +} + +func (ts *TriggerStore) FirePlayerInRoom(playerName string, roomID int, flagName string, flagValue any) []*PlayerTriggerSeq { + ts.mu.Lock() + defer ts.mu.Unlock() + + var started []*PlayerTriggerSeq + + for _, t := range ts.globalByPlayerFlag[flagName] { + if !ts.valueMatches(t.Value, flagValue) { + continue + } + seq := ts.startPlayerSeqLocked(playerName, t, roomID, flagValue) + if seq != nil { + started = append(started, seq) + } + } + + if roomFlags, ok := ts.roomByPlayerFlag[roomID]; ok { + for _, t := range roomFlags[flagName] { + if !ts.valueMatches(t.Value, flagValue) { + continue + } + seq := ts.startPlayerSeqLocked(playerName, t, roomID, flagValue) + if seq != nil { + started = append(started, seq) + } + } + } + + return started +} + +func (ts *TriggerStore) FireWorld(flagName string, flagValue any) []*WorldTriggerSeq { + ts.mu.Lock() + defer ts.mu.Unlock() + + var started []*WorldTriggerSeq + + for _, t := range ts.globalByWorldFlag[flagName] { + if !ts.valueMatches(t.Value, flagValue) { + continue + } + seq := ts.startWorldSeqLocked(t, flagValue) + if seq != nil { + started = append(started, seq) + } + } + + for roomID, roomFlags := range ts.roomByWorldFlag { + for _, t := range roomFlags[flagName] { + if !ts.valueMatches(t.Value, flagValue) { + continue + } + if t.Room == 0 { + t.Room = roomID + } + seq := ts.startWorldSeqLocked(t, flagValue) + if seq != nil { + started = append(started, seq) + } + } + } + + return started +} + +func (ts *TriggerStore) startPlayerSeqLocked(playerName string, t *TriggerDef, roomID int, flagValue any) *PlayerTriggerSeq { + key := playerName + ":" + t.ID + if _, exists := ts.playerSeqs[key]; exists { + return nil + } + seq := &PlayerTriggerSeq{ + PlayerName: playerName, + TriggerID: t.ID, + RoomID: roomID, + Steps: make([]TriggerStep, len(t.Steps)), + FlagValue: flagValue, + } + copy(seq.Steps, t.Steps) + if len(seq.Steps) > 0 { + seq.Wait = seq.Steps[0].Delay + } + ts.playerSeqs[key] = seq + return seq +} + +func (ts *TriggerStore) startWorldSeqLocked(t *TriggerDef, flagValue any) *WorldTriggerSeq { + key := t.ID + if _, exists := ts.worldSeqs[key]; exists { + return nil + } + seq := &WorldTriggerSeq{ + TriggerID: t.ID, + RoomID: t.Room, + Steps: make([]TriggerStep, len(t.Steps)), + FlagValue: flagValue, + } + copy(seq.Steps, t.Steps) + if len(seq.Steps) > 0 { + seq.Wait = seq.Steps[0].Delay + } + ts.worldSeqs[key] = seq + return seq +} + +func (ts *TriggerStore) valueMatches(want, got any) bool { + if want == nil { + return true + } + return valuesEqual(want, got) +} + +func (ts *TriggerStore) SnapshotPlayerSeqs() []*PlayerTriggerSeq { + ts.mu.Lock() + defer ts.mu.Unlock() + out := make([]*PlayerTriggerSeq, 0, len(ts.playerSeqs)) + for _, s := range ts.playerSeqs { + out = append(out, s) + } + return out +} + +func (ts *TriggerStore) SnapshotWorldSeqs() []*WorldTriggerSeq { + ts.mu.Lock() + defer ts.mu.Unlock() + out := make([]*WorldTriggerSeq, 0, len(ts.worldSeqs)) + for _, s := range ts.worldSeqs { + out = append(out, s) + } + return out +} + +func (ts *TriggerStore) RemovePlayerSeq(playerName, triggerID string) { + ts.mu.Lock() + defer ts.mu.Unlock() + key := playerName + ":" + triggerID + delete(ts.playerSeqs, key) +} + +func (ts *TriggerStore) RemoveWorldSeq(triggerID string) { + ts.mu.Lock() + defer ts.mu.Unlock() + delete(ts.worldSeqs, triggerID) +} + +func (ts *TriggerStore) AllGlobalTriggers() []*TriggerDef { + ts.mu.Lock() + defer ts.mu.Unlock() + var out []*TriggerDef + for _, triggers := range ts.globalByPlayerFlag { + out = append(out, triggers...) + } + for _, triggers := range ts.globalByWorldFlag { + out = append(out, triggers...) + } + return out +} + +func valuesEqual(a, b any) bool { + ai, aok := numericValue(a) + bi, bok := numericValue(b) + if aok && bok { + return ai == bi + } + return a == b +} + +func numericValue(v any) (float64, bool) { + switch x := v.(type) { + case int: + return float64(x), true + case int64: + return float64(x), true + case float64: + return x, true + } + return 0, false +} + +func (ts *TriggerStore) AllRoomTriggers() map[int][]*TriggerDef { + ts.mu.Lock() + defer ts.mu.Unlock() + out := make(map[int][]*TriggerDef) + for roomID, byFlag := range ts.roomByPlayerFlag { + for _, triggers := range byFlag { + out[roomID] = append(out[roomID], triggers...) + } + } + for roomID, byFlag := range ts.roomByWorldFlag { + for _, triggers := range byFlag { + out[roomID] = append(out[roomID], triggers...) + } + } + return out +} -- cgit v1.2.3