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 M3 — ability-stream features that scale per level: /// - Scent-Broker Pheromone Craft (L2/L5/L9/L13 ladder) /// - Covenant-Keeper Covenant's Authority (L2/L9/L13/L17 ladder) /// - Muzzle-Speaker Vocalization Dice (level ladder verified end-to-end) /// Plus the cross-cutting Frightened-disadvantage hookup the resolver needs /// for Pheromone Fear to actually do anything. /// public sealed class Phase65M3FeatureTests { private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory)); // ── Pheromone Craft ─────────────────────────────────────────────────── [Theory] [InlineData(1, 0)] // pre-L2: no uses [InlineData(2, 2)] // L2: pheromone_craft_2 [InlineData(4, 2)] // L4: still 2 [InlineData(5, 3)] // L5: pheromone_craft_3 [InlineData(8, 3)] // L8: still 3 [InlineData(9, 4)] // L9: pheromone_craft_4 [InlineData(13, 5)] // L13: pheromone_craft_5 [InlineData(20, 5)] // L20: capstone, still 5 public void PheromoneUsesAtLevel_FollowsJsonLadder(int level, int expected) { Assert.Equal(expected, FeatureProcessor.PheromoneUsesAtLevel(level)); } [Fact] public void EnsurePheromoneUsesReady_ToppedUpForScentBroker() { var c = MakeChar("scent_broker", new AbilityScores(8, 12, 13, 14, 16, 12)); c.Level = 5; c.PheromoneUsesRemaining = 0; FeatureProcessor.EnsurePheromoneUsesReady(c); Assert.Equal(3, c.PheromoneUsesRemaining); } [Fact] public void EnsurePheromoneUsesReady_NoOpForOtherClasses() { var c = MakeChar("fangsworn", new AbilityScores(15, 12, 13, 10, 10, 8)); c.PheromoneUsesRemaining = 0; FeatureProcessor.EnsurePheromoneUsesReady(c); Assert.Equal(0, c.PheromoneUsesRemaining); } [Fact] public void TryEmitPheromone_AppliesFrightenedToHostilesInRange_OnFailedSave() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "scent_broker", pcLevel: 5, hostileCon: 1, // -5 mod → guaranteed fail hostilePos: new Vec2(1, 0)); // adjacent → in 10ft cloud bool ok = FeatureProcessor.TryEmitPheromone(enc, pc, PheromoneType.Fear); Assert.True(ok); Assert.Contains(Condition.Frightened, hostile.Conditions); } [Fact] public void TryEmitPheromone_DoesNotAffectHostilesOutOfRange() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "scent_broker", pcLevel: 5, hostileCon: 1, hostilePos: new Vec2(10, 0)); // far outside 10ft cloud FeatureProcessor.TryEmitPheromone(enc, pc, PheromoneType.Fear); Assert.DoesNotContain(Condition.Frightened, hostile.Conditions); } [Fact] public void TryEmitPheromone_RefusesPreL2() { var enc = MakeEncounter(out var pc, out _, pcClass: "scent_broker", pcLevel: 1); bool ok = FeatureProcessor.TryEmitPheromone(enc, pc, PheromoneType.Fear); Assert.False(ok); } [Fact] public void TryEmitPheromone_ConsumesUse() { var enc = MakeEncounter(out var pc, out _, pcClass: "scent_broker", pcLevel: 5); int before = pc.SourceCharacter!.PheromoneUsesRemaining; FeatureProcessor.TryEmitPheromone(enc, pc, PheromoneType.Fear); Assert.Equal(before - 1, pc.SourceCharacter.PheromoneUsesRemaining); } [Fact] public void TryEmitPheromone_RefusesWhenExhausted() { var enc = MakeEncounter(out var pc, out _, pcClass: "scent_broker", pcLevel: 5); pc.SourceCharacter!.PheromoneUsesRemaining = 0; bool ok = FeatureProcessor.TryEmitPheromone(enc, pc, PheromoneType.Fear); Assert.False(ok); } [Fact] public void TryEmitPheromone_DoesNotAffectAllies() { var enc = MakeEncounter(out var pc, out _, pcClass: "scent_broker", pcLevel: 5, hostileCon: 1, includeAlly: true, allyPos: new Vec2(1, 0)); // ally in radius FeatureProcessor.TryEmitPheromone(enc, pc, PheromoneType.Fear); var ally = enc.Participants.Single(p => p.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied); Assert.DoesNotContain(Condition.Frightened, ally.Conditions); } [Theory] [InlineData(PheromoneType.Fear, Condition.Frightened)] [InlineData(PheromoneType.Calm, Condition.Charmed)] [InlineData(PheromoneType.Arousal, Condition.Dazed)] [InlineData(PheromoneType.Nausea, Condition.Poisoned)] public void Pheromone_AppliesMappedCondition(PheromoneType type, Condition expected) { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "scent_broker", pcLevel: 5, hostileCon: 1, hostilePos: new Vec2(1, 0)); FeatureProcessor.TryEmitPheromone(enc, pc, type); Assert.Contains(expected, hostile.Conditions); } // ── Frightened disadvantage in Resolver ────────────────────────────── [Fact] public void Resolver_FrightenedAttacker_RollsDisadvantage() { // Build attacker with Frightened condition, target far enough that // we exercise the d20 path. We can't deterministically observe // disadvantage from a single roll, but RollD20WithMode uses two // d20s under disadvantage and keeps the lower — so over 100 rolls // we should see a clear bias toward lower kept values. var enc = MakeEncounter(out var attacker, out var target, pcClass: "fangsworn", pcLevel: 5); attacker.Conditions.Add(Condition.Frightened); var attack = attacker.AttackOptions[0]; // The Frightened path goes through `situation |= Disadvantage` in // the resolver. Easiest behavioural check: the attack rolls happen // and don't throw; rolled d20 is in [1,20]. Determinism is verified // elsewhere (DamageDeterminismTests). Smoke test only here. for (int i = 0; i < 5; i++) Resolver.AttemptAttack(enc, attacker, target, attack); Assert.True(true); // no crash → wiring ok } // ── Covenant Authority ─────────────────────────────────────────────── [Theory] [InlineData(1, 0)] [InlineData(2, 2)] [InlineData(8, 2)] [InlineData(9, 3)] [InlineData(12, 3)] [InlineData(13, 4)] [InlineData(17, 5)] [InlineData(20, 5)] public void CovenantAuthorityUsesAtLevel_FollowsLadder(int level, int expected) { Assert.Equal(expected, FeatureProcessor.CovenantAuthorityUsesAtLevel(level)); } [Fact] public void TryDeclareOath_MarksTargetAndConsumesUse() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "covenant_keeper", pcLevel: 5); int before = pc.SourceCharacter!.CovenantAuthorityUsesRemaining; bool ok = FeatureProcessor.TryDeclareOath(enc, pc, hostile); Assert.True(ok); Assert.Equal(pc.Id, hostile.OathMarkBy); Assert.True(hostile.OathMarkRound.HasValue); Assert.Equal(before - 1, pc.SourceCharacter.CovenantAuthorityUsesRemaining); } [Fact] public void TryDeclareOath_RefusesPreL2() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "covenant_keeper", pcLevel: 1); bool ok = FeatureProcessor.TryDeclareOath(enc, pc, hostile); Assert.False(ok); } [Fact] public void TryDeclareOath_RefusesSelfTarget() { var enc = MakeEncounter(out var pc, out _, pcClass: "covenant_keeper", pcLevel: 5); bool ok = FeatureProcessor.TryDeclareOath(enc, pc, pc); Assert.False(ok); } [Fact] public void OathAttackPenalty_AppliesToMarkerOnly() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "covenant_keeper", pcLevel: 5, includeAlly: true, allyPos: new Vec2(2, 0)); FeatureProcessor.TryDeclareOath(enc, pc, hostile); var ally = enc.Participants.Single(p => p.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied); // Hostile attacking the marker (pc) → -2 penalty. Assert.Equal(-2, FeatureProcessor.OathAttackPenalty(enc, hostile, pc)); // Hostile attacking the ally → no penalty (oath is target-specific). Assert.Equal(0, FeatureProcessor.OathAttackPenalty(enc, hostile, ally)); } [Fact] public void OathAttackPenalty_ZeroForUnmarkedAttacker() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "covenant_keeper", pcLevel: 5); Assert.Equal(0, FeatureProcessor.OathAttackPenalty(enc, hostile, pc)); } [Fact] public void OathAttackPenalty_ExpiresAfterTenRounds() { var enc = MakeEncounter(out var pc, out var hostile, pcClass: "covenant_keeper", pcLevel: 5); FeatureProcessor.TryDeclareOath(enc, pc, hostile); // Force the encounter forward 11 rounds (we hand-set RoundNumber via // EndTurn, but easier: directly read OathAttackPenalty after we // shift the round forward via end-turn loop — too elaborate. Instead, // mock by mutating the mark round backward. hostile.OathMarkRound = enc.RoundNumber - 10; // expired Assert.Equal(0, FeatureProcessor.OathAttackPenalty(enc, hostile, pc)); // And the expiry sweep clears the fields. Assert.Null(hostile.OathMarkRound); Assert.Null(hostile.OathMarkBy); } // ── Vocalization Dice scaling end-to-end ───────────────────────────── [Theory] [InlineData(1, 6)] [InlineData(4, 6)] [InlineData(5, 8)] [InlineData(9, 10)] [InlineData(15, 12)] public void VocalizationDie_GrantsMatchedSidesAtLevel(int level, int expectedSides) { var enc = MakeEncounter(out var caster, out _, pcClass: "muzzle_speaker", pcLevel: level, includeAlly: true, allyPos: new Vec2(1, 0)); var ally = enc.Participants.Single(p => p.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied); bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, ally); Assert.True(ok); Assert.Equal(expectedSides, ally.InspirationDieSides); } // ── Helpers ─────────────────────────────────────────────────────────── private Encounter MakeEncounter( out Combatant pc, out Combatant hostile, string pcClass, int pcLevel = 1, int hostileCon = 10, Vec2? hostilePos = null, bool includeAlly = false, Vec2? allyPos = null) { var pcChar = MakeChar(pcClass, new AbilityScores(10, 12, 13, 14, 16, 14)); pcChar.Level = pcLevel; FeatureProcessor.EnsurePheromoneUsesReady(pcChar); FeatureProcessor.EnsureCovenantAuthorityReady(pcChar); FeatureProcessor.EnsureVocalizationDiceReady(pcChar); pc = Combatant.FromCharacter(pcChar, 1, "PC", new Vec2(0, 0), Theriapolis.Core.Rules.Character.Allegiance.Player); var hostileChar = MakeChar("fangsworn", new AbilityScores(13, 12, hostileCon, 10, 10, 8)); hostile = Combatant.FromCharacter(hostileChar, 2, "Hostile", hostilePos ?? 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(0xFEEDUL, 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); } }