b451f83174
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>
209 lines
9.1 KiB
C#
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}.");
|
|
}
|
|
}
|