b451f83174
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>
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; }
|
||
}
|