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; /// /// Runtime adapter the resolver works with. Wraps either a /// (player + future allies) or an /// (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. /// 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 AttackOptions { get; } /// Source if built from one (player or ally). Null for NPC-template combatants. public Theriapolis.Core.Rules.Character.Character? SourceCharacter { get; } /// Source if built from one. Null for character combatants. public NpcTemplateDef? SourceTemplate { get; } // ── Mutable per-encounter state ─────────────────────────────────────── public int CurrentHp { get; set; } public Vec2 Position { get; set; } public HashSet Conditions { get; } = new(); /// Phase 5 M6: present only on player combatants while at 0 HP. Tracks the death-save loop. public DeathSaveTracker? DeathSaves { get; set; } // ── Phase 5 M6: per-encounter feature flags ────────────────────────── /// True while Feral Rage is active. Bonus action toggle. public bool RageActive { get; set; } /// True while Bulwark Sentinel Stance is active. Halves speed; +2 AC. public bool SentinelStanceActive { get; set; } /// Set when Sneak Attack damage has fired this turn — once-per-turn limit. public bool SneakAttackUsedThisTurn { get; set; } // ── Phase 6.5 M1: per-encounter feature state ─────────────────────── /// /// 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<value> to the result; the field then resets to 0. /// Sides match the Vocalization Dice ladder: 6 / 8 / 10 / 12. /// public int InspirationDieSides { get; set; } // ── Phase 6.5 M2: subclass-feature per-encounter state ─────────────── /// /// 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 /// currentRound == HowlMarkRound + 0 (current round) or /// currentRound == HowlMarkRound + 1 (next round, before /// marker's turn). Cleared on consume. /// public int? HowlMarkRound { get; set; } /// The Pack-Forged combatant id that placed the howl mark. public int? HowlMarkBy { get; set; } /// /// 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). /// public bool PredatorySurgePending { get; set; } // ── Phase 6.5 M3: Covenant Authority oath mark ─────────────────────── /// /// 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). /// public int? OathMarkRound { get; set; } /// The Covenant-Keeper combatant id who placed the oath mark. public int? OathMarkBy { get; set; } // ── Phase 7 M0: subclass per-turn / per-encounter flags ────────────── /// /// 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. /// public bool TramplingChargeUsedThisTurn { get; set; } /// /// 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). /// public bool OpeningStrikeUsed { get; set; } /// Reset per-turn flags. Called by Encounter.EndTurn for the incoming actor. public void OnTurnStart() { SneakAttackUsedThisTurn = false; TramplingChargeUsedThisTurn = false; } /// True once HP ≤ 0. NPC-template combatants are removed from initiative; character combatants enter death-save mode. public bool IsDown => CurrentHp <= 0; /// True if either alive (HP > 0) or downed-but-not-dead (rolling death saves). 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 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; } /// /// Build a combatant from a . Pulls AC, HP, and /// the primary attack from equipped MainHand (or unarmed strike if none). /// 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); } /// /// Build a combatant from a with an explicit /// display name (typically the player's chosen name). /// 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); } /// /// Build a combatant from an NPC template. AC and HP come straight from /// the template; attacks are mapped 1:1 from . /// 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(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); } /// Distance to another combatant in tactical tiles, edge-to-edge Chebyshev. public int DistanceTo(Combatant other) => ReachAndCover.EdgeToEdgeChebyshev(this, other); private static int Score(IReadOnlyDictionary dict, string key, int fallback) => dict.TryGetValue(key, out int v) ? v : fallback; /// Builds the attack option list for a character: equipped weapon if any, else an unarmed strike. private static List BuildCharacterAttacks(Theriapolis.Core.Rules.Character.Character c) { var list = new List(); 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; } } /// Convenience extension so callers needn't know whether a Character has Allegiance attached. internal static class CharacterCombatExtensions { public static Allegiance SourceCharacterAllegiance(this Theriapolis.Core.Rules.Character.Character _) => Allegiance.Player; }