package world import ( "fmt" "math/rand" "os" "path/filepath" "strings" "sync" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" ) type MobDropTable struct { Remains string `yaml:"remains"` Loot []behavior.DropEntry `yaml:"loot"` } type MobSteal struct { Drops []behavior.DropEntry `yaml:"drops"` Level int `yaml:"level"` XP int `yaml:"xp"` Speed float64 `yaml:"speed"` } type MobTask struct { Verb string `yaml:"verb"` ProgressNoun string `yaml:"progress_noun"` CompleteMessage string `yaml:"complete_message"` } type MobCombatBonuses struct { AttackBonus int `yaml:"attack_bonus"` StrengthBonus int `yaml:"strength_bonus"` ScienceBonus int `yaml:"science_bonus"` SciencePercentBonus int `yaml:"science_percent_bonus"` RangedBonus int `yaml:"ranged_bonus"` RangedStrengthBonus int `yaml:"ranged_strength_bonus"` } type MobCombatDefenses struct { StabDefense int `yaml:"stab_defense"` SlashDefense int `yaml:"slash_defense"` CrushDefense int `yaml:"crush_defense"` ScienceDefense int `yaml:"science_defense"` RangedDefense int `yaml:"ranged_defense"` Weakness string `yaml:"weakness"` WeaknessPercent int `yaml:"weakness_percent"` } type MobCombatStats struct { HP int `yaml:"hp"` Attack int `yaml:"attack"` Strength int `yaml:"strength"` Defense int `yaml:"defense"` Ranged int `yaml:"ranged"` Science int `yaml:"science"` Speed float64 `yaml:"speed"` MaxMeleeHit int `yaml:"max_melee_hit"` MaxRangedHit int `yaml:"max_ranged_hit"` MaxScienceHit int `yaml:"max_science_hit"` Bonuses MobCombatBonuses `yaml:"bonuses"` Defenses MobCombatDefenses `yaml:"defenses"` } type MobCombat struct { Kind string `yaml:"kind"` AttackTypes []string `yaml:"attack_types"` Aggressive bool `yaml:"aggressive"` RespawnTicks float64 `yaml:"respawn_ticks"` Stats MobCombatStats `yaml:"stats"` AssassinLevel int `yaml:"assassin_level"` FinishingBlow string `yaml:"finishing_blow"` DamageWithout string `yaml:"damage_without"` Size string `yaml:"size"` CombatDescriptions []string `yaml:"combat_descriptions"` } type MobDef struct { ID string `yaml:"id"` Name string `yaml:"name"` Description string `yaml:"description"` IdleDescriptions []string `yaml:"idle_descriptions"` Protected bool `yaml:"protected"` Unique bool `yaml:"unique"` Drops MobDropTable `yaml:"drops"` Steal *MobSteal `yaml:"steal,omitempty"` Task *MobTask `yaml:"task,omitempty"` Combat *MobCombat `yaml:"combat,omitempty"` Talk *behavior.TalkConfig `yaml:"talk,omitempty"` Shop *behavior.ShopConfig `yaml:"shop,omitempty"` OnKill []behavior.Trigger `yaml:"on_kill,omitempty"` } func (d *MobDef) IsTalkable() bool { return d.Talk != nil } func (d *MobDef) HasShop() bool { return d.Shop != nil } func (d *MobDef) IsTask() bool { return d.Combat != nil && d.Combat.Kind == "task" } type MobInstance struct { InstanceID string DefID string Name string Description string HP int MaxHP int Attack int Strength int Defense int Ranged int Science int Speed float64 Aggressive bool Protected bool Unique bool RespawnTicks float64 RoomID int HomeRoomID int Drops MobDropTable IdleDescription string CombatDescriptions []string WanderRooms []int WanderInterval float64 WanderTickCounter int regenerateTick int AttackBonus int StrengthBonus int AttackTypes []string RangedBonus int ScienceBonus int SciencePercentBonus int StabDefense int SlashDefense int CrushDefense int ScienceDefense int RangedDefense int Weakness string WeaknessPercent int MaxMeleeHit int MaxRangedHit int MaxScienceHit int StealDrops []behavior.DropEntry StealLevel int StealXP int StealSpeed float64 AssassinLevel int FinishingBlow string DamageWithout string Size string Kind string Verb string ProgressNoun string CompleteMessage string TalkConfig *behavior.TalkConfig // OnKill is copied from the def at spawn time and fires from endCombat // when this mob is defeated — either by a combat kill or by a task mob's // HP draining to zero (the "completion" of the work). The first entry // whose item filter, Condition, and the first-match-wins rule all pass // fires. OnKill []behavior.Trigger // Shop is the (shared, read-only) shop config from the def. Per-instance // live stock is tracked in ShopStock; ShopRestock holds per-item countdown // timers. All three are only mutated under MobStore.mu. Shop *behavior.ShopConfig ShopStock map[string]int ShopRestock map[string]int Owner string OwnerOnly bool SpawnedByTrigger bool DespawnOnLeave bool DespawnRooms []int DespawnTicks float64 DespawnCounter int } func (m *MobInstance) IsTalkable() bool { return m.TalkConfig != nil } func (m *MobInstance) HasShop() bool { return m.Shop != nil } func (m *MobInstance) IsTask() bool { return m.Kind == "task" } func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []int, wanderInterval float64) *MobInstance { hp := 0 attack := 0 strength := 0 defense := 0 ranged := 0 science := 0 speed := 0.0 aggressive := false respawnTicks := 0.0 attackBonus := 0 strengthBonus := 0 rangedBonus := 0 scienceBonus := 0 sciencePercentBonus := 0 var attackType []string stabDef := 0 slashDef := 0 crushDef := 0 scienceDef := 0 rangedDef := 0 weakness := "" weaknessPercent := 0 maxMeleeHit := 0 maxRangedHit := 0 maxScienceHit := 0 assassinLevel := 0 finishingBlow := "" damageWithout := "" size := "" kind := "" verb := "" progressNoun := "" completeMessage := "" var combatDescriptions []string var stealDrops []behavior.DropEntry stealLevel := 0 stealXP := 0 stealSpeed := 0.0 if def.Combat != nil { aggressive = def.Combat.Aggressive respawnTicks = def.Combat.RespawnTicks attackType = append([]string(nil), def.Combat.AttackTypes...) cs := def.Combat.Stats hp = cs.HP attack = cs.Attack strength = cs.Strength defense = cs.Defense ranged = cs.Ranged science = cs.Science speed = cs.Speed attackBonus = cs.Bonuses.AttackBonus strengthBonus = cs.Bonuses.StrengthBonus rangedBonus = cs.Bonuses.RangedBonus scienceBonus = cs.Bonuses.ScienceBonus sciencePercentBonus = cs.Bonuses.SciencePercentBonus stabDef = cs.Defenses.StabDefense slashDef = cs.Defenses.SlashDefense crushDef = cs.Defenses.CrushDefense scienceDef = cs.Defenses.ScienceDefense rangedDef = cs.Defenses.RangedDefense weakness = cs.Defenses.Weakness weaknessPercent = cs.Defenses.WeaknessPercent maxMeleeHit = cs.MaxMeleeHit maxRangedHit = cs.MaxRangedHit maxScienceHit = cs.MaxScienceHit assassinLevel = def.Combat.AssassinLevel finishingBlow = def.Combat.FinishingBlow damageWithout = def.Combat.DamageWithout size = def.Combat.Size kind = def.Combat.Kind combatDescriptions = def.Combat.CombatDescriptions } if def.Steal != nil { stealDrops = def.Steal.Drops stealLevel = def.Steal.Level stealXP = def.Steal.XP stealSpeed = def.Steal.Speed } if def.Task != nil { verb = def.Task.Verb progressNoun = def.Task.ProgressNoun completeMessage = def.Task.CompleteMessage } if def.Protected && hp <= 0 { hp = 1 } inst := &MobInstance{ InstanceID: instanceID, DefID: def.ID, Name: def.Name, Description: def.Description, HP: hp, MaxHP: hp, Attack: attack, Strength: strength, Defense: defense, Ranged: ranged, Science: science, Speed: speed, Aggressive: aggressive, Protected: def.Protected, Unique: def.Unique, RespawnTicks: respawnTicks, RoomID: roomID, HomeRoomID: roomID, WanderRooms: wanderRooms, WanderInterval: wanderInterval, Drops: def.Drops, AttackBonus: attackBonus, StrengthBonus: strengthBonus, AttackTypes: attackType, RangedBonus: rangedBonus, ScienceBonus: scienceBonus, SciencePercentBonus: sciencePercentBonus, StabDefense: stabDef, SlashDefense: slashDef, CrushDefense: crushDef, ScienceDefense: scienceDef, RangedDefense: rangedDef, Weakness: weakness, WeaknessPercent: weaknessPercent, MaxMeleeHit: maxMeleeHit, MaxRangedHit: maxRangedHit, MaxScienceHit: maxScienceHit, StealDrops: stealDrops, StealLevel: stealLevel, StealXP: stealXP, StealSpeed: stealSpeed, AssassinLevel: assassinLevel, FinishingBlow: finishingBlow, DamageWithout: damageWithout, Size: size, Kind: kind, Verb: verb, ProgressNoun: progressNoun, CompleteMessage: completeMessage, CombatDescriptions: combatDescriptions, TalkConfig: def.Talk, OnKill: def.OnKill, } if def.Shop != nil { inst.Shop = def.Shop inst.ShopStock = make(map[string]int) inst.ShopRestock = make(map[string]int) for i := range def.Shop.Items { it := &def.Shop.Items[i] if it.ItemID == "" { continue } inst.ShopStock[it.ItemID] = it.Stock inst.ShopRestock[it.ItemID] = def.Shop.RestockInterval(it) } } return inst } const ( MatchNone = 0 MatchPrefix = 1 MatchExact = 2 ) func (m *MobInstance) MatchQuality(input string) int { lower := strings.ToLower(input) if strings.ToLower(m.Name) == lower { return MatchExact } if behavior.WordPrefixMatch(input, m.Name) { return MatchPrefix } return MatchNone } func pickIdleDescription(descriptions []string) string { if len(descriptions) == 0 { return "" } return descriptions[rand.Intn(len(descriptions))] } func (m *MobInstance) StartRegen() { if m.regenerateTick == 0 { m.regenerateTick = 100 } } 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 pathIndex map[string]string defs map[string]*MobDef instances map[string]*MobInstance } func NewMobStore(dataDir string) *MobStore { return &MobStore{ dataDir: dataDir, pathIndex: behavior.BuildPathIndex(filepath.Join(dataDir, "mobs")), defs: make(map[string]*MobDef), instances: make(map[string]*MobInstance), } } func (s *MobStore) LoadDef(id string) (*MobDef, error) { s.mu.Lock() if def, ok := s.defs[id]; ok { s.mu.Unlock() return def, nil } s.mu.Unlock() path, ok := s.pathIndex[id] if !ok { return nil, fmt.Errorf("read mob %s: no such mob", id) } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read mob %s: %w", id, err) } var def MobDef if err := yaml.Unmarshal(data, &def); err != nil { return nil, fmt.Errorf("parse mob %s: %w", id, err) } def.ID = id s.mu.Lock() s.defs[id] = &def s.mu.Unlock() return &def, nil } func (s *MobStore) AllDefIDs() map[string]bool { ids := make(map[string]bool) for id := range s.pathIndex { ids[id] = true } return ids } func (s *MobStore) ReloadDefs(dataDir string) { s.mu.Lock() defer s.mu.Unlock() s.defs = make(map[string]*MobDef) s.pathIndex = behavior.BuildPathIndex(filepath.Join(dataDir, "mobs")) } func (s *MobStore) GetInstance(id string) *MobInstance { s.mu.Lock() defer s.mu.Unlock() return s.instances[id] } func (s *MobStore) AllInstances() []*MobInstance { s.mu.Lock() defer s.mu.Unlock() var out []*MobInstance for _, inst := range s.instances { out = append(out, inst) } return out } // SetInstanceRoom updates a mob instance's room under the store lock, so it is // safe against concurrent readers (MobsInRoom, RemoveMobsInRoom) in other // goroutines. func (s *MobStore) SetInstanceRoom(instanceID string, roomID int) { s.mu.Lock() defer s.mu.Unlock() if inst := s.instances[instanceID]; inst != nil { inst.RoomID = roomID } } func (s *MobStore) MobsInRoom(roomID int) []*MobInstance { s.mu.Lock() defer s.mu.Unlock() var out []*MobInstance for _, inst := range s.instances { if inst.RoomID == roomID && inst.HP > 0 { out = append(out, inst) } } return out } func (s *MobStore) Tick() { s.mu.Lock() defer s.mu.Unlock() for _, inst := range s.instances { if inst.Shop != nil { s.tickShopLocked(inst) } if inst.HP <= 0 || inst.HP >= inst.MaxHP { inst.regenerateTick = 0 continue } inst.regenerateTick-- if inst.regenerateTick <= 0 { inst.HP++ if inst.HP >= inst.MaxHP { inst.HP = inst.MaxHP inst.regenerateTick = 0 } else { inst.regenerateTick = 100 } } } } // tickShopLocked advances restock timers for one shop instance. Current stock // converges toward each item's target (configured stock for listed items, 0 for // dynamically-acquired items) one unit per restock interval, in either // direction. Caller must hold s.mu. func (s *MobStore) tickShopLocked(inst *MobInstance) { for id, cur := range inst.ShopStock { target := 0 var cfgItem *behavior.ShopItem if cfgItem = inst.Shop.FindItem(id); cfgItem != nil { target = cfgItem.Stock } if cur == target { if cur == 0 && cfgItem == nil { delete(inst.ShopStock, id) delete(inst.ShopRestock, id) } continue } c := inst.ShopRestock[id] if c <= 0 { c = inst.Shop.RestockInterval(cfgItem) } c-- if c <= 0 { if cur < target { cur++ } else { cur-- } inst.ShopStock[id] = cur c = inst.Shop.RestockInterval(cfgItem) } inst.ShopRestock[id] = c if cur == 0 && cfgItem == nil { delete(inst.ShopStock, id) delete(inst.ShopRestock, id) } } } // ShopStockSnapshot returns a copy of the instance's current stock map. func (s *MobStore) ShopStockSnapshot(instanceID string) map[string]int { s.mu.Lock() defer s.mu.Unlock() inst := s.instances[instanceID] if inst == nil || inst.ShopStock == nil { return nil } out := make(map[string]int, len(inst.ShopStock)) for k, v := range inst.ShopStock { out[k] = v } return out } // ShopStockOf returns the current stock the instance holds of an item. func (s *MobStore) ShopStockOf(instanceID, itemID string) int { s.mu.Lock() defer s.mu.Unlock() inst := s.instances[instanceID] if inst == nil || inst.ShopStock == nil { return 0 } return inst.ShopStock[itemID] } // ShopTake removes up to qty of an item from the shop's stock, returning the // amount actually taken (capped by availability). Used when a player buys. func (s *MobStore) ShopTake(instanceID, itemID string, qty int) int { s.mu.Lock() defer s.mu.Unlock() inst := s.instances[instanceID] if inst == nil || inst.ShopStock == nil { return 0 } have := inst.ShopStock[itemID] if qty > have { qty = have } if qty <= 0 { return 0 } inst.ShopStock[itemID] = have - qty return qty } // ShopAdd adds qty of an item to the shop's stock. Used when a player sells. func (s *MobStore) ShopAdd(instanceID, itemID string, qty int) { if qty <= 0 { return } s.mu.Lock() defer s.mu.Unlock() inst := s.instances[instanceID] if inst == nil || inst.Shop == nil { return } if inst.ShopStock == nil { inst.ShopStock = make(map[string]int) } if inst.ShopRestock == nil { inst.ShopRestock = make(map[string]int) } if _, ok := inst.ShopStock[itemID]; !ok { inst.ShopRestock[itemID] = inst.Shop.RestockInterval(inst.Shop.FindItem(itemID)) } inst.ShopStock[itemID] += qty } func (s *MobStore) RollIdleDescription(inst *MobInstance) { def, err := s.LoadDef(inst.DefID) if err != nil { return } inst.IdleDescription = pickIdleDescription(def.IdleDescriptions) inst.WanderTickCounter = 0 } func (s *MobStore) SpawnTransient(def *MobDef, cfg *behavior.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) RemoveMobsInRoom(roomID int) { s.mu.Lock() defer s.mu.Unlock() for id, inst := range s.instances { if inst.RoomID == roomID { delete(s.instances, id) } } } func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) { type defWrapper struct { def *MobDef err error rm RoomMob } defs := make([]defWrapper, len(entries)) for i, rm := range entries { d, err := s.LoadDef(rm.ID) defs[i] = defWrapper{d, err, rm} } s.mu.Lock() defer s.mu.Unlock() for i, dw := range defs { if dw.err != nil { continue } defID := dw.rm.ID instID := fmt.Sprintf("%s_%d_%d", defID, roomID, i) if _, exists := s.instances[instID]; exists { continue } inst := NewMobInstance(dw.def, instID, roomID, dw.rm.WanderRooms, dw.rm.WanderInterval) inst.IdleDescription = pickIdleDescription(dw.def.IdleDescriptions) s.instances[instID] = inst } }