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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user