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; } }