Files

326 lines
13 KiB
C#
Raw Permalink Normal View History

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;
/// <summary>
/// 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.
/// </summary>
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<Combatant> { 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);
}
}