using Theriapolis.Core; using Theriapolis.Core.Data; 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 M2 — representative L3 subclass-feature mechanics: /// - Lone Fang "Isolation Bonus": +2 to-hit / +1 AC when alone. /// - Herd-Wall "Interlock Shields": +1 AC with adjacent ally. /// - Pack-Forged "Packmate's Howl": melee hit marks target → ally /// advantage on next attack against it. /// - Blood Memory "Predatory Surge": melee kill while raging sets a /// bonus-attack flag. /// /// Other 12 subclasses' features are scaffolded (definitions loaded, /// LevelUpScreen displays them, save round-trip works) but not yet /// mechanically wired — content authoring for them is a follow-up /// session per M2's plan-acknowledged 24-feature scope. /// public sealed class Phase65M2SubclassFeatureTests { private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory)); // ── Lone Fang Isolation Bonus ───────────────────────────────────────── [Fact] public void LoneFang_IsolationBonus_AddsAcWhenNoAllyNearby() { // Build a Fangsworn with subclass = lone_fang. No allies on the field // (just the attacker and a hostile target). var enc = MakeEncounter(out var pc, out var hostile, pcClass: "fangsworn", pcSubclass: "lone_fang"); int baseAc = pc.ArmorClass; // Target gets attacked; their AC is the relevant query — Lone Fang's // +1 AC applies *to themselves* (the Lone Fang). So instead, query // the AC bonus for the lone fang directly. int bonus = FeatureProcessor.ApplyAcBonus(pc, enc); Assert.Equal(1, bonus); } [Fact] public void LoneFang_IsolationBonus_DropsToZeroWithAdjacentAlly() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "fangsworn", pcSubclass: "lone_fang", includeAlly: true, allyPos: new Vec2(1, 0)); int bonus = FeatureProcessor.ApplyAcBonus(pc, enc); Assert.Equal(0, bonus); } [Fact] public void LoneFang_IsolationBonus_AddsToHitWhenAlone() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "fangsworn", pcSubclass: "lone_fang"); int toHit = FeatureProcessor.ApplyToHitBonus(pc, enc); Assert.Equal(2, toHit); } [Fact] public void LoneFang_NotApplied_WithoutSubclass() { var enc = MakeEncounter(out var pc, out _, pcClass: "fangsworn", pcSubclass: null); Assert.Equal(0, FeatureProcessor.ApplyAcBonus(pc, enc)); Assert.Equal(0, FeatureProcessor.ApplyToHitBonus(pc, enc)); } // ── Herd-Wall Interlock Shields ─────────────────────────────────────── [Fact] public void HerdWall_InterlockShields_AddsAcWithAdjacentAlly() { var enc = MakeEncounter(out var pc, out _, pcClass: "bulwark", pcSubclass: "herd_wall", includeAlly: true, allyPos: new Vec2(1, 0)); Assert.Equal(1, FeatureProcessor.ApplyAcBonus(pc, enc)); } [Fact] public void HerdWall_InterlockShields_NoBonus_WhenAlone() { var enc = MakeEncounter(out var pc, out _, pcClass: "bulwark", pcSubclass: "herd_wall"); Assert.Equal(0, FeatureProcessor.ApplyAcBonus(pc, enc)); } [Fact] public void HerdWall_InterlockShields_NoBonus_WhenAllyNotAdjacent() { var enc = MakeEncounter(out var pc, out _, pcClass: "bulwark", pcSubclass: "herd_wall", includeAlly: true, allyPos: new Vec2(5, 0)); Assert.Equal(0, FeatureProcessor.ApplyAcBonus(pc, enc)); } // ── Pack-Forged Packmate's Howl ─────────────────────────────────────── [Fact] public void PackForged_OnHit_MarksTarget() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "fangsworn", pcSubclass: "pack_forged"); // Simulate the on-hit pathway directly: the resolver calls this on // a melee hit. FeatureProcessor.OnPackForgedHit(enc, pc, hostile, pc.AttackOptions[0]); Assert.True(hostile.HowlMarkRound.HasValue); Assert.Equal(pc.Id, hostile.HowlMarkBy); } [Fact] public void PackForged_OnHit_DoesNotMarkOnRanged() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "fangsworn", pcSubclass: "pack_forged"); var rangedAttack = pc.AttackOptions[0] with { Name = "Bow", RangeShortTiles = 6 }; FeatureProcessor.OnPackForgedHit(enc, pc, hostile, rangedAttack); Assert.False(hostile.HowlMarkRound.HasValue); } [Fact] public void ConsumeHowlAdvantage_FiresForAllyWithinRound() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "fangsworn", pcSubclass: "pack_forged", includeAlly: true, allyPos: new Vec2(1, 0)); var ally = enc.Participants.Single(c => c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied); FeatureProcessor.OnPackForgedHit(enc, pc, hostile, pc.AttackOptions[0]); bool consumed = FeatureProcessor.ConsumeHowlAdvantage(enc, ally, hostile); Assert.True(consumed); // Mark cleared after consumption. Assert.False(hostile.HowlMarkRound.HasValue); // Second consumption returns false. Assert.False(FeatureProcessor.ConsumeHowlAdvantage(enc, ally, hostile)); } [Fact] public void ConsumeHowlAdvantage_RefusesSelfMarker() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "fangsworn", pcSubclass: "pack_forged"); FeatureProcessor.OnPackForgedHit(enc, pc, hostile, pc.AttackOptions[0]); // The marker can't consume their own howl. bool consumed = FeatureProcessor.ConsumeHowlAdvantage(enc, pc, hostile); Assert.False(consumed); // Mark stays in place. Assert.True(hostile.HowlMarkRound.HasValue); } [Fact] public void ConsumeHowlAdvantage_RefusesEnemyAttacker() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "fangsworn", pcSubclass: "pack_forged"); FeatureProcessor.OnPackForgedHit(enc, pc, hostile, pc.AttackOptions[0]); // Enemy attacking same target as the mark — should not consume. bool consumed = FeatureProcessor.ConsumeHowlAdvantage(enc, hostile, hostile); Assert.False(consumed); } // ── Blood Memory Predatory Surge ────────────────────────────────────── [Fact] public void BloodMemory_OnKill_SetsPredatorySurgePending_WhenRaging() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "feral", pcSubclass: "blood_memory"); pc.RageActive = true; FeatureProcessor.OnBloodMemoryKill(enc, pc, pc.AttackOptions[0]); Assert.True(pc.PredatorySurgePending); } [Fact] public void BloodMemory_OnKill_DoesNothing_IfNotRaging() { var enc = MakeEncounter(out var pc, out _, pcClass: "feral", pcSubclass: "blood_memory"); pc.RageActive = false; FeatureProcessor.OnBloodMemoryKill(enc, pc, pc.AttackOptions[0]); Assert.False(pc.PredatorySurgePending); } [Fact] public void BloodMemory_OnKill_DoesNothing_OnRangedKill() { var enc = MakeEncounter(out var pc, out _, pcClass: "feral", pcSubclass: "blood_memory"); pc.RageActive = true; var rangedAttack = pc.AttackOptions[0] with { Name = "Bow", RangeShortTiles = 6 }; FeatureProcessor.OnBloodMemoryKill(enc, pc, rangedAttack); Assert.False(pc.PredatorySurgePending); } // ── LevelUpFlow integration ─────────────────────────────────────────── [Fact] public void LevelUpFlow_PostL3_PopulatesSubclassFeatures() { var c = MakeChar("fangsworn", new AbilityScores(15, 12, 13, 10, 10, 8)); c.SubclassId = "pack_forged"; c.Level = 3; var result = LevelUpFlow.Compute(c, targetLevel: 7, seed: 0xBEEF, takeAverage: true, subclasses: _content.Subclasses); // Pack-Forged's L7 feature is "coordinated_takedown". Assert.Contains("coordinated_takedown", result.SubclassFeaturesUnlocked); } [Fact] public void LevelUpFlow_NoSubclassPicked_ReturnsEmptySubclassFeatures() { var c = MakeChar("fangsworn", new AbilityScores(15, 12, 13, 10, 10, 8)); c.Level = 6; // post-L3 but no subclass chosen (shouldn't happen in // normal flow but test the defensive path). var result = LevelUpFlow.Compute(c, targetLevel: 7, seed: 0xBEEF, takeAverage: true, subclasses: _content.Subclasses); Assert.Empty(result.SubclassFeaturesUnlocked); } [Fact] public void LevelUpFlow_NullSubclassesDict_ReturnsEmptySubclassFeatures() { var c = MakeChar("fangsworn", new AbilityScores(15, 12, 13, 10, 10, 8)); c.SubclassId = "pack_forged"; c.Level = 3; var result = LevelUpFlow.Compute(c, targetLevel: 7, seed: 0xBEEF, takeAverage: true, subclasses: null); Assert.Empty(result.SubclassFeaturesUnlocked); } [Fact] public void Character_ApplyLevelUp_RecordsSubclassFeaturesInLearned() { var c = MakeChar("fangsworn", new AbilityScores(15, 12, 13, 10, 10, 8)); c.SubclassId = "pack_forged"; c.Level = 6; var result = LevelUpFlow.Compute(c, 7, 0xBEEF, takeAverage: true, subclasses: _content.Subclasses); c.ApplyLevelUp(result, new LevelUpChoices()); Assert.Contains("coordinated_takedown", c.LearnedFeatureIds); } // ── Helpers ─────────────────────────────────────────────────────────── private Encounter MakeEncounter( out Combatant pc, out Combatant hostile, string pcClass, string? pcSubclass, bool includeAlly = false, Vec2? allyPos = null) { var pcChar = MakeChar(pcClass, new AbilityScores(15, 14, 13, 12, 10, 8)); if (pcSubclass is not null) pcChar.SubclassId = pcSubclass; pc = Combatant.FromCharacter(pcChar, 1, "PC", new Vec2(0, 0), Theriapolis.Core.Rules.Character.Allegiance.Player); var hostileChar = MakeChar("fangsworn", new AbilityScores(13, 12, 13, 10, 10, 8)); hostile = Combatant.FromCharacter(hostileChar, 2, "Hostile", new Vec2(3, 0), Theriapolis.Core.Rules.Character.Allegiance.Hostile); var participants = new List { pc, hostile }; if (includeAlly) { var allyChar = MakeChar("fangsworn", new AbilityScores(12, 12, 13, 10, 10, 8)); var ally = Combatant.FromCharacter(allyChar, 3, "Ally", allyPos ?? new Vec2(1, 0), Theriapolis.Core.Rules.Character.Allegiance.Allied); participants.Add(ally); } return new Encounter(0xCAFEUL, 1, participants); } 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); } }