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>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
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; }
}