195 lines
8.6 KiB
C#
195 lines
8.6 KiB
C#
|
|
using Theriapolis.Core.Data;
|
|||
|
|
using Theriapolis.Core.Util;
|
|||
|
|
|
|||
|
|
namespace Theriapolis.Core.Entities;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Phase 5 M5 NPC actor. Distinct from <see cref="PlayerActor"/>: stat-block
|
|||
|
|
/// driven (no <see cref="Rules.Character.Character"/>), HP/AC/attacks read
|
|||
|
|
/// directly from the source <see cref="NpcTemplateDef"/>. The
|
|||
|
|
/// <see cref="Combatant"/> wrapper is built from this on encounter start.
|
|||
|
|
///
|
|||
|
|
/// AI behavior is dispatched by id (the template's <c>behavior</c> 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.
|
|||
|
|
/// </summary>
|
|||
|
|
public sealed class NpcActor : Actor
|
|||
|
|
{
|
|||
|
|
/// <summary>
|
|||
|
|
/// Phase 5 hostile/wild template. Mutually exclusive with <see cref="Resident"/>.
|
|||
|
|
/// One of the two must be non-null.
|
|||
|
|
/// </summary>
|
|||
|
|
public NpcTemplateDef? Template { get; }
|
|||
|
|
|
|||
|
|
/// <summary>Phase 6 M1 friendly/neutral resident template.</summary>
|
|||
|
|
public ResidentTemplateDef? Resident { get; }
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Phase 6 M1 — display name (e.g. "Mara Threadwell"). For Phase-5 NPCs
|
|||
|
|
/// falls back to <see cref="NpcTemplateDef.Name"/>; for residents uses
|
|||
|
|
/// <see cref="ResidentTemplateDef.Name"/>.
|
|||
|
|
/// </summary>
|
|||
|
|
public string DisplayName => Resident?.Name ?? Template?.Name ?? "NPC";
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 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.
|
|||
|
|
/// </summary>
|
|||
|
|
public string RoleTag { get; init; } = "";
|
|||
|
|
|
|||
|
|
/// <summary>Phase 6 M1 — dialogue tree id (matches dialogues/*.json). Empty → InteractionScreen falls back to placeholder.</summary>
|
|||
|
|
public string DialogueId { get; init; } = "";
|
|||
|
|
|
|||
|
|
/// <summary>Phase 6 M1 — bias profile id used in disposition formula.</summary>
|
|||
|
|
public string BiasProfileId { get; init; } = "";
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Phase 6 M1 / M5 — faction affiliation id. Set automatically from
|
|||
|
|
/// <see cref="Data.NpcTemplateDef.Faction"/> or
|
|||
|
|
/// <see cref="Data.ResidentTemplateDef.Faction"/> at construction.
|
|||
|
|
/// Can also be set directly via init for testing / scripting.
|
|||
|
|
/// </summary>
|
|||
|
|
public string FactionId { get; init; } = "";
|
|||
|
|
|
|||
|
|
/// <summary>Current HP. Mutated by combat; written back from <see cref="Rules.Combat.Combatant"/> when an encounter ends.</summary>
|
|||
|
|
public int CurrentHp { get; set; }
|
|||
|
|
|
|||
|
|
/// <summary>HP at full. From whichever template populated this actor.</summary>
|
|||
|
|
public int MaxHp { get; }
|
|||
|
|
|
|||
|
|
/// <summary>The chunk this NPC was spawned from + its index in <c>chunk.Spawns</c>. Null when spawned outside the chunk system.</summary>
|
|||
|
|
public Tactical.ChunkCoord? SourceChunk { get; init; }
|
|||
|
|
public int? SourceSpawnIndex { get; init; }
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Phase 6 M5 — settlement this NPC belongs to. Set by
|
|||
|
|
/// <see cref="Rules.Combat.ResidentInstantiator"/> at spawn time when
|
|||
|
|
/// the NPC was placed inside a building footprint. Drives
|
|||
|
|
/// <see cref="Rules.Reputation.RepPropagation"/> when computing the
|
|||
|
|
/// faction standing this NPC perceives.
|
|||
|
|
/// </summary>
|
|||
|
|
public int? HomeSettlementId { get; init; }
|
|||
|
|
|
|||
|
|
/// <summary>Per-NPC AI scratchpad. Behaviors read/write here between turns.</summary>
|
|||
|
|
public AiState Ai { get; } = new();
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Phase 6.5 M6 — runtime scent-tag flags. Set by combat events
|
|||
|
|
/// (<see cref="HasRecentlyKilled"/> on melee kill, etc.) and
|
|||
|
|
/// surfaced through <see cref="ComputeScentTags"/>.
|
|||
|
|
///
|
|||
|
|
/// Faction-derived tags are NOT stored here — they're computed from
|
|||
|
|
/// <see cref="FactionId"/> on demand. Only flags that result from
|
|||
|
|
/// runtime activity (kills, fleeing, low HP) live as state.
|
|||
|
|
/// </summary>
|
|||
|
|
public bool HasRecentlyKilled { get; set; }
|
|||
|
|
public bool CarriesContrabandFlag { get; set; }
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Phase 6.5 M7 — sticky aggro flag set by
|
|||
|
|
/// <see cref="Rules.Reputation.BetrayalCascade"/> 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.
|
|||
|
|
/// </summary>
|
|||
|
|
public bool PermanentAggroAfterBetrayal { get; set; }
|
|||
|
|
|
|||
|
|
/// <summary>Behavior id used by combat AI dispatch. Residents return "resident" (M1 stub — they don't move).</summary>
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>Phase 6 M1 — resident-template constructor.</summary>
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 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 <paramref name="maxCount"/> entries.
|
|||
|
|
///
|
|||
|
|
/// Standard tiers:
|
|||
|
|
/// - Scent Literacy (level 1): maxCount = 1 — the headline read.
|
|||
|
|
/// - Scent Mastery (master_nose, level 11): maxCount = 3 — fuller picture.
|
|||
|
|
///
|
|||
|
|
/// Tag derivation:
|
|||
|
|
/// - <see cref="FactionId"/> resolves to a single faction-affiliation
|
|||
|
|
/// tag (priority 1–8 by enum order).
|
|||
|
|
/// - <see cref="HasRecentlyKilled"/> → <see cref="ScentTag.RecentlyKilled"/>.
|
|||
|
|
/// - HP < 25% (or low-HP fleeing) → <see cref="ScentTag.Frightened"/>.
|
|||
|
|
/// - HP < 50% → <see cref="ScentTag.Wounded"/>.
|
|||
|
|
/// - <see cref="CarriesContrabandFlag"/> → <see cref="ScentTag.CarriesContraband"/>.
|
|||
|
|
/// </summary>
|
|||
|
|
public List<ScentTag> ComputeScentTags(int maxCount = 1)
|
|||
|
|
{
|
|||
|
|
if (maxCount <= 0) return new List<ScentTag>();
|
|||
|
|
var list = new List<ScentTag>();
|
|||
|
|
|
|||
|
|
// 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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>Mutable per-NPC AI state. Behaviors read/write between turns.</summary>
|
|||
|
|
public sealed class AiState
|
|||
|
|
{
|
|||
|
|
/// <summary>Last position where this NPC saw a hostile target. Used to chase after losing line-of-sight.</summary>
|
|||
|
|
public Vec2? LastSeenTargetPos { get; set; }
|
|||
|
|
|
|||
|
|
/// <summary>Turns since LastSeenTargetPos was updated. After N turns, the NPC gives up and returns to home.</summary>
|
|||
|
|
public int TurnsSinceLastSeen { get; set; }
|
|||
|
|
|
|||
|
|
/// <summary>"Home" position the NPC drifts back to (PoiGuard patrol anchor). Null = no home, just stand still.</summary>
|
|||
|
|
public Vec2? HomePos { get; set; }
|
|||
|
|
|
|||
|
|
/// <summary>Currently engaged (in combat). Cleared on encounter end.</summary>
|
|||
|
|
public bool InCombat { get; set; }
|
|||
|
|
}
|