using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Combat;
///
/// 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.
///
public static class FeatureProcessor
{
///
/// Returns the raw AC for a character, factoring in class features.
/// Called by *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
/// when no feature applies.
///
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;
}
///
/// 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).
///
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;
}
///
/// Phase 6.5 M2 — to-hit bonus from subclass features that boost
/// attack rolls (e.g. Lone Fang Isolation Bonus). Resolver adds this
/// to attackTotal alongside the base attack bonus.
///
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;
}
///
/// True when the Lone Fang's "Isolation Bonus" applies — no allied
/// combatant within 10 ft.
/// 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.
///
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;
}
///
/// 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.
///
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;
}
///
/// 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).
///
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.");
}
///
/// 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.
///
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;
}
///
/// 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).
///
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.");
}
///
/// Damage bonus from feature effects (Fighting Style, Rage, Sneak Attack).
/// Returns extra damage to add to the rolled total. Side effects: marks
/// when sneak attack fires.
///
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;
}
///
/// Phase 7 M0 — Antler-Guard "Retaliatory Strike". Called from
/// 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").
///
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;
}
///
/// 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.
///
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;
}
///
/// True if the damage type is fully resisted (half-damage). Phase 5 M6:
/// Feral Rage gives resistance to bludgeoning/piercing/slashing while active.
///
public static bool IsResisted(Combatant target, DamageType damageType)
{
if (target.RageActive)
{
return damageType == DamageType.Bludgeoning
|| damageType == DamageType.Piercing
|| damageType == DamageType.Slashing;
}
return false;
}
///
/// Activate Feral Rage. Returns true if the rage started (had uses
/// remaining); false if the character has no uses left.
///
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;
}
/// Toggle Bulwark Sentinel Stance.
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 ──────────────────────
///
/// Claw-Wright field_repair. Action; heals 1d8 + INT mod
/// 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).
///
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;
}
///
/// Covenant-Keeper lay_on_paws. Action; spend up to a fixed
/// amount from a pool of 5 × CHA HP per long rest (per-encounter
/// at M1) to heal a target. Pool tops up via
/// ; spending one point cures
/// disease — not modelled here yet (no disease subsystem).
///
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;
}
///
/// Initialise / refresh the Lay on Paws pool to 5 × CHA mod if
/// the character has the lay_on_paws 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.
///
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;
}
///
/// Refresh the Field Repair use to 1 if it's been spent. Encounter-rest
/// equivalence per the Phase 5 contract.
///
public static void EnsureFieldRepairReady(Theriapolis.Core.Rules.Character.Character c)
{
if (c.ClassDef.Id != "claw_wright") return;
if (c.FieldRepairUsesRemaining < 1) c.FieldRepairUsesRemaining = 1;
}
///
/// Refresh Vocalization Dice to 4 if any have been spent. Encounter-rest
/// equivalence per the Phase 5 contract.
///
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) ─────────────────────
///
/// Pheromone Craft uses-per-encounter cap based on character level. The
/// JSON ladder unlocks more uses at higher levels:
/// L1–4 → 0 (feature not unlocked yet),
/// L5–8 → 2 (pheromone_craft_2 from L2 entry retains here, but the L5
/// entry brings pheromone_craft_3),
/// L9–12 → 4, L13+ → 5. The granted-at-each-level structure in
/// classes.json uses the highest-tier feature unlocked.
///
public static int PheromoneUsesAtLevel(int level) => level switch
{
>= 13 => 5,
>= 9 => 4,
>= 5 => 3,
>= 2 => 2,
_ => 0,
};
///
/// Refill the Scent-Broker's Pheromone Craft pool to the per-level cap.
/// Encounter-rest equivalence; Phase 8 replaces with real short-rest.
///
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;
}
///
/// Scent-Broker pheromone_craft_*. 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.
/// DC = 8 + prof + WIS mod; on failure, the pheromone-mapped
/// is applied
/// ().
/// Consumes one Pheromone Use.
///
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) ───────────────
///
/// Covenant Authority uses-per-encounter cap based on level. JSON
/// ladder: covenants_authority_2/3/4/5 at L2/L9/L13/L17 →
/// 2 / 3 / 4 / 5.
///
public static int CovenantAuthorityUsesAtLevel(int level) => level switch
{
>= 17 => 5,
>= 13 => 4,
>= 9 => 3,
>= 2 => 2,
_ => 0,
};
///
/// Refill the Covenant-Keeper's Authority pool to the per-level cap.
///
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;
}
///
/// Covenant-Keeper covenants_authority_*. 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.
///
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;
}
///
/// 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).
///
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;
}
///
/// 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 = the current die
/// size; the next attack/check/save they make rolls that bonus.
///
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;
}
///
/// Standard d20 Bardic Inspiration die ladder, mapped to Vocalization
/// Dice per classes.json level table:
/// 1–4 → d6; 5–8 → d8; 9–14 → d10; 15+ → d12.
///
public static int VocalizationDieSidesFor(int level) => level switch
{
>= 15 => 12,
>= 9 => 10,
>= 5 => 8,
_ => 6,
};
///
/// Consume an inspiration die (if any) on a d20 roll. Adds 1d<sides>
/// to the d20 result and clears the field. Returns the bonus added (0 if
/// no inspiration was active).
///
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;
}
}