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 { Table string `yaml:"table"` 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"` } 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"` AttackType string `yaml:"attack_type"` Speed float64 `yaml:"speed"` Aggressive bool `yaml:"aggressive"` RespawnTicks float64 `yaml:"respawn_ticks"` Bonuses MobCombatBonuses `yaml:"bonuses"` Defenses MobCombatDefenses `yaml:"defenses"` } type MobCombat struct { Kind string `yaml:"kind"` 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"` } 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 AttackType string StabDefense int SlashDefense int CrushDefense int ScienceDefense int RangedDefense int Weakness string StealTable string 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 // 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 attackType := "" stabDef := 0 slashDef := 0 crushDef := 0 scienceDef := 0 rangedDef := 0 weakness := "" assassinLevel := 0 finishingBlow := "" damageWithout := "" size := "" kind := "" verb := "" progressNoun := "" completeMessage := "" var combatDescriptions []string stealTable := "" stealLevel := 0 stealXP := 0 stealSpeed := 0.0 if def.Combat != nil { 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 aggressive = cs.Aggressive respawnTicks = cs.RespawnTicks attackBonus = cs.Bonuses.AttackBonus strengthBonus = cs.Bonuses.StrengthBonus attackType = cs.AttackType stabDef = cs.Defenses.StabDefense slashDef = cs.Defenses.SlashDefense crushDef = cs.Defenses.CrushDefense scienceDef = cs.Defenses.ScienceDefense rangedDef = cs.Defenses.RangedDefense weakness = cs.Defenses.Weakness 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 { stealTable = def.Steal.Table 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, AttackType: attackType, StabDefense: stabDef, SlashDefense: slashDef, CrushDefense: crushDef, ScienceDefense: scienceDef, RangedDefense: rangedDef, Weakness: weakness, StealTable: stealTable, 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, } 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 } 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 *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 } }