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
+45
View File
@@ -0,0 +1,45 @@
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Entities;
/// <summary>
/// Base actor record. Position is in world-pixel space and is meaningful in
/// both the world-map view and the tactical view (see Section 5 of the
/// implementation plan — one canonical coordinate system).
///
/// Phase 5 adds the optional <see cref="Character"/> attachment carrying all
/// gameplay state (stats, inventory, HP, conditions). The renderer layer
/// stays ignorant of the gameplay layer — Camera2D doesn't need to know HP.
/// </summary>
public class Actor
{
/// <summary>Stable id assigned by <see cref="ActorManager"/>.</summary>
public int Id { get; init; }
/// <summary>World-pixel position. Reused at both zoom scales.</summary>
public Vec2 Position { get; set; }
/// <summary>Facing angle in radians (0 = east, π/2 = south).</summary>
public float FacingAngleRad { get; set; }
/// <summary>Continuous-time travel speed on the world map, in world pixels per real second.</summary>
public float SpeedWorldPxPerSec { get; set; } = C.PLAYER_TRAVEL_PX_PER_SEC;
/// <summary>
/// Phase 5: gameplay state. Null when this actor isn't combat-capable
/// (cosmetic NPCs, future-phase additions). PlayerActor and NpcActor
/// always carry one.
/// </summary>
public Character? Character { get; set; }
/// <summary>Whose side this actor is on. Drives encounter triggering and AI targeting.</summary>
public Allegiance Allegiance { get; set; } = Allegiance.Neutral;
/// <summary>
/// True if this actor still has positive HP (or is downed but not dead).
/// Subclasses (e.g. <see cref="NpcActor"/>) override to use direct HP
/// fields instead of a <see cref="Character"/> reference.
/// </summary>
public virtual bool IsAlive => Character is null ? true : Character.IsAlive;
}
+149
View File
@@ -0,0 +1,149 @@
namespace Theriapolis.Core.Entities;
/// <summary>
/// Owns live actors. Phase 4 only ever holds the single player actor; this
/// class exists so Phase 5/6 can add NPCs without further architectural change.
/// </summary>
public sealed class ActorManager
{
private readonly List<Actor> _actors = new();
private int _nextId = 1;
public IReadOnlyList<Actor> All => _actors;
public PlayerActor? Player { get; private set; }
/// <summary>Creates the player actor. Idempotent on repeat calls — returns the existing player.</summary>
public PlayerActor SpawnPlayer(Util.Vec2 worldPixelPos)
{
if (Player is not null) return Player;
var p = new PlayerActor
{
Id = _nextId++,
Position = worldPixelPos,
Allegiance = Rules.Character.Allegiance.Player,
};
_actors.Add(p);
Player = p;
return p;
}
/// <summary>
/// Phase 5 overload: spawn the player and attach a freshly-built character
/// in one step. The character carries the player's name into the actor.
/// </summary>
public PlayerActor SpawnPlayer(Util.Vec2 worldPixelPos, Rules.Character.Character character)
{
var p = SpawnPlayer(worldPixelPos);
p.Character = character;
// The character record's identity (name) wins over the default actor name.
// Other fields (HP, abilities, inventory) live exclusively on the Character.
return p;
}
/// <summary>
/// Restore a player actor from a save. Does not allocate a new id — the
/// saved id is restored as-is so cross-save references stay stable.
/// </summary>
public PlayerActor RestorePlayer(PlayerActorState state)
{
var p = new PlayerActor { Id = state.Id };
p.RestoreState(state);
_actors.Add(p);
Player = p;
if (state.Id >= _nextId) _nextId = state.Id + 1;
return p;
}
/// <summary>
/// Phase 5 M5: spawn an NPC at the given world-pixel position. The caller
/// chooses the template (including any DangerZone-driven per-zone selection).
/// Returns the spawned actor with a freshly-allocated id.
/// </summary>
public NpcActor SpawnNpc(
Data.NpcTemplateDef template,
Util.Vec2 worldPixelPos,
Tactical.ChunkCoord? sourceChunk = null,
int? sourceSpawnIndex = null)
{
var npc = new NpcActor(template)
{
Id = _nextId++,
Position = worldPixelPos,
SourceChunk = sourceChunk,
SourceSpawnIndex = sourceSpawnIndex,
};
_actors.Add(npc);
return npc;
}
/// <summary>
/// Phase 6 M1 — adopt a pre-constructed NpcActor (e.g. a resident built
/// by <see cref="Rules.Combat.ResidentInstantiator"/> with the resident
/// template constructor). Assigns an id and registers in the actor list.
/// </summary>
public NpcActor SpawnNpc(NpcActor pre)
{
if (pre.Id <= 0)
{
// Use reflection-free init via a fresh actor copy. NpcActor.Id is
// init-only, but the only place we hit this path is the
// resident-instantiator which constructs `pre` with Id = -1
// expecting us to assign. Build the real one here.
NpcActor adopted = pre.Resident is not null
? new NpcActor(pre.Resident)
{
Id = _nextId++,
Position = pre.Position,
SourceChunk = pre.SourceChunk,
SourceSpawnIndex = pre.SourceSpawnIndex,
RoleTag = pre.RoleTag,
HomeSettlementId = pre.HomeSettlementId,
}
: new NpcActor(pre.Template!)
{
Id = _nextId++,
Position = pre.Position,
SourceChunk = pre.SourceChunk,
SourceSpawnIndex = pre.SourceSpawnIndex,
RoleTag = pre.RoleTag,
HomeSettlementId = pre.HomeSettlementId,
};
_actors.Add(adopted);
return adopted;
}
_actors.Add(pre);
if (pre.Id >= _nextId) _nextId = pre.Id + 1;
return pre;
}
/// <summary>Remove an actor by id. Returns true if it was found.</summary>
public bool RemoveActor(int id)
{
for (int i = 0; i < _actors.Count; i++)
{
if (_actors[i].Id == id) { _actors.RemoveAt(i); return true; }
}
return false;
}
/// <summary>Returns every NPC currently spawned.</summary>
public IEnumerable<NpcActor> Npcs
{
get
{
foreach (var a in _actors) if (a is NpcActor npc) yield return npc;
}
}
/// <summary>Find a live NPC originating from a specific chunk + spawn index.</summary>
public NpcActor? FindNpcBySource(Tactical.ChunkCoord chunk, int spawnIndex)
{
foreach (var a in _actors)
if (a is NpcActor npc &&
npc.SourceChunk.HasValue && npc.SourceChunk.Value.Equals(chunk) &&
npc.SourceSpawnIndex == spawnIndex)
return npc;
return null;
}
}
+106
View File
@@ -0,0 +1,106 @@
using Theriapolis.Core.Rules.Combat;
namespace Theriapolis.Core.Entities.Ai;
/// <summary>
/// Read-only view of the world the AI behavior consults during a turn.
/// Wraps the live <see cref="Encounter"/> + a sight predicate so behaviors
/// stay testable in isolation (M5 unit tests pass an always-clear predicate;
/// the live game wires in the tactical-tile sampler).
///
/// Behaviors call into the resolver to actually mutate the encounter; this
/// context is purely for reading the situation.
/// </summary>
public sealed class AiContext
{
public Encounter Encounter { get; }
/// <summary>Returns true if the tile at (tx, ty) blocks line-of-sight.</summary>
public System.Func<int, int, bool> IsLosBlocked { get; }
public AiContext(Encounter encounter, System.Func<int, int, bool>? isLosBlocked = null)
{
Encounter = encounter;
IsLosBlocked = isLosBlocked ?? LineOfSight.AlwaysClear;
}
/// <summary>Find the closest hostile combatant to the given actor, or null if none.</summary>
public Combatant? FindClosestHostile(Combatant self)
{
Combatant? best = null;
int bestDist = int.MaxValue;
foreach (var c in Encounter.Participants)
{
if (c.Id == self.Id) continue;
if (c.IsDown) continue;
if (!IsHostileTo(self.Allegiance, c.Allegiance)) continue;
int d = ReachAndCover.EdgeToEdgeChebyshev(self, c);
if (d < bestDist) { best = c; bestDist = d; }
}
return best;
}
/// <summary>
/// Phase 6.5 M1 — find the closest *ally* to the given actor (excludes
/// self). Allies share the player-side allegiance: Player or Allied.
/// Returns null when none qualify (which is the typical M1 case — the
/// player is alone — and disables ally-targeted features cleanly).
/// </summary>
public Combatant? FindClosestAlly(Combatant self)
{
Combatant? best = null;
int bestDist = int.MaxValue;
bool selfPlayerSide = self.Allegiance == Rules.Character.Allegiance.Player
|| self.Allegiance == Rules.Character.Allegiance.Allied;
if (!selfPlayerSide) return null;
foreach (var c in Encounter.Participants)
{
if (c.Id == self.Id) continue;
if (c.IsDown) continue;
bool cPlayerSide = c.Allegiance == Rules.Character.Allegiance.Player
|| c.Allegiance == Rules.Character.Allegiance.Allied;
if (!cPlayerSide) continue;
int d = ReachAndCover.EdgeToEdgeChebyshev(self, c);
if (d < bestDist) { best = c; bestDist = d; }
}
return best;
}
/// <summary>
/// Phase 6.5 M1 — find the lowest-HP friendly target (self or any
/// ally), preferring the most damaged. Returns null only when every
/// friendly is at full HP. Used as the auto-target for healing
/// features when the player presses the heal hotkey without picking
/// explicitly.
/// </summary>
public Combatant? FindMostDamagedFriendly(Combatant self)
{
Combatant? best = null;
int bestDeficit = -1;
bool selfPlayerSide = self.Allegiance == Rules.Character.Allegiance.Player
|| self.Allegiance == Rules.Character.Allegiance.Allied;
if (!selfPlayerSide) return null;
foreach (var c in Encounter.Participants)
{
if (c.IsDown) continue;
bool cPlayerSide = c.Allegiance == Rules.Character.Allegiance.Player
|| c.Allegiance == Rules.Character.Allegiance.Allied;
if (!cPlayerSide) continue;
int deficit = c.MaxHp - c.CurrentHp;
if (deficit > bestDeficit) { best = c; bestDeficit = deficit; }
}
return bestDeficit > 0 ? best : null;
}
/// <summary>True if the two allegiances should target each other in combat.</summary>
public static bool IsHostileTo(
Rules.Character.Allegiance a,
Rules.Character.Allegiance b)
{
bool aIsPlayerSide = a == Rules.Character.Allegiance.Player || a == Rules.Character.Allegiance.Allied;
bool bIsPlayerSide = b == Rules.Character.Allegiance.Player || b == Rules.Character.Allegiance.Allied;
if (aIsPlayerSide && b == Rules.Character.Allegiance.Hostile) return true;
if (bIsPlayerSide && a == Rules.Character.Allegiance.Hostile) return true;
return false;
}
}
@@ -0,0 +1,37 @@
using Theriapolis.Core.Rules.Combat;
namespace Theriapolis.Core.Entities.Ai;
/// <summary>
/// "Move toward target, attack when in reach" — the baseline aggressive
/// melee behavior. Used by Brigand* and Patrol templates that aren't
/// scripted to flee.
/// </summary>
public sealed class BrigandBehavior : INpcBehavior
{
public void TakeTurn(Combatant self, AiContext ctx)
{
if (self.IsDown) return;
var target = ctx.FindClosestHostile(self);
if (target is null) return;
var attack = self.AttackOptions[0];
// Move budget: 5 ft. = 1 tactical tile per d20 standard.
int tiles = ctx.Encounter.CurrentTurn.RemainingMovementFt / 5;
while (!ReachAndCover.IsInReach(self, target, attack) && tiles > 0)
{
var next = ReachAndCover.StepToward(self.Position, target.Position);
if (next.X == self.Position.X && next.Y == self.Position.Y) break;
self.Position = next;
ctx.Encounter.AppendLog(CombatLogEntry.Kind.Move,
$"{self.Name} moves to ({(int)next.X},{(int)next.Y}).");
tiles--;
}
int consumedTiles = (ctx.Encounter.CurrentTurn.RemainingMovementFt / 5) - tiles;
ctx.Encounter.CurrentTurn.ConsumeMovement(consumedTiles * 5);
if (!ReachAndCover.IsInReach(self, target, attack)) return;
Resolver.AttemptAttack(ctx.Encounter, self, target, attack);
ctx.Encounter.CurrentTurn.ConsumeAction();
}
}
@@ -0,0 +1,45 @@
using Theriapolis.Core.Rules.Combat;
namespace Theriapolis.Core.Entities.Ai;
/// <summary>
/// One NPC behavior — what does this NPC do on its turn? Behaviors are
/// dispatched by id (the template's <c>behavior</c> field) via
/// <see cref="BehaviorRegistry"/>. Each behavior must produce its turn's
/// actions within bounded time — no recursion, no while-true loops; if no
/// valid action is available, end the turn.
/// </summary>
public interface INpcBehavior
{
/// <summary>
/// Take one turn for <paramref name="self"/>. Behaviors call into
/// <see cref="Resolver"/> to mutate the encounter, then return — the
/// caller (typically <see cref="Encounter"/>'s turn pump) calls
/// <see cref="Encounter.EndTurn"/> after this returns.
/// </summary>
void TakeTurn(Combatant self, AiContext ctx);
}
/// <summary>
/// Maps behavior ids (the strings in <c>npc_templates.json</c>'s
/// <c>behavior</c> field) to their <see cref="INpcBehavior"/>
/// implementation. Phase 5 M5 ships three: <c>brigand</c>,
/// <c>wild_animal</c>, <c>poi_guard</c>. Unknown ids fall back to
/// <c>brigand</c> with a debug log.
/// </summary>
public static class BehaviorRegistry
{
private static readonly System.Collections.Generic.Dictionary<string, INpcBehavior> _impls
= new(System.StringComparer.OrdinalIgnoreCase)
{
["brigand"] = new BrigandBehavior(),
["wild_animal"] = new WildAnimalBehavior(),
["poi_guard"] = new PoiGuardBehavior(),
["patrol"] = new BrigandBehavior(), // patrol shares brigand combat behavior
};
public static INpcBehavior For(string id)
{
return _impls.TryGetValue(id, out var b) ? b : _impls["brigand"];
}
}
@@ -0,0 +1,20 @@
namespace Theriapolis.Core.Entities.Ai;
/// <summary>
/// Defends a point of interest. Phase 5 M5 ships this as Brigand-equivalent
/// combat behavior — patrolling around a home position is M6 territory once
/// out-of-combat tactical movement is implemented (currently NPCs only act
/// during encounters).
/// </summary>
public sealed class PoiGuardBehavior : INpcBehavior
{
private readonly BrigandBehavior _melee = new();
public void TakeTurn(Rules.Combat.Combatant self, AiContext ctx)
{
// Same combat logic as Brigand. The "patrol around home" behavior
// outside of combat hooks in at M6 — for now PoiGuards stand still
// until engaged.
_melee.TakeTurn(self, ctx);
}
}
@@ -0,0 +1,65 @@
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Entities.Ai;
/// <summary>
/// Like <see cref="BrigandBehavior"/>, but flees when reduced below
/// <c>FLEE_HP_FRACTION</c> of max HP — wild animals don't have honor.
/// </summary>
public sealed class WildAnimalBehavior : INpcBehavior
{
public const float FLEE_HP_FRACTION = 0.25f;
public void TakeTurn(Combatant self, AiContext ctx)
{
if (self.IsDown) return;
var target = ctx.FindClosestHostile(self);
if (target is null) return;
// Flee at low HP — move directly away, don't attack.
if (self.CurrentHp <= self.MaxHp * FLEE_HP_FRACTION)
{
int tiles = ctx.Encounter.CurrentTurn.RemainingMovementFt / 5;
for (int i = 0; i < tiles; i++)
{
var away = StepAwayFrom(self.Position, target.Position);
if ((int)away.X == (int)self.Position.X && (int)away.Y == (int)self.Position.Y) break;
self.Position = away;
ctx.Encounter.AppendLog(CombatLogEntry.Kind.Move,
$"{self.Name} flees to ({(int)away.X},{(int)away.Y}).");
}
ctx.Encounter.CurrentTurn.ConsumeMovement(tiles * 5);
return;
}
// Otherwise, identical to Brigand: close + attack.
var attack = self.AttackOptions[0];
int tilesAvail = ctx.Encounter.CurrentTurn.RemainingMovementFt / 5;
while (!ReachAndCover.IsInReach(self, target, attack) && tilesAvail > 0)
{
var next = ReachAndCover.StepToward(self.Position, target.Position);
if ((int)next.X == (int)self.Position.X && (int)next.Y == (int)self.Position.Y) break;
self.Position = next;
ctx.Encounter.AppendLog(CombatLogEntry.Kind.Move,
$"{self.Name} moves to ({(int)next.X},{(int)next.Y}).");
tilesAvail--;
}
int consumedTiles = (ctx.Encounter.CurrentTurn.RemainingMovementFt / 5) - tilesAvail;
ctx.Encounter.CurrentTurn.ConsumeMovement(consumedTiles * 5);
if (!ReachAndCover.IsInReach(self, target, attack)) return;
Resolver.AttemptAttack(ctx.Encounter, self, target, attack);
ctx.Encounter.CurrentTurn.ConsumeAction();
}
private static Vec2 StepAwayFrom(Vec2 from, Vec2 menace)
{
// Reverse of StepToward — increment by sign of (from - menace) so we
// move away from the menace.
int dx = System.Math.Sign(from.X - menace.X);
int dy = System.Math.Sign(from.Y - menace.Y);
if (dx == 0 && dy == 0) dx = 1; // pick a direction if standing on top
return new Vec2((int)from.X + dx, (int)from.Y + dy);
}
}
+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; }
}
+61
View File
@@ -0,0 +1,61 @@
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Entities;
/// <summary>
/// Player actor. Phase 4 keeps the field set deliberately small — just enough
/// to position, save, and reload a single human-controlled character. Stats,
/// inventory, and class data are added in Phase 5.
/// </summary>
public sealed class PlayerActor : Actor
{
public string Name { get; set; } = "Wanderer";
/// <summary>Highest settlement tier the player has actually visited (1 = capital).</summary>
public int HighestTierReached { get; set; } = 5;
/// <summary>Set of discovered settlement / PoI ids. Drives slot-picker labels and Phase 7+ map UI.</summary>
public HashSet<int> DiscoveredPoiIds { get; } = new();
/// <summary>Snapshot used by SaveCodec; mutated by RestoreState.</summary>
public PlayerActorState CaptureState() => new()
{
Id = Id,
Name = Name,
PositionX = Position.X,
PositionY = Position.Y,
FacingAngleRad = FacingAngleRad,
SpeedWorldPxPerSec = SpeedWorldPxPerSec,
HighestTierReached = HighestTierReached,
DiscoveredPoiIds = DiscoveredPoiIds.ToArray(),
};
public void RestoreState(PlayerActorState s)
{
Name = s.Name;
Position = new Vec2(s.PositionX, s.PositionY);
FacingAngleRad = s.FacingAngleRad;
SpeedWorldPxPerSec = s.SpeedWorldPxPerSec;
HighestTierReached = s.HighestTierReached;
DiscoveredPoiIds.Clear();
foreach (int id in s.DiscoveredPoiIds) DiscoveredPoiIds.Add(id);
}
}
/// <summary>
/// Plain serializable snapshot of a <see cref="PlayerActor"/>.
/// Kept as a struct of primitive fields so the persistence layer doesn't need
/// MessagePack attributes on the live object — keeps Core dependency-free for
/// modules that don't yet care about saves.
/// </summary>
public sealed class PlayerActorState
{
public int Id;
public string Name = "";
public float PositionX;
public float PositionY;
public float FacingAngleRad;
public float SpeedWorldPxPerSec;
public int HighestTierReached;
public int[] DiscoveredPoiIds = Array.Empty<int>();
}
+91
View File
@@ -0,0 +1,91 @@
namespace Theriapolis.Core.Entities;
/// <summary>
/// Phase 6.5 M6 — bounded vocabulary of "scent reads" surfaced by
/// Scent-Broker abilities (Scent Literacy at L1, Scent Mastery /
/// <c>master_nose</c> at L11). Each tag is a short, in-fiction
/// observation a Scent-Broker can pick out of an NPC's scent profile —
/// recent activity, faction affiliation, distress markers.
///
/// Per the Phase 6.5 plan §3.2: bounded enum on purpose. Phase 8's full
/// scent-propagation simulation can extend the vocabulary; Phase 6.5
/// keeps the set small and the population path simple (auto-derived from
/// faction id + a small set of runtime triggers — see
/// <see cref="NpcActor.ComputeScentTags"/>).
///
/// Display priority is the enum order: lower-numbered tags win when
/// truncating to a single read for L1 Scent Literacy. Faction-affiliation
/// tags lead because they're the most narratively meaningful (Lacroix
/// reads as "Maw-affiliated" before "recently killed", because the latter
/// is generic brigand flavour).
/// </summary>
public enum ScentTag : byte
{
None = 0,
// ── Faction-affiliation reads (priority 18) ─────────────────────────
/// <summary>The NPC carries the scent of Maw chemistry / contact.</summary>
MawAffiliated = 1,
/// <summary>Inheritor pheromone-marker / ration scent.</summary>
InheritorAffiliated = 2,
/// <summary>Thorn Council chemical signatures.</summary>
ThornCouncilAffiliated = 3,
/// <summary>Covenant Enforcer protocol scent (uniform launderings, sigil oils).</summary>
CovenantEnforcerAffiliated = 4,
/// <summary>Hybrid Underground safe-house scent traces.</summary>
HybridUndergroundAffiliated = 5,
/// <summary>Unsheathed (hybrid activist) chemical markers.</summary>
UnsheathedAffiliated = 6,
/// <summary>Merchant-guild handshake oils — well-traveled, much-greeted.</summary>
MerchantAffiliated = 7,
// ── Runtime-derived reads (priority 16+) ─────────────────────────────
/// <summary>Has killed something recently (within ~1 hour). Set on melee kill.</summary>
RecentlyKilled = 16,
/// <summary>Carries combat-distress markers — fleeing or near-death.</summary>
Frightened = 17,
/// <summary>Carries contraband (pheromone vials, deep-cover masks, faction sigils).</summary>
CarriesContraband = 18,
/// <summary>Wounded — current HP &lt; 50%.</summary>
Wounded = 19,
}
public static class ScentTagExtensions
{
/// <summary>Human-readable label for the InteractionScreen overlay.</summary>
public static string DisplayName(this ScentTag tag) => tag switch
{
ScentTag.MawAffiliated => "Maw-affiliated",
ScentTag.InheritorAffiliated => "Inheritor-affiliated",
ScentTag.ThornCouncilAffiliated => "Thorn Council-affiliated",
ScentTag.CovenantEnforcerAffiliated => "Covenant Enforcer-affiliated",
ScentTag.HybridUndergroundAffiliated => "Hybrid Underground-affiliated",
ScentTag.UnsheathedAffiliated => "Unsheathed-affiliated",
ScentTag.MerchantAffiliated => "Merchant-affiliated",
ScentTag.RecentlyKilled => "Recently killed",
ScentTag.Frightened => "Frightened",
ScentTag.CarriesContraband => "Carries contraband",
ScentTag.Wounded => "Wounded",
_ => "—",
};
/// <summary>
/// Map a faction id to the corresponding affiliation tag. Returns
/// <see cref="ScentTag.None"/> for empty / unknown faction ids.
/// </summary>
public static ScentTag FromFactionId(string factionId) => factionId?.ToLowerInvariant() switch
{
"maw" => ScentTag.MawAffiliated,
"inheritors" => ScentTag.InheritorAffiliated,
"thorn_council" => ScentTag.ThornCouncilAffiliated,
"covenant_enforcers" => ScentTag.CovenantEnforcerAffiliated,
"hybrid_underground" => ScentTag.HybridUndergroundAffiliated,
"unsheathed" => ScentTag.UnsheathedAffiliated,
"merchant_guilds" => ScentTag.MerchantAffiliated,
_ => ScentTag.None,
};
/// <summary>True if this tag carries narrative weight — e.g. faction reveals.</summary>
public static bool IsNarrative(this ScentTag tag) =>
tag != ScentTag.None && (byte)tag < 16;
}
@@ -0,0 +1,116 @@
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Entities;
/// <summary>
/// Plans a continuous-time path between two world tiles. The result is a
/// list of world-pixel waypoints (tile centers); the controller animates
/// the player along it at <see cref="C.PLAYER_TRAVEL_PX_PER_SEC"/>.
///
/// Cost function:
/// • Ocean tiles (Biome == Ocean) are impassable.
/// • Mountain tiles cost 4× a grassland tile (slow but not blocked).
/// • Tiles carrying a road get a strong discount (matches the ROAD_SPEED_MULT idea).
/// • Diagonal moves are √2 like everywhere else in the codebase.
///
/// The clock-advance amount is computed afterwards by walking the path
/// and summing per-segment travel time — see <see cref="EstimateSecondsForLeg"/>.
/// </summary>
public sealed class WorldTravelPlanner
{
private readonly AStarPathfinder _astar = new();
private readonly WorldState _world;
public WorldTravelPlanner(WorldState world) { _world = world; }
/// <summary>
/// Returns null if no path exists. Returned waypoints are tile coordinates,
/// not world-pixel coordinates — convert with <see cref="TileCenterToWorldPixel"/>.
/// </summary>
public List<(int X, int Y)>? PlanTilePath(int sx, int sy, int gx, int gy)
{
if (!IsWalkable(sx, sy)) return null;
if (!IsWalkable(gx, gy)) return null;
return _astar.FindPath(sx, sy, gx, gy, CostFn);
}
private float CostFn(int fx, int fy, int tx, int ty, byte _)
{
if (!IsWalkable(tx, ty)) return float.PositiveInfinity;
ref var t = ref _world.TileAt(tx, ty);
float baseCost = TerrainCost(t);
// Strong incentive to follow roads — matches the EXISTING_ROAD_COST
// worldgen philosophy. Pure additive (not multiplicative) so it
// never goes negative and A* admissibility holds.
if ((t.Features & FeatureFlags.HasRoad) != 0) baseCost *= 0.25f;
return baseCost;
}
private bool IsWalkable(int x, int y)
{
if ((uint)x >= C.WORLD_WIDTH_TILES) return false;
if ((uint)y >= C.WORLD_HEIGHT_TILES) return false;
ref var t = ref _world.TileAt(x, y);
return t.Biome != BiomeId.Ocean;
}
private static float TerrainCost(in WorldTile t) => t.Biome switch
{
BiomeId.MountainAlpine => 4.0f,
BiomeId.MountainForested => 3.0f,
BiomeId.Wetland => 2.5f,
BiomeId.Foothills => 1.8f,
BiomeId.Boreal => 1.6f,
BiomeId.SubtropicalForest => 1.6f,
BiomeId.TemperateDeciduous => 1.5f,
BiomeId.Tundra => 1.5f,
BiomeId.DesertCold => 1.4f,
BiomeId.Mangrove => 1.4f,
BiomeId.MarshEdge => 1.4f,
BiomeId.Scrubland => 1.2f,
BiomeId.ForestEdge => 1.3f,
BiomeId.Cliff => 3.0f,
BiomeId.TemperateGrassland => 1.0f,
BiomeId.RiverValley => 1.0f,
BiomeId.Coastal => 1.0f,
BiomeId.Beach => 1.0f,
BiomeId.TidalFlat => 1.5f,
_ => 1.2f,
};
public static Vec2 TileCenterToWorldPixel(int x, int y)
=> new(x * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
y * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f);
/// <summary>
/// In-game seconds to traverse a single world-pixel between adjacent tiles.
/// Combines BASE_SEC_PER_WORLD_PIXEL with biome modifier and a road bonus.
/// </summary>
public float SecondsPerPixel(in WorldTile t)
{
float biomeMod = t.Biome switch
{
BiomeId.MountainAlpine => 3.0f,
BiomeId.MountainForested => 2.5f,
BiomeId.Wetland => 2.0f,
BiomeId.Foothills => 1.6f,
BiomeId.Boreal => 1.5f,
BiomeId.SubtropicalForest => 1.5f,
BiomeId.TemperateDeciduous => 1.4f,
BiomeId.Tundra => 1.5f,
_ => 1.0f,
};
float roadMod = ((t.Features & FeatureFlags.HasRoad) != 0) ? C.ROAD_SPEED_MULT : 1f;
return C.BASE_SEC_PER_WORLD_PIXEL * biomeMod * roadMod;
}
/// <summary>In-game seconds to walk between two adjacent tile centers.</summary>
public float EstimateSecondsForLeg(int fx, int fy, int tx, int ty)
{
ref var to = ref _world.TileAt(tx, ty);
float pixDist = Vec2.Dist(TileCenterToWorldPixel(fx, fy), TileCenterToWorldPixel(tx, ty));
return pixDist * SecondsPerPixel(to);
}
}