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>
This commit is contained in:
@@ -0,0 +1,781 @@
|
||||
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:
|
||||
/// 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
|
||||
/// <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:
|
||||
/// 1–4 → d6; 5–8 → d8; 9–14 → 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<sides>
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user