using Theriapolis.Core.Data; using Theriapolis.Core.Util; namespace Theriapolis.Core.Entities; /// /// Phase 5 M5 NPC actor. Distinct from : stat-block /// driven (no ), HP/AC/attacks read /// directly from the source . The /// wrapper is built from this on encounter start. /// /// AI behavior is dispatched by id (the template's behavior field) /// — the ActorManager doesn't tick behaviors itself; combat does, and the /// ChunkStreamer-driven spawn/despawn lifecycle keeps the live actor list /// in sync with the player's tactical window. /// public sealed class NpcActor : Actor { /// /// Phase 5 hostile/wild template. Mutually exclusive with . /// One of the two must be non-null. /// public NpcTemplateDef? Template { get; } /// Phase 6 M1 friendly/neutral resident template. public ResidentTemplateDef? Resident { get; } /// /// Phase 6 M1 — display name (e.g. "Mara Threadwell"). For Phase-5 NPCs /// falls back to ; for residents uses /// . /// public string DisplayName => Resident?.Name ?? Template?.Name ?? "NPC"; /// /// Phase 6 M1 — role tag (anchor-qualified for named NPCs: /// "millhaven.innkeeper", or generic: "shopkeeper"). Empty for hostile /// NPCs that don't carry a settlement role. /// public string RoleTag { get; init; } = ""; /// Phase 6 M1 — dialogue tree id (matches dialogues/*.json). Empty → InteractionScreen falls back to placeholder. public string DialogueId { get; init; } = ""; /// Phase 6 M1 — bias profile id used in disposition formula. public string BiasProfileId { get; init; } = ""; /// /// Phase 6 M1 / M5 — faction affiliation id. Set automatically from /// or /// at construction. /// Can also be set directly via init for testing / scripting. /// public string FactionId { get; init; } = ""; /// Current HP. Mutated by combat; written back from when an encounter ends. public int CurrentHp { get; set; } /// HP at full. From whichever template populated this actor. public int MaxHp { get; } /// The chunk this NPC was spawned from + its index in chunk.Spawns. Null when spawned outside the chunk system. public Tactical.ChunkCoord? SourceChunk { get; init; } public int? SourceSpawnIndex { get; init; } /// /// Phase 6 M5 — settlement this NPC belongs to. Set by /// at spawn time when /// the NPC was placed inside a building footprint. Drives /// when computing the /// faction standing this NPC perceives. /// public int? HomeSettlementId { get; init; } /// Per-NPC AI scratchpad. Behaviors read/write here between turns. public AiState Ai { get; } = new(); /// /// Phase 6.5 M6 — runtime scent-tag flags. Set by combat events /// ( on melee kill, etc.) and /// surfaced through . /// /// Faction-derived tags are NOT stored here — they're computed from /// on demand. Only flags that result from /// runtime activity (kills, fleeing, low HP) live as state. /// public bool HasRecentlyKilled { get; set; } public bool CarriesContrabandFlag { get; set; } /// /// Phase 6.5 M7 — sticky aggro flag set by /// on a betrayed /// guard / patrol NPC. Once set, the NPC attacks on sight regardless /// of faction-standing recovery — they remember. /// /// Flag survives chunk despawn/respawn for *named* NPCs (their /// PersonalDisposition.Memory.betrayed_me tag drives re-application /// at re-instantiation). Generic respawning NPCs lose it on chunk /// re-stream — they're literally a fresh template-rolled NPC, the /// betrayal followed the role tag, not the entity. /// public bool PermanentAggroAfterBetrayal { get; set; } /// Behavior id used by combat AI dispatch. Residents return "resident" (M1 stub — they don't move). public string BehaviorId => Template?.Behavior ?? "resident"; public override bool IsAlive => CurrentHp > 0; public NpcActor(NpcTemplateDef template) { Template = template ?? throw new System.ArgumentNullException(nameof(template)); Resident = null; CurrentHp = template.Hp; MaxHp = template.Hp; Allegiance = Rules.Character.AllegianceExtensions.FromJson(template.DefaultAllegiance); // Phase 6 M5 — pick up the template's faction id so propagation can // re-classify (e.g. militia_patrol → covenant_enforcers). FactionId = template.Faction; } /// Phase 6 M1 — resident-template constructor. public NpcActor(ResidentTemplateDef resident) { Template = null; Resident = resident ?? throw new System.ArgumentNullException(nameof(resident)); CurrentHp = resident.Hp; MaxHp = resident.Hp; RoleTag = resident.RoleTag; DialogueId = resident.Dialogue; BiasProfileId = resident.BiasProfile; FactionId = resident.Faction; Allegiance = Rules.Character.AllegianceExtensions.FromJson(resident.DefaultAllegiance); } /// /// Phase 6.5 M6 — compute the ordered scent-tag list for a Scent-Broker /// reading this NPC. Higher-priority tags sort first; ties broken by /// enum order. Returns at most entries. /// /// Standard tiers: /// - Scent Literacy (level 1): maxCount = 1 — the headline read. /// - Scent Mastery (master_nose, level 11): maxCount = 3 — fuller picture. /// /// Tag derivation: /// - resolves to a single faction-affiliation /// tag (priority 1–8 by enum order). /// - . /// - HP < 25% (or low-HP fleeing) → . /// - HP < 50% → . /// - . /// public List ComputeScentTags(int maxCount = 1) { if (maxCount <= 0) return new List(); var list = new List(); // Faction-derived (priority 1–8). At most one — an NPC carries one // faction's chemistry strongly. var factionTag = ScentTagExtensions.FromFactionId(FactionId); if (factionTag != ScentTag.None) list.Add(factionTag); // Runtime-derived. Order matters — most informative first. if (HasRecentlyKilled) list.Add(ScentTag.RecentlyKilled); // Distress markers — cheaper of the two when triggered. float hpFraction = MaxHp > 0 ? (float)CurrentHp / MaxHp : 1f; if (hpFraction < 0.25f && CurrentHp > 0) list.Add(ScentTag.Frightened); else if (hpFraction < 0.50f && CurrentHp > 0) list.Add(ScentTag.Wounded); if (CarriesContrabandFlag) list.Add(ScentTag.CarriesContraband); // Truncate to the cap. if (list.Count > maxCount) list.RemoveRange(maxCount, list.Count - maxCount); return list; } } /// Mutable per-NPC AI state. Behaviors read/write between turns. public sealed class AiState { /// Last position where this NPC saw a hostile target. Used to chase after losing line-of-sight. public Vec2? LastSeenTargetPos { get; set; } /// Turns since LastSeenTargetPos was updated. After N turns, the NPC gives up and returns to home. public int TurnsSinceLastSeen { get; set; } /// "Home" position the NPC drifts back to (PoiGuard patrol anchor). Null = no home, just stand still. public Vec2? HomePos { get; set; } /// Currently engaged (in combat). Cleared on encounter end. public bool InCombat { get; set; } }