Files
TheriapolisV3/Theriapolis.Core/Rules/Combat/Combatant.cs
T
Christopher Wiebe b451f83174 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>
2026-04-30 20:40:51 -07:00

320 lines
15 KiB
C#

using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
// NOTE: deliberately NOT importing Theriapolis.Core.Rules.Character because
// the namespace name collides with the Character class inside it. Fully
// qualify Character; use Allegiance via Rules.Character.Allegiance below.
using Allegiance = Theriapolis.Core.Rules.Character.Allegiance;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Runtime adapter the resolver works with. Wraps either a
/// <see cref="Character"/> (player + future allies) or an
/// <see cref="NpcTemplateDef"/> (NPCs spawned from chunk lists). Carries
/// the mutable per-encounter state — HP, position, conditions — so the
/// source records aren't touched until the encounter ends and results
/// are written back.
/// </summary>
public sealed class Combatant
{
public int Id { get; }
public string Name { get; }
public Allegiance Allegiance { get; }
public SizeCategory Size { get; }
public AbilityScores Abilities { get; }
public int ProficiencyBonus { get; }
public int ArmorClass { get; }
public int MaxHp { get; }
public int SpeedFt { get; }
public int InitiativeBonus { get; }
public IReadOnlyList<AttackOption> AttackOptions { get; }
/// <summary>Source <see cref="Character"/> if built from one (player or ally). Null for NPC-template combatants.</summary>
public Theriapolis.Core.Rules.Character.Character? SourceCharacter { get; }
/// <summary>Source <see cref="NpcTemplateDef"/> if built from one. Null for character combatants.</summary>
public NpcTemplateDef? SourceTemplate { get; }
// ── Mutable per-encounter state ───────────────────────────────────────
public int CurrentHp { get; set; }
public Vec2 Position { get; set; }
public HashSet<Condition> Conditions { get; } = new();
/// <summary>Phase 5 M6: present only on player combatants while at 0 HP. Tracks the death-save loop.</summary>
public DeathSaveTracker? DeathSaves { get; set; }
// ── Phase 5 M6: per-encounter feature flags ──────────────────────────
/// <summary>True while Feral Rage is active. Bonus action toggle.</summary>
public bool RageActive { get; set; }
/// <summary>True while Bulwark Sentinel Stance is active. Halves speed; +2 AC.</summary>
public bool SentinelStanceActive { get; set; }
/// <summary>Set when Sneak Attack damage has fired this turn — once-per-turn limit.</summary>
public bool SneakAttackUsedThisTurn { get; set; }
// ── Phase 6.5 M1: per-encounter feature state ───────────────────────
/// <summary>
/// Pending Vocalization-Dice inspiration die granted by a Muzzle-Speaker.
/// 0 = none. When non-zero, the next attack/check/save this combatant
/// rolls adds 1d&lt;value&gt; to the result; the field then resets to 0.
/// Sides match the Vocalization Dice ladder: 6 / 8 / 10 / 12.
/// </summary>
public int InspirationDieSides { get; set; }
// ── Phase 6.5 M2: subclass-feature per-encounter state ───────────────
/// <summary>
/// Phase 6.5 M2 — Pack-Forged "Packmate's Howl" mark. Set on the target
/// when a Pack-Forged Fangsworn lands a melee hit; the next attack by
/// any *ally* of the Pack-Forged on this target gains advantage. The
/// mark expires when the marker's turn comes around again — tracked
/// here as the round number the mark was placed; resolver checks
/// <c>currentRound == HowlMarkRound + 0</c> (current round) or
/// <c>currentRound == HowlMarkRound + 1</c> (next round, before
/// marker's turn). Cleared on consume.
/// </summary>
public int? HowlMarkRound { get; set; }
/// <summary>The Pack-Forged combatant id that placed the howl mark.</summary>
public int? HowlMarkBy { get; set; }
/// <summary>
/// Phase 6.5 M2 — Blood Memory "Predatory Surge" trigger. Set when this
/// raging Feral kills a creature with a melee attack; consumed by the
/// HUD on the next bonus-action prompt (free extra melee attack).
/// </summary>
public bool PredatorySurgePending { get; set; }
// ── Phase 6.5 M3: Covenant Authority oath mark ───────────────────────
/// <summary>
/// Round number when an oath was placed on this combatant (Covenant-
/// Keeper Covenant's Authority). While the mark is live, the combatant
/// suffers -2 to attack rolls vs. its marker. Expires 10 rounds after
/// placement (= 1 minute in d20 round time).
/// </summary>
public int? OathMarkRound { get; set; }
/// <summary>The Covenant-Keeper combatant id who placed the oath mark.</summary>
public int? OathMarkBy { get; set; }
// ── Phase 7 M0: subclass per-turn / per-encounter flags ──────────────
/// <summary>
/// Phase 7 M0 — Stampede-Heart "Trampling Charge". Set when this turn's
/// first melee attack adds the +1d8 bludgeoning bonus; prevents the
/// bonus from firing twice in one turn. Resets at turn start.
/// </summary>
public bool TramplingChargeUsedThisTurn { get; set; }
/// <summary>
/// Phase 7 M0 — Ambush-Artist "Opening Strike". Set after the
/// first melee attack in this encounter consumes the +2d6 bonus; the
/// bonus only fires once per encounter. Lasts the encounter
/// (no per-turn reset).
/// </summary>
public bool OpeningStrikeUsed { get; set; }
/// <summary>Reset per-turn flags. Called by Encounter.EndTurn for the incoming actor.</summary>
public void OnTurnStart()
{
SneakAttackUsedThisTurn = false;
TramplingChargeUsedThisTurn = false;
}
/// <summary>True once HP ≤ 0. NPC-template combatants are removed from initiative; character combatants enter death-save mode.</summary>
public bool IsDown => CurrentHp <= 0;
/// <summary>True if either alive (HP &gt; 0) or downed-but-not-dead (rolling death saves).</summary>
public bool IsAlive => !IsDown || (DeathSaves is not null && !DeathSaves.Dead);
private Combatant(
int id, string name, Allegiance allegiance,
SizeCategory size, AbilityScores abilities, int profBonus,
int armorClass, int maxHp, int speedFt, int initiativeBonus,
IReadOnlyList<AttackOption> attacks,
Theriapolis.Core.Rules.Character.Character? sourceCharacter, NpcTemplateDef? sourceTemplate,
Vec2 position)
{
Id = id;
Name = name;
Allegiance = allegiance;
Size = size;
Abilities = abilities;
ProficiencyBonus= profBonus;
ArmorClass = armorClass;
MaxHp = maxHp;
SpeedFt = speedFt;
InitiativeBonus = initiativeBonus;
AttackOptions = attacks;
SourceCharacter = sourceCharacter;
SourceTemplate = sourceTemplate;
CurrentHp = maxHp;
Position = position;
}
/// <summary>
/// Build a combatant from a <see cref="Character"/>. Pulls AC, HP, and
/// the primary attack from equipped MainHand (or unarmed strike if none).
/// </summary>
public static Combatant FromCharacter(Theriapolis.Core.Rules.Character.Character c, int id, Vec2 position)
{
int ac = DerivedStats.ArmorClass(c);
int speed = DerivedStats.SpeedFt(c);
int initBonus = DerivedStats.Initiative(c);
var attacks = BuildCharacterAttacks(c);
return new Combatant(
id, c.Background?.Name is { Length: > 0 } ? $"PC-{id}" : $"PC-{id}",
c.SourceCharacterAllegiance(), c.Size, c.Abilities, c.ProficiencyBonus,
ac, c.MaxHp, speed, initBonus, attacks,
sourceCharacter: c, sourceTemplate: null, position: position);
}
/// <summary>
/// Build a combatant from a <see cref="Character"/> with an explicit
/// display name (typically the player's chosen name).
/// </summary>
public static Combatant FromCharacter(Theriapolis.Core.Rules.Character.Character c, int id, string name, Vec2 position, Allegiance allegiance)
{
int ac = DerivedStats.ArmorClass(c);
int speed = DerivedStats.SpeedFt(c);
int initBonus = DerivedStats.Initiative(c);
var attacks = BuildCharacterAttacks(c);
return new Combatant(
id, name, allegiance, c.Size, c.Abilities, c.ProficiencyBonus,
ac, c.MaxHp, speed, initBonus, attacks,
sourceCharacter: c, sourceTemplate: null, position: position);
}
/// <summary>
/// Build a combatant from an NPC template. AC and HP come straight from
/// the template; attacks are mapped 1:1 from <see cref="NpcTemplateDef.Attacks"/>.
/// </summary>
public static Combatant FromNpcTemplate(NpcTemplateDef def, int id, Vec2 position)
{
var size = SizeExtensions.FromJson(def.Size);
var abilities = new AbilityScores(
Score(def.AbilityScores, "STR", 10),
Score(def.AbilityScores, "DEX", 10),
Score(def.AbilityScores, "CON", 10),
Score(def.AbilityScores, "INT", 10),
Score(def.AbilityScores, "WIS", 10),
Score(def.AbilityScores, "CHA", 10));
// NPC profs default to +2 (CR ≤ 4 baseline).
const int npcProf = 2;
int initBonus = AbilityScores.Mod(abilities.DEX);
var attacks = new List<AttackOption>(def.Attacks.Length);
foreach (var atk in def.Attacks) attacks.Add(BuildNpcAttack(atk));
// 5 ft. = 1 tactical tile; convert NPC speed_ft to tiles.
int speedFt = def.SpeedFt;
var allegiance = Theriapolis.Core.Rules.Character.AllegianceExtensions.FromJson(def.DefaultAllegiance);
return new Combatant(
id, def.Name, allegiance, size, abilities, npcProf,
armorClass: def.Ac, maxHp: def.Hp, speedFt: speedFt, initiativeBonus: initBonus,
attacks: attacks,
sourceCharacter: null, sourceTemplate: def, position: position);
}
/// <summary>Distance to another combatant in tactical tiles, edge-to-edge Chebyshev.</summary>
public int DistanceTo(Combatant other) => ReachAndCover.EdgeToEdgeChebyshev(this, other);
private static int Score(IReadOnlyDictionary<string, int> dict, string key, int fallback)
=> dict.TryGetValue(key, out int v) ? v : fallback;
/// <summary>Builds the attack option list for a character: equipped weapon if any, else an unarmed strike.</summary>
private static List<AttackOption> BuildCharacterAttacks(Theriapolis.Core.Rules.Character.Character c)
{
var list = new List<AttackOption>();
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
if (main is not null && string.Equals(main.Def.Kind, "weapon", System.StringComparison.OrdinalIgnoreCase))
{
list.Add(BuildWeaponAttack(c, main.Def));
}
else
{
list.Add(BuildUnarmedStrike(c));
}
return list;
}
private static AttackOption BuildWeaponAttack(Theriapolis.Core.Rules.Character.Character c, ItemDef weapon)
{
// Finesse weapons use the higher of STR/DEX; ranged weapons use DEX.
bool isFinesse = HasProperty(weapon, "finesse");
bool isRanged = weapon.RangeShortTiles > 0 || HasProperty(weapon, "ammunition") || HasProperty(weapon, "thrown");
AbilityId abil = isRanged
? AbilityId.DEX
: (isFinesse
? (c.Abilities.ModFor(AbilityId.STR) >= c.Abilities.ModFor(AbilityId.DEX)
? AbilityId.STR : AbilityId.DEX)
: AbilityId.STR);
int abilMod = c.Abilities.ModFor(abil);
// Proficiency: assume the character is proficient with all weapons their class lists.
// For Phase 5 M4 we apply proficiency unconditionally (every combat-touching class
// is proficient with their starting weapon). Wrong-proficiency disadvantage lands in M6.
int toHit = c.ProficiencyBonus + abilMod;
var damage = DamageRoll.Parse(
string.IsNullOrEmpty(weapon.Damage) ? "1d4" : weapon.Damage,
string.IsNullOrEmpty(weapon.DamageType)
? DamageType.Bludgeoning
: DamageTypeExtensions.FromJson(weapon.DamageType));
damage = damage with { FlatMod = damage.FlatMod + abilMod };
int reach = weapon.ReachTiles > 0 ? weapon.ReachTiles : c.Size.DefaultReachTiles();
return new AttackOption
{
Name = weapon.Name,
ToHitBonus = toHit,
Damage = damage,
ReachTiles = isRanged ? 0 : reach,
RangeShortTiles = isRanged ? (weapon.RangeShortTiles > 0 ? weapon.RangeShortTiles : 6) : 0,
RangeLongTiles = isRanged ? (weapon.RangeLongTiles > 0 ? weapon.RangeLongTiles : 24) : 0,
CritOnNatural = 20,
};
}
private static AttackOption BuildUnarmedStrike(Theriapolis.Core.Rules.Character.Character c)
{
int strMod = c.Abilities.ModFor(AbilityId.STR);
int toHit = c.ProficiencyBonus + strMod;
return new AttackOption
{
Name = "Unarmed Strike",
ToHitBonus = toHit,
Damage = new DamageRoll(0, 0, System.Math.Max(1, 1 + strMod), DamageType.Bludgeoning),
ReachTiles = c.Size.DefaultReachTiles(),
CritOnNatural = 20,
};
}
private static AttackOption BuildNpcAttack(NpcAttack atk)
{
var dmg = DamageRoll.Parse(
string.IsNullOrEmpty(atk.Damage) ? "1d4" : atk.Damage,
string.IsNullOrEmpty(atk.DamageType)
? DamageType.Bludgeoning
: DamageTypeExtensions.FromJson(atk.DamageType));
return new AttackOption
{
Name = atk.Name,
ToHitBonus = atk.ToHit,
Damage = dmg,
ReachTiles = atk.ReachTiles > 0 ? atk.ReachTiles : 1,
RangeShortTiles = atk.RangeShortTiles,
RangeLongTiles = atk.RangeLongTiles,
CritOnNatural = 20,
};
}
private static bool HasProperty(ItemDef def, string prop)
{
foreach (var p in def.Properties)
if (string.Equals(p, prop, System.StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}
/// <summary>Convenience extension so callers needn't know whether a Character has Allegiance attached.</summary>
internal static class CharacterCombatExtensions
{
public static Allegiance SourceCharacterAllegiance(this Theriapolis.Core.Rules.Character.Character _)
=> Allegiance.Player;
}