using Theriapolis.Core; using Theriapolis.Core.Data; using Theriapolis.Core.Entities.Ai; using Theriapolis.Core.Items; using Theriapolis.Core.Rules.Character; using Theriapolis.Core.Rules.Combat; using Theriapolis.Core.Rules.Stats; using Theriapolis.Core.Util; using Xunit; namespace Theriapolis.Tests.Combat; /// /// Phase 6.5 M1 — level-1 class-feature catch-up: Field Repair (Claw-Wright), /// Lay on Paws (Covenant-Keeper), Vocalization Dice (Muzzle-Speaker). /// Scent Literacy is a UI-only feature in M1 and is exercised at the /// integration level rather than here. /// public sealed class Phase65M1FeatureTests { private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory)); // ── Field Repair ────────────────────────────────────────────────────── [Fact] public void FieldRepair_HealsTargetByOneD8PlusInt_AndConsumesUse() { var enc = MakeEncounter(out var healer, out var ally, healerClass: "claw_wright", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); // Damage the ally so the heal has somewhere to land. ally.CurrentHp = 5; int beforeUses = healer.SourceCharacter!.FieldRepairUsesRemaining; bool ok = FeatureProcessor.TryFieldRepair(enc, healer, ally); Assert.True(ok); Assert.Equal(beforeUses - 1, healer.SourceCharacter.FieldRepairUsesRemaining); Assert.True(ally.CurrentHp > 5, $"ally HP should rise; was 5, now {ally.CurrentHp}"); // 1d8 + INT mod (Claw-Wright with INT 13 from default kit → +1) → ≥ 2. Assert.True(ally.CurrentHp - 5 >= 2); } [Fact] public void FieldRepair_RefusesWhenExhausted() { var enc = MakeEncounter(out var healer, out var ally, healerClass: "claw_wright", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); healer.SourceCharacter!.FieldRepairUsesRemaining = 0; ally.CurrentHp = 5; bool ok = FeatureProcessor.TryFieldRepair(enc, healer, ally); Assert.False(ok); Assert.Equal(5, ally.CurrentHp); } [Fact] public void FieldRepair_OnlyForClawWright() { var enc = MakeEncounter(out var notHealer, out var ally, healerClass: "fangsworn", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); ally.CurrentHp = 5; bool ok = FeatureProcessor.TryFieldRepair(enc, notHealer, ally); Assert.False(ok); } [Fact] public void EnsureFieldRepairReady_RestoresUseAfterEncounter() { var c = MakeChar("claw_wright", new AbilityScores(10, 12, 13, 14, 12, 8)); c.FieldRepairUsesRemaining = 0; FeatureProcessor.EnsureFieldRepairReady(c); Assert.Equal(1, c.FieldRepairUsesRemaining); } // ── Lay on Paws ─────────────────────────────────────────────────────── [Fact] public void LayOnPaws_SpendsPoolAndHealsTarget() { var enc = MakeEncounter(out var healer, out var ally, healerClass: "covenant_keeper", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); FeatureProcessor.EnsureLayOnPawsPoolReady(healer.SourceCharacter!); int poolBefore = healer.SourceCharacter!.LayOnPawsPoolRemaining; Assert.True(poolBefore >= 1); ally.CurrentHp = ally.MaxHp - 4; bool ok = FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 4); Assert.True(ok); Assert.Equal(ally.MaxHp, ally.CurrentHp); Assert.Equal(poolBefore - 4, healer.SourceCharacter.LayOnPawsPoolRemaining); } [Fact] public void LayOnPaws_ClampsToPoolRemaining() { var enc = MakeEncounter(out var healer, out var ally, healerClass: "covenant_keeper", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); healer.SourceCharacter!.LayOnPawsPoolRemaining = 3; ally.CurrentHp = 1; bool ok = FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 99); Assert.True(ok); Assert.Equal(0, healer.SourceCharacter.LayOnPawsPoolRemaining); Assert.Equal(4, ally.CurrentHp); } [Fact] public void LayOnPaws_RefusesWhenPoolEmpty() { var enc = MakeEncounter(out var healer, out var ally, healerClass: "covenant_keeper", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); healer.SourceCharacter!.LayOnPawsPoolRemaining = 0; ally.CurrentHp = 5; bool ok = FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 5); Assert.False(ok); Assert.Equal(5, ally.CurrentHp); } [Fact] public void EnsureLayOnPawsPool_ScalesWithCha() { var c = MakeChar("covenant_keeper", new AbilityScores(15, 10, 13, 10, 12, 16)); c.LayOnPawsPoolRemaining = 0; FeatureProcessor.EnsureLayOnPawsPoolReady(c); // CHA 16 → +3 mod → 5 × 3 = 15 pool. Assert.Equal(15, c.LayOnPawsPoolRemaining); } [Fact] public void EnsureLayOnPawsPool_LowChaStillGetsTokenPool() { var c = MakeChar("covenant_keeper", new AbilityScores(15, 10, 13, 10, 12, 8)); c.LayOnPawsPoolRemaining = 0; FeatureProcessor.EnsureLayOnPawsPoolReady(c); // CHA 8 → -1 mod → minimum 5 pool. Assert.True(c.LayOnPawsPoolRemaining >= 1); } // ── Vocalization Dice ───────────────────────────────────────────────── [Fact] public void VocalizationDieSidesFor_FollowsLevelLadder() { Assert.Equal(6, FeatureProcessor.VocalizationDieSidesFor(1)); Assert.Equal(6, FeatureProcessor.VocalizationDieSidesFor(4)); Assert.Equal(8, FeatureProcessor.VocalizationDieSidesFor(5)); Assert.Equal(10, FeatureProcessor.VocalizationDieSidesFor(9)); Assert.Equal(12, FeatureProcessor.VocalizationDieSidesFor(15)); } [Fact] public void TryGrantVocalizationDie_GivesAllyInspirationAndConsumesUse() { var enc = MakeEncounter(out var caster, out var ally, healerClass: "muzzle_speaker", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); int before = caster.SourceCharacter!.VocalizationDiceRemaining; bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, ally); Assert.True(ok); Assert.Equal(6, ally.InspirationDieSides); Assert.Equal(before - 1, caster.SourceCharacter.VocalizationDiceRemaining); } [Fact] public void TryGrantVocalizationDie_RefusesSelfTarget() { var enc = MakeEncounter(out var caster, out _, healerClass: "muzzle_speaker", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, caster); Assert.False(ok); Assert.Equal(0, caster.InspirationDieSides); } [Fact] public void TryGrantVocalizationDie_RefusesAlreadyInspired() { var enc = MakeEncounter(out var caster, out var ally, healerClass: "muzzle_speaker", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); ally.InspirationDieSides = 6; int before = caster.SourceCharacter!.VocalizationDiceRemaining; bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, ally); Assert.False(ok); Assert.Equal(before, caster.SourceCharacter.VocalizationDiceRemaining); } [Fact] public void TryGrantVocalizationDie_RefusesOutOfRange() { var enc = MakeEncounter(out var caster, out var ally, healerClass: "muzzle_speaker", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied, allyPosition: new Vec2(20, 0)); // > 12 tactical tiles bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, ally); Assert.False(ok); } [Fact] public void ConsumeInspirationDie_ZeroesAndReturnsRoll() { var enc = MakeEncounter(out var caster, out var ally, healerClass: "muzzle_speaker", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); ally.InspirationDieSides = 6; int rolled = FeatureProcessor.ConsumeInspirationDie(enc, ally); Assert.InRange(rolled, 1, 6); Assert.Equal(0, ally.InspirationDieSides); } [Fact] public void ConsumeInspirationDie_NoOpWhenNoInspiration() { var enc = MakeEncounter(out var caster, out var ally, healerClass: "muzzle_speaker", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); Assert.Equal(0, ally.InspirationDieSides); Assert.Equal(0, FeatureProcessor.ConsumeInspirationDie(enc, ally)); } [Fact] public void EnsureVocalizationDiceReady_RefillsToFour() { var c = MakeChar("muzzle_speaker", new AbilityScores(8, 14, 13, 10, 12, 16)); c.VocalizationDiceRemaining = 0; FeatureProcessor.EnsureVocalizationDiceReady(c); Assert.Equal(4, c.VocalizationDiceRemaining); } // ── AiContext targeting helpers ─────────────────────────────────────── [Fact] public void AiContext_FindClosestAlly_FindsAllyWhenPresent() { var enc = MakeEncounter(out var pc, out var ally, healerClass: "muzzle_speaker", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); var ctx = new AiContext(enc); Assert.Same(ally, ctx.FindClosestAlly(pc)); } [Fact] public void AiContext_FindClosestAlly_NullWhenAlone() { var enc = MakeEncounter(out var pc, out var hostile, healerClass: "muzzle_speaker", allyClass: "fangsworn", allyAllegiance: Allegiance.Hostile); var ctx = new AiContext(enc); Assert.Null(ctx.FindClosestAlly(pc)); } [Fact] public void AiContext_FindMostDamagedFriendly_PrefersWoundedAllyOverFullHpSelf() { var enc = MakeEncounter(out var pc, out var ally, healerClass: "covenant_keeper", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); // PC at full HP, ally damaged. ally.CurrentHp = 5; var ctx = new AiContext(enc); Assert.Same(ally, ctx.FindMostDamagedFriendly(pc)); } [Fact] public void AiContext_FindMostDamagedFriendly_NullWhenAllAtFullHp() { var enc = MakeEncounter(out var pc, out _, healerClass: "covenant_keeper", allyClass: "fangsworn", allyAllegiance: Allegiance.Allied); var ctx = new AiContext(enc); Assert.Null(ctx.FindMostDamagedFriendly(pc)); } // ── Inspiration die end-to-end through Resolver ─────────────────────── [Fact] public void Resolver_ConsumesInspirationDie_OnAttackRoll() { var enc = MakeEncounter(out var attacker, out var target, healerClass: "fangsworn", allyClass: "fangsworn", allyAllegiance: Allegiance.Hostile); attacker.InspirationDieSides = 6; var attack = attacker.AttackOptions[0]; Resolver.AttemptAttack(enc, attacker, target, attack); // The die should have been consumed regardless of hit/miss. Assert.Equal(0, attacker.InspirationDieSides); } // ── Helpers ─────────────────────────────────────────────────────────── private Encounter MakeEncounter( out Combatant healer, out Combatant ally, string healerClass, string allyClass, Allegiance allyAllegiance, Vec2? allyPosition = null) { var hc = MakeChar(healerClass, new AbilityScores(10, 12, 13, 14, 12, 14)); var ac = MakeChar(allyClass, new AbilityScores(15, 12, 13, 10, 10, 8)); healer = Combatant.FromCharacter(hc, 1, "Healer", new Vec2(0, 0), Allegiance.Player); ally = Combatant.FromCharacter(ac, 2, "Ally", allyPosition ?? new Vec2(2, 0), allyAllegiance); return new Encounter(0xFEEDUL, 1, new[] { healer, ally }); } private Theriapolis.Core.Rules.Character.Character MakeChar(string classId, AbilityScores a) { var b = new CharacterBuilder { Clade = _content.Clades["canidae"], Species = _content.Species["wolf"], ClassDef = _content.Classes[classId], Background = _content.Backgrounds["pack_raised"], BaseAbilities = a, }; int n = b.ClassDef.SkillsChoose; foreach (var raw in b.ClassDef.SkillOptions) { if (b.ChosenClassSkills.Count >= n) break; try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { } } return b.Build(_content.Items); } }