Files
TheriapolisV3/Theriapolis.Core/Rules/Combat/Resolver.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

209 lines
9.1 KiB
C#

using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Pure d20-adjacent rule evaluator. Static — no per-encounter state lives
/// here; everything flows through <see cref="Encounter"/> (dice + log) and
/// the supplied <see cref="Combatant"/> 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 <see cref="Character"/> features in <see cref="AttemptAttack"/>.
/// </summary>
public static class Resolver
{
/// <summary>
/// Roll an attack from <paramref name="attacker"/> against
/// <paramref name="target"/>. Logs the outcome on
/// <paramref name="enc"/>'s log. Mutates target HP if the attack hits.
/// </summary>
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;
}
/// <summary>
/// Roll a saving throw for <paramref name="target"/> against
/// <paramref name="dc"/>. Bonus = ability mod + (proficient ? prof : 0).
/// </summary>
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;
}
/// <summary>
/// Subtract <paramref name="damage"/> from <paramref name="target"/>'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
/// <see cref="DeathSaveTracker"/> 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).
/// </summary>
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();
}
}
/// <summary>Heal up to MaxHp. Negative amounts are no-ops (use ApplyDamage).</summary>
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();
}
}
/// <summary>Apply a condition to a target. Logs the change.</summary>
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}.");
}
/// <summary>Remove a condition from a target. Logs if it was present.</summary>
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}.");
}
}