using Theriapolis.Core.Rules.Stats; namespace Theriapolis.Core.Rules.Combat; /// /// Pure d20-adjacent rule evaluator. Static — no per-encounter state lives /// here; everything flows through (dice + log) and /// the supplied instances (HP + conditions). /// /// Phase 5 M4 ships AttemptAttack, MakeSave, ApplyDamage, ApplyCondition. /// Class-feature combat effects (Sneak Attack damage, Rage damage bonus, /// fighting-style modifiers, etc.) are layered on at M6 by inspecting the /// attacker's features in . /// public static class Resolver { /// /// Roll an attack from against /// . Logs the outcome on /// 's log. Mutates target HP if the attack hits. /// public static AttackResult AttemptAttack( Encounter enc, Combatant attacker, Combatant target, AttackOption attack, SituationFlags situation = SituationFlags.None) { // Range/long-range disadvantage decoration: if the attack is ranged // and the target is past short range, OR the calling code is firing // a ranged attack into melee, fold those in. if (attack.IsRanged && ReachAndCover.IsLongRange(attacker, target, attack)) situation |= SituationFlags.LongRange; // Phase 6.5 M2 — Pack-Forged "Packmate's Howl" consumption: if the // target is howl-marked by an ally of this attacker, force advantage // on this attack roll. if (FeatureProcessor.ConsumeHowlAdvantage(enc, attacker, target)) situation |= SituationFlags.Advantage; // Phase 6.5 M3 — Frightened attackers roll at disadvantage. if (attacker.Conditions.Contains(Condition.Frightened)) situation |= SituationFlags.Disadvantage; var (kept, other) = enc.RollD20WithMode(situation); // Phase 5 M6: stack Sentinel Stance and other per-combatant AC bonuses. // Phase 6.5 M2 — pass the encounter so passive subclass AC features // (Herd-Wall Interlock Shields, Lone Fang Isolation Bonus) can read // positional state. int totalAc = target.ArmorClass + situation.CoverAcBonus() + FeatureProcessor.ApplyAcBonus(target, enc); // Phase 6.5 M1: consume an inspiration die (Vocalization Dice) on // attack rolls. The bonus applies to the d20 total *before* compare; // crits/fumbles still trigger off the natural d20. int inspirationBonus = FeatureProcessor.ConsumeInspirationDie(enc, attacker); // Phase 6.5 M2 — subclass to-hit bonuses (Lone Fang Isolation Bonus). int subclassToHit = FeatureProcessor.ApplyToHitBonus(attacker, enc); // Phase 6.5 M3 — Covenant's Authority oath mark: -2 attack vs. marker. int oathPenalty = FeatureProcessor.OathAttackPenalty(enc, attacker, target); int attackTotal = kept + attack.ToHitBonus + inspirationBonus + subclassToHit + oathPenalty; bool natural1 = kept == 1; bool natural20 = kept >= attack.CritOnNatural; bool isCrit = natural20; bool hit = !natural1 && (natural20 || attackTotal >= totalAc); int damage = 0; if (hit) { // Damage roll wraps the per-die source so Great Weapon style // can re-roll 1s/2s on damage dice without changing the resolver // contract. The per-die delegate consumes RNG via the encounter. damage = attack.Damage.Roll( sides => FeatureProcessor.GreatWeaponReroll(enc, attacker, attack, enc.RollDie(sides), sides), isCrit); // Per-feature damage bonuses (Duelist, Rage, Sneak Attack). damage += FeatureProcessor.ApplyDamageBonus(enc, attacker, target, attack, isCrit); // Resistance halves damage (Rage vs phys). if (FeatureProcessor.IsResisted(target, attack.Damage.DamageType)) damage = damage / 2; ApplyDamage(target, damage); // Phase 6.5 M2 — subclass on-hit triggers. // Pack-Forged: melee hit marks the target so allies' next attack // gets advantage. if (!attack.IsRanged) FeatureProcessor.OnPackForgedHit(enc, attacker, target, attack); // Blood Memory: melee kill while raging triggers Predatory Surge. if (target.IsDown && !attack.IsRanged && attacker.RageActive) FeatureProcessor.OnBloodMemoryKill(enc, attacker, attack); // Phase 7 M0 — Antler-Guard Retaliatory Strike. Returns 1d8+CON // to the attacker when the target is an Antler-Guard in Sentinel // Stance hit by a melee attack. Calls ApplyDamage on the attacker // directly; the encounter log carries the structured note. if (!attack.IsRanged) FeatureProcessor.OnAntlerGuardHit(enc, attacker, target, attack); } var result = new AttackResult { AttackerId = attacker.Id, TargetId = target.Id, AttackName = attack.Name, D20Roll = kept, D20Other = other == -1 ? null : other, ToHitBonus = attack.ToHitBonus, AttackTotal = attackTotal, TargetAc = totalAc, Hit = hit, Crit = isCrit && hit, DamageRolled = damage, TargetHpAfter = target.CurrentHp, Situation = situation, }; enc.AppendLog(CombatLogEntry.Kind.Attack, result.FormatLog(attacker.Name, target.Name)); if (target.IsDown) enc.AppendLog(CombatLogEntry.Kind.Death, $"{target.Name} falls."); return result; } /// /// Roll a saving throw for against /// . Bonus = ability mod + (proficient ? prof : 0). /// public static SaveResult MakeSave( Encounter enc, Combatant target, SaveId save, int dc, bool isProficient = false, SituationFlags situation = SituationFlags.None) { var (kept, _) = enc.RollD20WithMode(situation); int bonus = AbilityScores.Mod(target.Abilities.Get(save.Ability())) + (isProficient ? target.ProficiencyBonus : 0); int total = kept + bonus; bool succ = total >= dc; var result = new SaveResult { TargetId = target.Id, Save = save, D20Roll = kept, SaveBonus = bonus, SaveTotal = total, Dc = dc, Succeeded = succ, }; enc.AppendLog(CombatLogEntry.Kind.Save, $"{target.Name} {save} save: {total} vs DC {dc} → {(succ ? "succeeds" : "fails")}"); return result; } /// /// Subtract from 's /// HP, clamped to 0. Does not log (callers like AttemptAttack handle /// the structured log entry; this is the raw mutation). /// /// Phase 5 M6: when a player character drops to 0, install a /// on the combatant; combat HUD reads /// this and rolls a save at the start of the player's turn until the /// loop resolves (stabilised, revived, or dead). /// public static void ApplyDamage(Combatant target, int damage) { if (damage <= 0) return; target.CurrentHp = System.Math.Max(0, target.CurrentHp - damage); if (target.CurrentHp == 0 && target.SourceCharacter is not null) { target.Conditions.Add(Condition.Unconscious); target.DeathSaves ??= new DeathSaveTracker(); } } /// Heal up to MaxHp. Negative amounts are no-ops (use ApplyDamage). public static void Heal(Combatant target, int amount) { if (amount <= 0) return; target.CurrentHp = System.Math.Min(target.MaxHp, target.CurrentHp + amount); // If the heal lifts a downed character above 0, the unconscious // condition lifts automatically and the death-save loop resets. if (target.CurrentHp > 0) { target.Conditions.Remove(Condition.Unconscious); target.DeathSaves?.Reset(); } } /// Apply a condition to a target. Logs the change. public static void ApplyCondition(Encounter enc, Combatant target, Condition condition) { if (target.Conditions.Add(condition)) enc.AppendLog(CombatLogEntry.Kind.ConditionApplied, $"{target.Name} is now {condition}."); } /// Remove a condition from a target. Logs if it was present. public static void RemoveCondition(Encounter enc, Combatant target, Condition condition) { if (target.Conditions.Remove(condition)) enc.AppendLog(CombatLogEntry.Kind.ConditionEnded, $"{target.Name} is no longer {condition}."); } }