Files
TheriapolisV3/Theriapolis.Core/Entities/NpcActor.cs
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

195 lines
8.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 18 by enum order).
/// - <see cref="HasRecentlyKilled"/> → <see cref="ScentTag.RecentlyKilled"/>.
/// - HP &lt; 25% (or low-HP fleeing) → <see cref="ScentTag.Frightened"/>.
/// - HP &lt; 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 18). 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; }
}