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

782 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
/// <summary>
/// Phase 5 M6: dispatches class-feature combat effects at hook points the
/// resolver and DerivedStats call into. Hand-coded switch-on-class-id; the
/// alternative would be a feature-registration system — overkill for the
/// half-dozen combat-touching level-1 features we actually ship.
///
/// Implemented features:
/// - Fangsworn fighting styles: Duelist (+2 dmg one-handed), Great Weapon (re-roll 1s/2s on dmg)
/// - Feral: Unarmored Defense (10 + DEX + CON when no body armor), Feral Rage (+2 dmg, resistance)
/// - Bulwark: Sentinel Stance (+2 AC), Guardian's Mark (UI hook only — full effect M6.5)
/// - Shadow-Pelt: Sneak Attack (+1d6 first hit per turn with finesse/ranged weapon)
///
/// Stubs (no combat effect at M6 — flagged for later wiring):
/// - Scent-Broker, Covenant-Keeper, Muzzle-Speaker, Claw-Wright level-1
/// features. They appear in level_table but don't alter dice yet.
/// </summary>
public static class FeatureProcessor
{
/// <summary>
/// Returns the raw AC for a character, factoring in class features.
/// Called by <see cref="DerivedStats.ArmorClass"/> *after* the standard
/// armor/shield/DEX computation so this layer can either replace
/// (Unarmored Defense) or add (Sentinel Stance) to the base.
///
/// Returns the *new* AC value to use; pass back <paramref name="baseAc"/>
/// when no feature applies.
/// </summary>
public static int ApplyAcFeatures(Theriapolis.Core.Rules.Character.Character c, int baseAc)
{
int ac = baseAc;
// Feral Unarmored Defense replaces base if no body armor.
if (c.ClassDef.Id == "feral" && c.Inventory.GetEquipped(EquipSlot.Body) is null)
{
int dex = c.Abilities.ModFor(AbilityId.DEX);
int con = c.Abilities.ModFor(AbilityId.CON);
int unarmoredAc = 10 + dex + con;
// Take whichever is higher — Feral may pick up a buckler offhand etc. that pushes baseAc higher.
if (unarmoredAc > ac) ac = unarmoredAc;
}
return ac;
}
/// <summary>
/// AC bonus from per-encounter combat-time features (Sentinel Stance, etc).
/// Combat resolver adds this to the combatant's base AC at attack-resolution time.
///
/// Phase 6.5 M2 layers in subclass passive AC bonuses — caller passes
/// the encounter so the resolver can consult positional state for
/// adjacency-driven features (Herd-Wall Interlock Shields, Lone Fang
/// Isolation Bonus).
/// </summary>
public static int ApplyAcBonus(Combatant target, Encounter? enc = null)
{
int bonus = 0;
if (target.SentinelStanceActive) bonus += 2;
// Phase 6.5 M2 subclass passives.
var c = target.SourceCharacter;
if (c is not null && enc is not null && !string.IsNullOrEmpty(c.SubclassId))
{
switch (c.SubclassId)
{
case "lone_fang":
if (HasLoneFangIsolation(target, enc)) bonus += 1;
break;
case "herd_wall":
if (HasHerdWallAdjacentAlly(target, enc)) bonus += 1;
break;
}
}
return bonus;
}
/// <summary>
/// Phase 6.5 M2 — to-hit bonus from subclass features that boost
/// attack rolls (e.g. Lone Fang Isolation Bonus). Resolver adds this
/// to <c>attackTotal</c> alongside the base attack bonus.
/// </summary>
public static int ApplyToHitBonus(Combatant attacker, Encounter enc)
{
int bonus = 0;
var c = attacker.SourceCharacter;
if (c is null || string.IsNullOrEmpty(c.SubclassId)) return 0;
switch (c.SubclassId)
{
case "lone_fang":
if (HasLoneFangIsolation(attacker, enc)) bonus += 2;
break;
}
return bonus;
}
/// <summary>
/// True when the Lone Fang's "Isolation Bonus" applies — no allied
/// combatant within 10 ft. <see cref="ReachAndCover.EdgeToEdgeChebyshev"/>
/// returns the number of *empty tiles between* two footprints, so:
/// 0 = touching (5 ft. away), 1 = one empty tile (10 ft.), etc.
/// "Within 10 ft" means edge-to-edge ≤ 1.
/// </summary>
private static bool HasLoneFangIsolation(Combatant self, Encounter enc)
{
foreach (var c in enc.Participants)
{
if (c.Id == self.Id) continue;
if (c.IsDown) continue;
bool sameSide = (self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
&& (c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
if (!sameSide) continue;
if (ReachAndCover.EdgeToEdgeChebyshev(self, c) <= 1) return false;
}
return true;
}
/// <summary>
/// True when the Herd-Wall has at least one allied combatant adjacent.
/// "Adjacent" in the d20 sense = sharing an edge or corner; with the
/// edge-to-edge "empty tiles between" metric that's distance 0.
/// </summary>
private static bool HasHerdWallAdjacentAlly(Combatant self, Encounter enc)
{
foreach (var c in enc.Participants)
{
if (c.Id == self.Id) continue;
if (c.IsDown) continue;
bool sameSide = (self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
&& (c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
if (!sameSide) continue;
if (ReachAndCover.EdgeToEdgeChebyshev(self, c) == 0) return true;
}
return false;
}
/// <summary>
/// Phase 6.5 M2 — Pack-Forged "Packmate's Howl". Called from the
/// resolver when a Pack-Forged hits a target with a melee attack:
/// marks the target so the next *ally* attack against it gains
/// advantage (until the marker's next turn).
/// </summary>
public static void OnPackForgedHit(Encounter enc, Combatant attacker, Combatant target, AttackOption attack)
{
var c = attacker.SourceCharacter;
if (c is null || c.SubclassId != "pack_forged") return;
if (attack.IsRanged) return; // melee only per the description
target.HowlMarkRound = enc.RoundNumber;
target.HowlMarkBy = attacker.Id;
enc.AppendLog(CombatLogEntry.Kind.Note,
$" Packmate's Howl: {target.Name} marked — next ally attack has advantage.");
}
/// <summary>
/// Phase 6.5 M2 — Pack-Forged consumption hook. If the target carries
/// a Howl mark from one of the attacker's *allies* (not self), and the
/// mark hasn't expired, returns true (advantage on this attack) and
/// clears the mark. Resolver calls this before rolling the d20.
/// </summary>
public static bool ConsumeHowlAdvantage(Encounter enc, Combatant attacker, Combatant target)
{
if (target.HowlMarkRound is not int markRound) return false;
if (target.HowlMarkBy is not int markBy) return false;
if (markBy == attacker.Id) return false; // can't consume your own mark
// Mark expires once the marker's next turn begins. Approximation: a
// mark placed on round N consumed on round N or N+1 (before marker
// gets to act) is valid; round > markRound + 1 = expired.
if (enc.RoundNumber > markRound + 1) return false;
// Allies only: the marker must be on the same side as the attacker.
var marker = enc.GetById(markBy);
if (marker is null) return false;
bool sameSide = (attacker.Allegiance == marker.Allegiance)
|| (attacker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
&& marker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
|| (attacker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied
&& marker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player);
if (!sameSide) return false;
// Consume.
target.HowlMarkRound = null;
target.HowlMarkBy = null;
enc.AppendLog(CombatLogEntry.Kind.Note,
$" {attacker.Name} consumes Packmate's Howl — advantage on this attack.");
return true;
}
/// <summary>
/// Phase 6.5 M2 — Blood Memory "Predatory Surge". Called by the
/// resolver when a raging Feral with this subclass reduces a target
/// to 0 HP with a melee attack. Sets the surge-pending flag; the HUD
/// can offer the player a free bonus melee attack (M2 wires the flag;
/// the bonus-action consumption is the player's job via the existing
/// attack input).
/// </summary>
public static void OnBloodMemoryKill(Encounter enc, Combatant attacker, AttackOption attack)
{
var c = attacker.SourceCharacter;
if (c is null || c.SubclassId != "blood_memory") return;
if (!attacker.RageActive) return;
if (attack.IsRanged) return;
attacker.PredatorySurgePending = true;
enc.AppendLog(CombatLogEntry.Kind.Note,
$" Predatory Surge: {attacker.Name} can take a free melee attack.");
}
/// <summary>
/// Damage bonus from feature effects (Fighting Style, Rage, Sneak Attack).
/// Returns extra damage to add to the rolled total. Side effects: marks
/// <see cref="Combatant.SneakAttackUsedThisTurn"/> when sneak attack fires.
/// </summary>
public static int ApplyDamageBonus(
Encounter enc,
Combatant attacker,
Combatant target,
AttackOption attack,
bool isCrit)
{
int bonus = 0;
var c = attacker.SourceCharacter;
// Feral Rage — +2 damage on melee attacks while raging.
if (attacker.RageActive && !attack.IsRanged) bonus += 2;
// Fangsworn fighting styles.
if (c is not null && c.ClassDef.Id == "fangsworn")
{
if (c.FightingStyle == "duelist" && IsOneHanded(c))
bonus += 2;
// Great Weapon and Natural Predator handled elsewhere (re-roll
// and to-hit respectively).
}
// Shadow-Pelt Sneak Attack — once per turn, +1d6 with finesse/ranged.
if (c is not null && c.ClassDef.Id == "shadow_pelt"
&& !attacker.SneakAttackUsedThisTurn
&& IsFinesseOrRanged(attacker, attack))
{
int d6 = enc.RollDie(6);
if (isCrit) d6 += enc.RollDie(6); // crit doubles the sneak attack die
bonus += d6;
attacker.SneakAttackUsedThisTurn = true;
enc.AppendLog(CombatLogEntry.Kind.Note, $" Sneak Attack: +{d6}");
}
// Phase 7 M0 — Ambush-Artist "Opening Strike". First melee attack of
// round 1 in the encounter (the "ambush" round) deals +2d6 sneak
// damage. Stacks with base Sneak Attack — opening strike represents
// a different surprise mechanism.
if (c is not null && c.SubclassId == "ambush_artist"
&& !attacker.OpeningStrikeUsed
&& enc.RoundNumber == 1
&& !attack.IsRanged)
{
int d6a = enc.RollDie(6);
int d6b = enc.RollDie(6);
int extra = d6a + d6b;
if (isCrit) extra += enc.RollDie(6) + enc.RollDie(6);
bonus += extra;
attacker.OpeningStrikeUsed = true;
enc.AppendLog(CombatLogEntry.Kind.Note, $" Opening Strike: +{extra}");
}
// Phase 7 M0 — Stampede-Heart "Trampling Charge". First melee attack
// each turn while raging deals +1d8 bludgeoning. Phase 7 simplifies
// the JSON's "moved 20+ ft. straight" geometry constraint to "first
// melee attack while raging" — captures the spirit of the charge
// without requiring a movement-vector tracker the tactical layer
// doesn't yet expose. Phase 8 / 9 polish can refine.
if (c is not null && c.SubclassId == "stampede_heart"
&& attacker.RageActive
&& !attacker.TramplingChargeUsedThisTurn
&& !attack.IsRanged)
{
int d8 = enc.RollDie(8);
if (isCrit) d8 += enc.RollDie(8);
bonus += d8;
attacker.TramplingChargeUsedThisTurn = true;
enc.AppendLog(CombatLogEntry.Kind.Note, $" Trampling Charge: +{d8}");
}
return bonus;
}
/// <summary>
/// Phase 7 M0 — Antler-Guard "Retaliatory Strike". Called from
/// <see cref="Resolver.AttemptAttack"/> after damage applies on a melee
/// hit. If the target is an Antler-Guard Bulwark in Sentinel Stance,
/// the attacker takes 1d8 + CON (the target's CON) automatic damage.
/// Phase 7 contract: deterrence-style return-damage, no save, no roll —
/// the attack itself is the trigger. Doesn't fire on ranged attacks
/// (the JSON specifies "from a melee attack").
/// </summary>
public static int OnAntlerGuardHit(Encounter enc, Combatant attacker, Combatant target, AttackOption attack)
{
var c = target.SourceCharacter;
if (c is null || c.SubclassId != "antler_guard") return 0;
if (!target.SentinelStanceActive) return 0;
if (attack.IsRanged) return 0;
// 1d8 + CON-mod return damage; min 1.
int d8 = enc.RollDie(8);
int con = AbilityScores.Mod(target.Abilities.Get(AbilityId.CON));
int retaliation = System.Math.Max(1, d8 + con);
Resolver.ApplyDamage(attacker, retaliation);
enc.AppendLog(CombatLogEntry.Kind.Note,
$" Retaliatory Strike: {target.Name} returns {retaliation} ({d8}+{con}) to {attacker.Name}.");
return retaliation;
}
/// <summary>
/// Re-roll 1s and 2s on damage dice for Fangsworn Great Weapon style.
/// Called by DamageRoll.Roll only if the attacker has the style + a
/// two-handed weapon. Returns the (possibly adjusted) dice value.
/// </summary>
public static int GreatWeaponReroll(Encounter enc, Combatant attacker, AttackOption attack, int rolledDie, int sides)
{
var c = attacker.SourceCharacter;
if (c is null || c.ClassDef.Id != "fangsworn" || c.FightingStyle != "great_weapon") return rolledDie;
if (!IsTwoHanded(c)) return rolledDie;
if (rolledDie > 2) return rolledDie;
// Re-roll once and take the new value (even if also 1 or 2).
int rerolled = enc.RollDie(sides);
return rerolled;
}
/// <summary>
/// True if the damage type is fully resisted (half-damage). Phase 5 M6:
/// Feral Rage gives resistance to bludgeoning/piercing/slashing while active.
/// </summary>
public static bool IsResisted(Combatant target, DamageType damageType)
{
if (target.RageActive)
{
return damageType == DamageType.Bludgeoning
|| damageType == DamageType.Piercing
|| damageType == DamageType.Slashing;
}
return false;
}
/// <summary>
/// Activate Feral Rage. Returns true if the rage started (had uses
/// remaining); false if the character has no uses left.
/// </summary>
public static bool TryActivateRage(Encounter enc, Combatant attacker)
{
var c = attacker.SourceCharacter;
if (c is null || c.ClassDef.Id != "feral") return false;
if (attacker.RageActive) return false;
if (c.RageUsesRemaining <= 0) return false;
attacker.RageActive = true;
c.RageUsesRemaining--;
enc.AppendLog(CombatLogEntry.Kind.Note, $"{attacker.Name} enters a rage. ({c.RageUsesRemaining} use(s) left)");
return true;
}
/// <summary>Toggle Bulwark Sentinel Stance.</summary>
public static bool ToggleSentinelStance(Encounter enc, Combatant attacker)
{
var c = attacker.SourceCharacter;
if (c is null || c.ClassDef.Id != "bulwark") return false;
attacker.SentinelStanceActive = !attacker.SentinelStanceActive;
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{attacker.Name} {(attacker.SentinelStanceActive ? "enters" : "leaves")} Sentinel Stance.");
return true;
}
// ── Phase 6.5 M1: level-1 active class features ──────────────────────
/// <summary>
/// Claw-Wright <c>field_repair</c>. Action; heals <c>1d8 + INT mod</c>
/// HP to the target. Hybrid heal-target effectiveness (75%) applies if
/// the target is a hybrid PC (Phase 6.5 M5 schema-stub for now — no
/// hybrids exist yet, so the multiplier is gated by future data).
/// </summary>
public static bool TryFieldRepair(Encounter enc, Combatant healer, Combatant target)
{
var c = healer.SourceCharacter;
if (c is null || c.ClassDef.Id != "claw_wright") return false;
if (c.FieldRepairUsesRemaining <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note, $"{healer.Name}: Field Repair exhausted (rest to recover).");
return false;
}
if (target.IsDown) return false;
int intMod = c.Abilities.ModFor(AbilityId.INT);
// Phase 7 M0 — Body-Wright "Combat Medic" rolls 2d8 + INT instead of
// the base 1d8 + INT. The bonus-action treatment described in the
// JSON is a HUD-side concern (the resource economy is unchanged);
// this hook adjusts only the dice.
int rolled;
if (c.SubclassId == "body_wright")
{
rolled = enc.RollDie(8) + enc.RollDie(8);
}
else
{
rolled = enc.RollDie(8);
}
int healed = Math.Max(1, rolled + intMod);
// Phase 6.5 M4 — Medical Incompatibility: hybrid recipients heal at
// 75% effectiveness (round down, min 1). Non-hybrids pass through.
int delivered = healed;
if (target.SourceCharacter is { } targetCharacter)
delivered = Theriapolis.Core.Rules.Character.HybridDetriments.ScaleHealForHybrid(
targetCharacter, healed);
target.CurrentHp = Math.Min(target.MaxHp, target.CurrentHp + delivered);
c.FieldRepairUsesRemaining--;
string scaledNote = (target.SourceCharacter?.IsHybrid ?? false) && delivered != healed
? $" (hybrid → {delivered})"
: "";
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{healer.Name} Field Repair on {target.Name}: rolled {rolled} + INT {intMod:+#;-#;0} = {healed} HP{scaledNote}. ({target.CurrentHp}/{target.MaxHp})");
return true;
}
/// <summary>
/// Covenant-Keeper <c>lay_on_paws</c>. Action; spend up to a fixed
/// amount from a pool of <c>5 × CHA</c> HP per long rest (per-encounter
/// at M1) to heal a target. Pool tops up via
/// <see cref="EnsureLayOnPawsPoolReady"/>; spending one point cures
/// disease — not modelled here yet (no disease subsystem).
/// </summary>
public static bool TryLayOnPaws(Encounter enc, Combatant healer, Combatant target, int requestHp)
{
var c = healer.SourceCharacter;
if (c is null || c.ClassDef.Id != "covenant_keeper") return false;
if (target.IsDown) return false;
if (requestHp <= 0) return false;
int spend = Math.Min(requestHp, c.LayOnPawsPoolRemaining);
if (spend <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note, $"{healer.Name}: Lay on Paws pool empty (rest to refill).");
return false;
}
// Phase 6.5 M4 — Medical Incompatibility scales hybrid heal received,
// but the *cost* to the pool is the requested amount. (Hybrid pays
// the same cost; the inefficiency models the body resisting the
// calibration, not the healer wasting effort.)
int delivered = spend;
if (target.SourceCharacter is { } targetCharacter)
delivered = Theriapolis.Core.Rules.Character.HybridDetriments.ScaleHealForHybrid(
targetCharacter, spend);
target.CurrentHp = Math.Min(target.MaxHp, target.CurrentHp + delivered);
c.LayOnPawsPoolRemaining -= spend;
string scaledNote = (target.SourceCharacter?.IsHybrid ?? false) && delivered != spend
? $" (hybrid → {delivered})"
: "";
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{healer.Name} channels Lay on Paws → {target.Name} +{spend} HP{scaledNote}. ({target.CurrentHp}/{target.MaxHp}, pool {c.LayOnPawsPoolRemaining})");
return true;
}
/// <summary>
/// Initialise / refresh the Lay on Paws pool to <c>5 × CHA mod</c> if
/// the character has the <c>lay_on_paws</c> feature. Called at
/// encounter start so M1 (no rest model) treats every encounter as
/// fully rested. CHA mod ≤ 0 yields a 1-point minimum so a low-CHA
/// Covenant-Keeper still has a token pool.
/// </summary>
public static void EnsureLayOnPawsPoolReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "covenant_keeper") return;
int chaMod = c.Abilities.ModFor(AbilityId.CHA);
int target = Math.Max(1, 5 * Math.Max(1, chaMod));
if (c.LayOnPawsPoolRemaining < target)
c.LayOnPawsPoolRemaining = target;
}
/// <summary>
/// Refresh the Field Repair use to 1 if it's been spent. Encounter-rest
/// equivalence per the Phase 5 contract.
/// </summary>
public static void EnsureFieldRepairReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "claw_wright") return;
if (c.FieldRepairUsesRemaining < 1) c.FieldRepairUsesRemaining = 1;
}
/// <summary>
/// Refresh Vocalization Dice to 4 if any have been spent. Encounter-rest
/// equivalence per the Phase 5 contract.
/// </summary>
public static void EnsureVocalizationDiceReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "muzzle_speaker") return;
if (c.VocalizationDiceRemaining < 4) c.VocalizationDiceRemaining = 4;
}
// ── Phase 6.5 M3: Pheromone Craft (Scent-Broker) ─────────────────────
/// <summary>
/// Pheromone Craft uses-per-encounter cap based on character level. The
/// JSON ladder unlocks more uses at higher levels:
/// L14 → 0 (feature not unlocked yet),
/// L58 → 2 (pheromone_craft_2 from L2 entry retains here, but the L5
/// entry brings pheromone_craft_3),
/// L912 → 4, L13+ → 5. The granted-at-each-level structure in
/// <c>classes.json</c> uses the highest-tier feature unlocked.
/// </summary>
public static int PheromoneUsesAtLevel(int level) => level switch
{
>= 13 => 5,
>= 9 => 4,
>= 5 => 3,
>= 2 => 2,
_ => 0,
};
/// <summary>
/// Refill the Scent-Broker's Pheromone Craft pool to the per-level cap.
/// Encounter-rest equivalence; Phase 8 replaces with real short-rest.
/// </summary>
public static void EnsurePheromoneUsesReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "scent_broker") return;
int cap = PheromoneUsesAtLevel(c.Level);
if (c.PheromoneUsesRemaining < cap) c.PheromoneUsesRemaining = cap;
}
/// <summary>
/// Scent-Broker <c>pheromone_craft_*</c>. Bonus action; emits a 10-ft
/// (= 2 tactical tile) cloud centred on the caster. Every creature in
/// range that the caster considers hostile must make a CON save vs.
/// <c>DC = 8 + prof + WIS mod</c>; on failure, the pheromone-mapped
/// <see cref="Theriapolis.Core.Rules.Stats.Condition"/> is applied
/// (<see cref="PheromoneTypeExtensions.AppliedCondition"/>).
/// Consumes one Pheromone Use.
/// </summary>
public static bool TryEmitPheromone(Encounter enc, Combatant caster, PheromoneType type)
{
var c = caster.SourceCharacter;
if (c is null || c.ClassDef.Id != "scent_broker") return false;
if (c.Level < 2)
{
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name}: Pheromone Craft unlocks at level 2.");
return false;
}
if (c.PheromoneUsesRemaining <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name}: Pheromone Craft exhausted (rest to recover).");
return false;
}
int wisMod = c.Abilities.ModFor(Theriapolis.Core.Rules.Stats.AbilityId.WIS);
int dc = 8 + c.ProficiencyBonus + wisMod;
var applied = type.AppliedCondition();
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name} emits {type.DisplayName()} pheromone (DC {dc}).");
int affected = 0;
foreach (var t in enc.Participants)
{
if (t.Id == caster.Id) continue;
if (t.IsDown) continue;
// 10 ft. cloud = within 1 empty tile (≤ 1 edge-to-edge).
if (ReachAndCover.EdgeToEdgeChebyshev(caster, t) > 1) continue;
// Only target hostiles for offensive pheromones; calm targets
// hostiles too (charmed-toward-source is the desired effect).
bool sameSide = (caster.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| caster.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
&& (t.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|| t.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
if (sameSide) continue;
// Roll CON save: 1d20 + CON mod.
int conMod = Theriapolis.Core.Rules.Stats.AbilityScores.Mod(t.Abilities.CON);
int saveRoll = enc.RollD20();
int saveTotal = saveRoll + conMod;
bool saved = saveTotal >= dc;
enc.AppendLog(CombatLogEntry.Kind.Save,
$" {t.Name} CON save: {saveRoll}{conMod:+#;-#;0} = {saveTotal} vs DC {dc} → {(saved ? "saved" : "FAILED")}");
if (!saved && applied != Theriapolis.Core.Rules.Stats.Condition.None)
{
Resolver.ApplyCondition(enc, t, applied);
affected++;
}
}
c.PheromoneUsesRemaining--;
if (affected == 0)
enc.AppendLog(CombatLogEntry.Kind.Note,
$" No hostiles affected by the pheromone.");
return true;
}
// ── Phase 6.5 M3: Covenant Authority (Covenant-Keeper) ───────────────
/// <summary>
/// Covenant Authority uses-per-encounter cap based on level. JSON
/// ladder: <c>covenants_authority_2/3/4/5</c> at L2/L9/L13/L17 →
/// 2 / 3 / 4 / 5.
/// </summary>
public static int CovenantAuthorityUsesAtLevel(int level) => level switch
{
>= 17 => 5,
>= 13 => 4,
>= 9 => 3,
>= 2 => 2,
_ => 0,
};
/// <summary>
/// Refill the Covenant-Keeper's Authority pool to the per-level cap.
/// </summary>
public static void EnsureCovenantAuthorityReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "covenant_keeper") return;
int cap = CovenantAuthorityUsesAtLevel(c.Level);
if (c.CovenantAuthorityUsesRemaining < cap) c.CovenantAuthorityUsesRemaining = cap;
}
/// <summary>
/// Covenant-Keeper <c>covenants_authority_*</c>. Bonus action; declares
/// an oath against a target hostile; for 10 rounds (1 minute), the
/// oath-marked creature suffers -2 to attack rolls against the
/// Covenant-Keeper. Consumes one use. The full three-option
/// description (Compel Truth / Rebuke Predation / Shield the Innocent)
/// is plan-deferred to Phase 8/9 dialogue + AoE polish; M3 ships the
/// simple combat-marker mechanic per the Phase 6.5 plan §4.4.
/// </summary>
public static bool TryDeclareOath(Encounter enc, Combatant caster, Combatant target)
{
var c = caster.SourceCharacter;
if (c is null || c.ClassDef.Id != "covenant_keeper") return false;
if (c.Level < 2)
{
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name}: Covenant's Authority unlocks at level 2.");
return false;
}
if (c.CovenantAuthorityUsesRemaining <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name}: Covenant's Authority exhausted (rest to recover).");
return false;
}
if (target.IsDown) return false;
if (target.Id == caster.Id) return false;
target.OathMarkRound = enc.RoundNumber;
target.OathMarkBy = caster.Id;
c.CovenantAuthorityUsesRemaining--;
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name} pronounces an oath against {target.Name} — -2 attack vs caster for 1 minute.");
return true;
}
/// <summary>
/// Phase 6.5 M3 — to-hit penalty applied to a marked attacker rolling
/// against the Covenant-Keeper who marked them. Returns 0 when no
/// active oath, -2 when the marked attacker targets the marker, and 0
/// for any other target (the oath is target-specific).
/// </summary>
public static int OathAttackPenalty(Encounter enc, Combatant attacker, Combatant defender)
{
if (attacker.OathMarkRound is not int markRound) return 0;
if (attacker.OathMarkBy is not int markBy) return 0;
// Expire after 10 rounds.
if (enc.RoundNumber > markRound + 9)
{
attacker.OathMarkRound = null;
attacker.OathMarkBy = null;
return 0;
}
if (markBy != defender.Id) return 0; // penalty only when attacking the marker
return -2;
}
/// <summary>
/// Muzzle-Speaker Vocalization Dice (level-1 d6, scaling to d8/d10/d12
/// at L5/L9/L15). Bonus action; consumes one die. The target combatant
/// gains <see cref="Combatant.InspirationDieSides"/> = the current die
/// size; the next attack/check/save they make rolls that bonus.
/// </summary>
public static bool TryGrantVocalizationDie(Encounter enc, Combatant caster, Combatant ally)
{
var c = caster.SourceCharacter;
if (c is null || c.ClassDef.Id != "muzzle_speaker") return false;
if (caster.Id == ally.Id) return false; // can't inspire yourself
if (c.VocalizationDiceRemaining <= 0)
{
enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: Vocalization Dice spent (rest to recover).");
return false;
}
if (ally.InspirationDieSides > 0)
{
// Already inspired — overlapping inspirations don't stack at L1.
enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: {ally.Name} already inspired.");
return false;
}
// Range gate: 60 ft. = 12 tactical tiles per the standard 5-ft tile.
int dist = caster.DistanceTo(ally);
if (dist > 12)
{
enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: {ally.Name} too far for Vocalization Dice ({dist}/12).");
return false;
}
int sides = VocalizationDieSidesFor(c.Level);
ally.InspirationDieSides = sides;
c.VocalizationDiceRemaining--;
enc.AppendLog(CombatLogEntry.Kind.Note,
$"{caster.Name} grants {ally.Name} a Vocalization Die (1d{sides}). ({c.VocalizationDiceRemaining} left)");
return true;
}
/// <summary>
/// Standard d20 Bardic Inspiration die ladder, mapped to Vocalization
/// Dice per <c>classes.json</c> level table:
/// 14 → d6; 58 → d8; 914 → d10; 15+ → d12.
/// </summary>
public static int VocalizationDieSidesFor(int level) => level switch
{
>= 15 => 12,
>= 9 => 10,
>= 5 => 8,
_ => 6,
};
/// <summary>
/// Consume an inspiration die (if any) on a d20 roll. Adds 1d&lt;sides&gt;
/// to the d20 result and clears the field. Returns the bonus added (0 if
/// no inspiration was active).
/// </summary>
public static int ConsumeInspirationDie(Encounter enc, Combatant roller)
{
if (roller.InspirationDieSides <= 0) return 0;
int sides = roller.InspirationDieSides;
int rolled = enc.RollDie(sides);
roller.InspirationDieSides = 0;
enc.AppendLog(CombatLogEntry.Kind.Note,
$" {roller.Name} adds Vocalization Die (1d{sides} = {rolled}).");
return rolled;
}
// ── Helpers ──────────────────────────────────────────────────────────
private static bool IsOneHanded(Theriapolis.Core.Rules.Character.Character c)
{
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
if (main is null) return false;
if (HasProp(main.Def, "two_handed")) return false;
var off = c.Inventory.GetEquipped(EquipSlot.OffHand);
// Duelist requires the off hand to be empty (shields don't count as another weapon, but the d20 spec says "no other weapon" — for M6 we treat shields as OK).
if (off is null) return true;
return string.Equals(off.Def.Kind, "shield", System.StringComparison.OrdinalIgnoreCase);
}
private static bool IsTwoHanded(Theriapolis.Core.Rules.Character.Character c)
{
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
return main is not null && HasProp(main.Def, "two_handed");
}
private static bool IsFinesseOrRanged(Combatant attacker, AttackOption attack)
{
if (attack.IsRanged) return true;
var c = attacker.SourceCharacter;
if (c is null) return false;
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
if (main is null) return false;
return HasProp(main.Def, "finesse");
}
private static bool HasProp(Theriapolis.Core.Data.ItemDef def, string prop)
{
foreach (var p in def.Properties)
if (string.Equals(p, prop, System.StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}