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
+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);
}
}