aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-25 20:33:47 -0400
committerhistoria <[not public]>2026-06-25 20:33:47 -0400
commit84d74ca4351a97148ad8339676f9df7946bc21fb (patch)
treea9f50ac56e8bbcdf2b307ef6f7143b67bce46c61 /internal
parentc55d0e4150b23f2c5c9f96bbc3d4dc2fc6dbaa36 (diff)
downloadthehouseoficarus-84d74ca4351a97148ad8339676f9df7946bc21fb.tar.gz
feat: trigger system for scripted events added.
Diffstat (limited to 'internal')
-rw-r--r--internal/game/act_agility.go2
-rw-r--r--internal/game/act_farm.go80
-rw-r--r--internal/game/act_fletch.go2
-rw-r--r--internal/game/act_room.go3
-rw-r--r--internal/game/act_steal.go4
-rw-r--r--internal/game/act_talk.go11
-rw-r--r--internal/game/cmd_craft.go2
-rw-r--r--internal/game/combat_attack.go4
-rw-r--r--internal/game/combat_mob.go15
-rw-r--r--internal/game/core_flags.go16
-rw-r--r--internal/game/flagstore.go39
-rw-r--r--internal/game/game.go18
-rw-r--r--internal/game/production_menu.go2
-rw-r--r--internal/game/sys_assassin.go32
-rw-r--r--internal/game/triggers.go291
-rw-r--r--internal/validate/checks.go68
-rw-r--r--internal/validate/validate.go2
-rw-r--r--internal/world/mob.go45
-rw-r--r--internal/world/room.go1
-rw-r--r--internal/world/trigger.go61
-rw-r--r--internal/world/trigger_store.go340
21 files changed, 963 insertions, 75 deletions
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
+}