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:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,157 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
public sealed class AttackResolutionTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void AttemptAttack_RecordsHitOrMissAndDamage()
{
var (a, b) = MakeDuelists(seed: 0xABCDUL);
var enc = new Encounter(0xABCDUL, encounterId: 1, new[] { a, b });
var attack = a.AttackOptions[0];
var result = Resolver.AttemptAttack(enc, a, b, attack);
Assert.Equal(a.Id, result.AttackerId);
Assert.Equal(b.Id, result.TargetId);
Assert.InRange(result.D20Roll, 1, 20);
if (result.Hit)
Assert.InRange(result.DamageRolled, 1, attack.Damage.Max(isCrit: result.Crit));
else
Assert.Equal(0, result.DamageRolled);
}
[Fact]
public void AttemptAttack_Natural1_AlwaysMissesEvenIfTotalBeatsAc()
{
var (attacker, _) = MakeDuelists(seed: 1UL);
// Build a target with AC so low that any d20 + bonus beats it.
var weakTarget = WeakDummy(id: 99);
var enc = new Encounter(1UL, 1, new[] { attacker, weakTarget });
// Pick an attack with a known small bonus we can reason about.
var atk = new AttackOption { Name = "Test", ToHitBonus = 5,
Damage = new DamageRoll(1, 6, 0, DamageType.Slashing) };
// Use a deterministic forced-d20 by replaying rolls until we observe a natural 1.
// Simpler: assert across many encounters that any natural 1 is logged as miss.
for (int s = 0; s < 200; s++)
{
var enc2 = new Encounter((ulong)s, encounterId: 17, new[] { attacker, weakTarget });
var r = Resolver.AttemptAttack(enc2, attacker, weakTarget, atk);
if (r.D20Roll == 1)
Assert.False(r.Hit, $"natural 1 must miss (seed {s}, total {r.AttackTotal} vs AC {r.TargetAc})");
}
}
[Fact]
public void AttemptAttack_Natural20_AlwaysHitsAndIsCrit()
{
var attacker = WeakDummy(id: 1);
// Target with AC sky-high so only natural 20 can hit.
var fortress = StrongDummy(id: 2);
var atk = new AttackOption { Name = "Test", ToHitBonus = 0,
Damage = new DamageRoll(1, 6, 0, DamageType.Slashing) };
for (int s = 0; s < 300; s++)
{
var enc = new Encounter((ulong)s, 17, new[] { attacker, fortress });
var r = Resolver.AttemptAttack(enc, attacker, fortress, atk);
if (r.D20Roll == 20)
{
Assert.True(r.Hit, "natural 20 must always hit");
Assert.True(r.Crit, "natural 20 must register as crit");
}
}
}
[Fact]
public void AttemptAttack_DamageReducesTargetHp()
{
var (a, b) = MakeDuelists(seed: 99UL);
int startHp = b.CurrentHp;
var enc = new Encounter(99UL, 1, new[] { a, b });
// Hammer until at least one hit lands so we can compare HP delta.
for (int i = 0; i < 100; i++)
{
var r = Resolver.AttemptAttack(enc, a, b, a.AttackOptions[0]);
if (r.Hit)
{
Assert.Equal(startHp - r.DamageRolled, r.TargetHpAfter);
Assert.Equal(startHp - r.DamageRolled, b.CurrentHp);
return;
}
}
// 100 misses in a row is statistically impossible with our test combatants;
// if we ever see it the test should fail loudly to flag the regression.
Assert.Fail("Expected at least one hit in 100 attacks.");
}
[Fact]
public void AttemptAttack_LogsEntry()
{
var (a, b) = MakeDuelists(seed: 7UL);
var enc = new Encounter(7UL, 1, new[] { a, b });
int logBefore = enc.Log.Count;
Resolver.AttemptAttack(enc, a, b, a.AttackOptions[0]);
Assert.True(enc.Log.Count > logBefore);
}
[Fact]
public void Heal_ClampsToMaxHp()
{
var c = WeakDummy(id: 1);
c.CurrentHp = 5;
Resolver.Heal(c, 100);
Assert.Equal(c.MaxHp, c.CurrentHp);
}
[Fact]
public void ApplyDamage_ClampsToZero()
{
var c = WeakDummy(id: 1);
Resolver.ApplyDamage(c, c.MaxHp + 50);
Assert.Equal(0, c.CurrentHp);
Assert.True(c.IsDown);
}
[Fact]
public void MakeSave_CountsProficiencyOnlyWhenProficient()
{
var c = MakeDuelists(seed: 1UL).a;
var enc = new Encounter(1UL, 1, new[] { c });
var profSave = Resolver.MakeSave(enc, c, SaveId.STR, dc: 100, isProficient: true);
var enc2 = new Encounter(1UL, 1, new[] { c });
var nonprofSave = Resolver.MakeSave(enc2, c, SaveId.STR, dc: 100, isProficient: false);
Assert.Equal(profSave.SaveBonus, nonprofSave.SaveBonus + c.ProficiencyBonus);
}
// ── Helpers ──────────────────────────────────────────────────────────
private (Combatant a, Combatant b) MakeDuelists(ulong seed)
{
var brigand = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
var wolf = _content.Npcs.Templates.First(t => t.Id == "wolf");
var a = Combatant.FromNpcTemplate(brigand, id: 1, position: new Vec2(0, 0));
var b = Combatant.FromNpcTemplate(wolf, id: 2, position: new Vec2(1, 0));
return (a, b);
}
private Combatant WeakDummy(int id)
{
// Take a footpad and reset HP to a known small value for damage tests.
var def = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
var c = Combatant.FromNpcTemplate(def, id, new Vec2(0, 0));
return c;
}
private Combatant StrongDummy(int id)
{
var def = _content.Npcs.Templates.First(t => t.Id == "brigand_captain"); // AC 16
return Combatant.FromNpcTemplate(def, id, new Vec2(1, 0));
}
}
+78
View File
@@ -0,0 +1,78 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities.Ai;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
public sealed class BehaviorTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void Brigand_MovesTowardTargetWhenOutOfReach()
{
var brigand = MakeNpc("brigand_footpad", new Vec2(0, 0), Allegiance.Hostile);
var hero = MakeNpc("brigand_footpad", new Vec2(5, 0), Allegiance.Player);
var enc = new Encounter(0xCAFEUL, 1, new[] { brigand, hero });
// Skip past initiative and start brigand's turn explicitly.
while (enc.CurrentActor.Id != brigand.Id) enc.EndTurn();
new BrigandBehavior().TakeTurn(brigand, new AiContext(enc));
Assert.True((int)brigand.Position.X > 0); // moved toward hero
}
[Fact]
public void WildAnimal_FleesBelowQuarterHp()
{
var wolf = MakeNpc("wolf", new Vec2(5, 0), Allegiance.Hostile);
var hero = MakeNpc("wolf", new Vec2(0, 0), Allegiance.Player);
wolf.CurrentHp = 1; // well below 25% of 11
var enc = new Encounter(0xCAFEUL, 1, new[] { wolf, hero });
while (enc.CurrentActor.Id != wolf.Id) enc.EndTurn();
var startX = (int)wolf.Position.X;
new WildAnimalBehavior().TakeTurn(wolf, new AiContext(enc));
Assert.True((int)wolf.Position.X > startX, "Wounded wolf should flee away from hero (positive X)");
}
[Fact]
public void Behavior_NoTargetMakesNoMoves()
{
var lone = MakeNpc("brigand_footpad", new Vec2(0, 0), Allegiance.Hostile);
var enc = new Encounter(0xCAFEUL, 1, new[] { lone });
var startPos = lone.Position;
new BrigandBehavior().TakeTurn(lone, new AiContext(enc));
Assert.Equal(startPos.X, lone.Position.X);
Assert.Equal(startPos.Y, lone.Position.Y);
}
[Fact]
public void Behavior_AttacksWhenInReach()
{
var brigand = MakeNpc("brigand_footpad", new Vec2(0, 0), Allegiance.Hostile);
var hero = MakeNpc("brigand_footpad", new Vec2(1, 0), Allegiance.Player);
var enc = new Encounter(0xCAFEUL, 1, new[] { brigand, hero });
while (enc.CurrentActor.Id != brigand.Id) enc.EndTurn();
int logBefore = enc.Log.Count;
new BrigandBehavior().TakeTurn(brigand, new AiContext(enc));
Assert.Contains(enc.Log.Skip(logBefore), e => e.Type == CombatLogEntry.Kind.Attack);
}
[Fact]
public void Registry_UnknownIdFallsBackToBrigand()
{
var b = BehaviorRegistry.For("nonsense_behavior");
Assert.IsType<BrigandBehavior>(b);
}
private Combatant MakeNpc(string templateId, Vec2 pos, Allegiance side)
{
var t = _content.Npcs.Templates.First(x => x.Id == templateId);
var swapped = t with { DefaultAllegiance = side.ToString().ToLowerInvariant() };
return Combatant.FromNpcTemplate(swapped, id: pos.X.GetHashCode() ^ pos.Y.GetHashCode(), pos);
}
}
@@ -0,0 +1,116 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
/// <summary>
/// Phase 5 plan §5: same (worldSeed, encounterId, rollSequence) → identical
/// dice outcomes across runs. Save/load can resume mid-combat by re-creating
/// the encounter and replaying through its rollCount.
/// </summary>
public sealed class DamageDeterminismTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void EncounterSeed_IsXorOfWorldSeedRngCombatAndEncounterId()
{
var enc = new Encounter(worldSeed: 0xABCDUL, encounterId: 0x1234UL, MakeOne());
Assert.Equal(0xABCDUL ^ Theriapolis.Core.C.RNG_COMBAT ^ 0x1234UL, enc.EncounterSeed);
}
[Fact]
public void SameInputs_SameDiceSequence()
{
var a = new Encounter(0xCAFEUL, 1, MakeOne());
var b = new Encounter(0xCAFEUL, 1, MakeOne());
for (int i = 0; i < 100; i++)
Assert.Equal(a.RollD20(), b.RollD20());
}
[Fact]
public void DifferentEncounterIds_DivergeImmediately()
{
var a = new Encounter(0xCAFEUL, 1, MakeOne());
var b = new Encounter(0xCAFEUL, 2, MakeOne());
bool anyDifferent = false;
for (int i = 0; i < 20; i++)
if (a.RollD20() != b.RollD20()) { anyDifferent = true; break; }
Assert.True(anyDifferent, "Different encounter ids should produce different dice streams.");
}
[Fact]
public void ResumeRolls_SkipsForwardThroughDiceStream()
{
var a = new Encounter(0xCAFEUL, 1, MakeOne());
var b = new Encounter(0xCAFEUL, 1, MakeOne());
// Burn some rolls on `a` and capture the next 5.
for (int i = 0; i < 10; i++) a.RollD20();
int rollCountSnapshot = a.RollCount; // includes initiative rolls consumed by the ctor
int[] expected = new int[5];
for (int i = 0; i < 5; i++) expected[i] = a.RollD20();
// Resume `b` to the same total rollcount and capture the same window.
b.ResumeRolls(rollCountSnapshot);
int[] actual = new int[5];
for (int i = 0; i < 5; i++) actual[i] = b.RollD20();
Assert.Equal(expected, actual);
Assert.Equal(rollCountSnapshot + 5, b.RollCount);
}
[Fact]
public void Resolver_FullScenario_IsDeterministicAcrossRuns()
{
// Run the same scripted scenario twice and expect identical logs.
var log1 = RunScriptedScenario(seed: 0xABCDEFUL);
var log2 = RunScriptedScenario(seed: 0xABCDEFUL);
Assert.Equal(log1, log2);
}
[Fact]
public void Resolver_DifferentSeeds_ProduceDifferentLogs()
{
var log1 = RunScriptedScenario(seed: 1UL);
var log2 = RunScriptedScenario(seed: 2UL);
Assert.NotEqual(log1, log2);
}
private List<Combatant> MakeOne() => new()
{
Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "brigand_footpad"), id: 1, new Vec2(0, 0)),
};
private string RunScriptedScenario(ulong seed)
{
var brigand = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
var wolf = _content.Npcs.Templates.First(t => t.Id == "wolf");
var hero = Combatant.FromNpcTemplate(brigand with { DefaultAllegiance = "player" }, id: 1, new Vec2(0, 0));
var foe = Combatant.FromNpcTemplate(wolf, id: 2, new Vec2(1, 0));
var enc = new Encounter(seed, 1, new[] { hero, foe });
for (int round = 0; round < 10 && !enc.IsOver; round++)
{
for (int t = 0; t < enc.Participants.Count && !enc.IsOver; t++)
{
var actor = enc.CurrentActor;
if (actor.IsAlive && !actor.IsDown)
{
var target = actor.Id == hero.Id ? foe : hero;
if (target.IsAlive && !target.IsDown)
Resolver.AttemptAttack(enc, actor, target, actor.AttackOptions[0]);
}
enc.EndTurn();
}
}
var sb = new System.Text.StringBuilder();
foreach (var entry in enc.Log)
sb.AppendLine($"R{entry.Round} T{entry.Turn} [{entry.Type}] {entry.Message}");
return sb.ToString();
}
}
@@ -0,0 +1,85 @@
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
public sealed class DamageRollTests
{
[Theory]
[InlineData("1d6", 1, 6, 0)]
[InlineData("2d8+2", 2, 8, 2)]
[InlineData("1d4-1", 1, 4, -1)]
[InlineData("3d6", 3, 6, 0)]
[InlineData("d8", 1, 8, 0)]
[InlineData(" 1 d 6 + 1", 1, 6, 1)]
public void Parse_ProducesExpectedShape(string expr, int n, int sides, int mod)
{
var d = DamageRoll.Parse(expr, DamageType.Slashing);
Assert.Equal(n, d.DiceCount);
Assert.Equal(sides, d.DiceSides);
Assert.Equal(mod, d.FlatMod);
}
[Fact]
public void Parse_PureFlatNumber_HasNoDice()
{
var d = DamageRoll.Parse("5", DamageType.Bludgeoning);
Assert.Equal(0, d.DiceCount);
Assert.Equal(0, d.DiceSides);
Assert.Equal(5, d.FlatMod);
}
[Fact]
public void Parse_BadExpressionThrows()
{
Assert.Throws<System.FormatException>(() => DamageRoll.Parse("1d", DamageType.Slashing));
Assert.Throws<System.ArgumentException>(() => DamageRoll.Parse("", DamageType.Slashing));
}
[Fact]
public void Roll_Range_StaysWithinMinAndMax()
{
var rng = new SeededRng(0xCAFEUL);
var d = DamageRoll.Parse("2d6+3", DamageType.Slashing);
for (int i = 0; i < 1000; i++)
{
int v = d.Roll(sides => (int)(rng.NextUInt64() % (ulong)sides) + 1);
Assert.InRange(v, d.Min(), d.Max());
}
}
[Fact]
public void Roll_Crit_DoublesDiceButNotFlatMod()
{
var roller = new FixedRoller(new[] { 1 }); // every die rolls 1
var d = DamageRoll.Parse("2d6+3", DamageType.Slashing);
// Normal: 2 dice ⇒ 2*1 + 3 = 5
Assert.Equal(5, d.Roll(roller.Next));
// Crit: 4 dice ⇒ 4*1 + 3 = 7 (NOT 4*1 + 6 = 10 — flat mod doesn't double)
roller.Reset();
Assert.Equal(7, d.Roll(roller.Next, isCrit: true));
}
[Fact]
public void Roll_NeverNegative()
{
var d = DamageRoll.Parse("1d4-10", DamageType.Slashing);
for (int i = 0; i < 50; i++)
{
// Force the die to roll 1 (the worst case): result would be 1-10 = -9, clamped to 0.
int v = d.Roll(_ => 1);
Assert.True(v >= 0);
}
}
private sealed class FixedRoller
{
private readonly int[] _values;
private int _idx;
public FixedRoller(int[] values) { _values = values; }
public int Next(int _) { int v = _values[_idx % _values.Length]; _idx++; return v; }
public void Reset() => _idx = 0;
}
}
@@ -0,0 +1,116 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
public sealed class DeathSaveTrackerTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void ApplyDamage_PlayerDroppedToZero_InstallsDeathSaveTracker()
{
var (player, _) = MakeFight();
Resolver.ApplyDamage(player, player.MaxHp + 50);
Assert.Equal(0, player.CurrentHp);
Assert.NotNull(player.DeathSaves);
Assert.True(player.IsDown);
Assert.True(player.IsAlive); // unconscious-but-not-dead is "alive"
}
[Fact]
public void Heal_AbovZero_ResetsDeathSaves()
{
var (player, _) = MakeFight();
Resolver.ApplyDamage(player, player.MaxHp + 5);
player.DeathSaves!.Roll(MakeEnc(player), player); // 1 fail or success
Resolver.Heal(player, 5);
Assert.True(player.CurrentHp > 0);
Assert.Equal(0, player.DeathSaves.Successes);
Assert.Equal(0, player.DeathSaves.Failures);
}
[Fact]
public void ThreeFailures_MarkDead()
{
var t = new DeathSaveTracker();
// Use an encounter with a fixed seed and roll until we accumulate 3 failures
// — then assert the Dead flag is set.
var enc = MakeEncFromScratch(0x10UL); // arbitrary seed
var dummy = MakeDummyCombatant(enc);
for (int i = 0; i < 50 && !t.Dead && !t.Stabilised; i++)
t.Roll(enc, dummy);
// Across 50 rolls (and with the 3-fail / 3-success thresholds) we always
// resolve to one of the terminal states. Either is acceptable for this
// smoke test; the important thing is the loop terminates and counters
// can advance.
Assert.True(t.Dead || t.Stabilised);
}
[Fact]
public void Roll_Natural20_RevivesAtOneHp()
{
// Find a seed whose first death-save d20 is 20. Probe deterministically.
for (int s = 0; s < 200; s++)
{
var enc = MakeEncFromScratch((ulong)s);
var pc = MakeDummyCombatant(enc);
pc.CurrentHp = 0;
pc.Conditions.Add(Condition.Unconscious);
pc.DeathSaves = new DeathSaveTracker();
var outcome = pc.DeathSaves.Roll(enc, pc);
if (outcome == DeathSaveOutcome.CriticalRevive)
{
Assert.Equal(1, pc.CurrentHp);
Assert.DoesNotContain(Condition.Unconscious, pc.Conditions);
Assert.Equal(0, pc.DeathSaves.Successes);
Assert.Equal(0, pc.DeathSaves.Failures);
return;
}
}
Assert.Fail("Couldn't find a seed producing natural 20 in 200 attempts.");
}
// ── Helpers ──────────────────────────────────────────────────────────
private (Combatant pc, Combatant foe) MakeFight()
{
var clade = _content.Clades["canidae"];
var species = _content.Species["wolf"];
var classDef= _content.Classes["fangsworn"];
var bg = _content.Backgrounds["pack_raised"];
var character = new Theriapolis.Core.Rules.Character.CharacterBuilder
{
Clade = clade, Species = species, ClassDef = classDef, Background = bg,
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
}
.ChooseSkill(SkillId.Athletics)
.ChooseSkill(SkillId.Intimidation)
.Build(_content.Items);
var pc = Combatant.FromCharacter(character, 1, "PC", new Vec2(0, 0),
Theriapolis.Core.Rules.Character.Allegiance.Player);
var foe = Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "wolf"),
2, new Vec2(1, 0));
return (pc, foe);
}
private Encounter MakeEnc(Combatant pc)
{
var foe = Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "wolf"), 2, new Vec2(1, 0));
return new Encounter(0xCAFEUL, 1, new[] { pc, foe });
}
private Encounter MakeEncFromScratch(ulong seed)
{
var hero = Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "brigand_footpad"), 1, new Vec2(0, 0));
var foe = Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "wolf"), 2, new Vec2(1, 0));
return new Encounter(seed, 1, new[] { hero, foe });
}
private Combatant MakeDummyCombatant(Encounter enc)
=> enc.Participants[0];
}
@@ -0,0 +1,73 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
public sealed class EncounterTriggerTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void FindHostileTrigger_ReturnsNullWhenNoHostiles()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(100, 100));
Assert.Null(EncounterTrigger.FindHostileTrigger(mgr));
}
[Fact]
public void FindHostileTrigger_ReturnsNearbyHostile()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(100, 100));
var wolf = mgr.SpawnNpc(_content.Npcs.Templates.First(t => t.Id == "wolf"),
new Vec2(105, 100));
var hit = EncounterTrigger.FindHostileTrigger(mgr);
Assert.Same(wolf, hit);
}
[Fact]
public void FindHostileTrigger_IgnoresHostilesPastTriggerRadius()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(100, 100));
mgr.SpawnNpc(_content.Npcs.Templates.First(t => t.Id == "wolf"),
new Vec2(100 + C.ENCOUNTER_TRIGGER_TILES + 5, 100));
Assert.Null(EncounterTrigger.FindHostileTrigger(mgr));
}
[Fact]
public void FindHostileTrigger_IgnoresFriendlyAndNeutral()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(100, 100));
var merchant = _content.Npcs.Templates.First(t => t.Id == "merchant_traveler");
mgr.SpawnNpc(merchant, new Vec2(102, 100));
Assert.Null(EncounterTrigger.FindHostileTrigger(mgr));
}
[Fact]
public void FindInteractCandidate_FindsFriendlyOrNeutralInRange()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(100, 100));
var merchant = _content.Npcs.Templates.First(t => t.Id == "merchant_traveler");
var npc = mgr.SpawnNpc(merchant, new Vec2(101, 100));
var hit = EncounterTrigger.FindInteractCandidate(mgr);
Assert.Same(npc, hit);
}
[Fact]
public void FindInteractCandidate_IgnoresHostiles()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(100, 100));
mgr.SpawnNpc(_content.Npcs.Templates.First(t => t.Id == "wolf"), new Vec2(101, 100));
Assert.Null(EncounterTrigger.FindInteractCandidate(mgr));
}
}
@@ -0,0 +1,179 @@
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;
public sealed class FeatureProcessorTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Unarmored Defense (Feral) ──────────────────────────────────────
[Fact]
public void FeralUnarmoredDefense_RaisesAcWithoutBodyArmor()
{
var c = MakeChar("feral", "ursidae", "brown_bear", new AbilityScores(15, 14, 14, 10, 10, 8));
// Brown bear doesn't auto-equip body armor in feral kit (hide_vest is the only one) —
// but the kit DOES equip hide_vest. Strip it and re-check.
var body = c.Inventory.GetEquipped(EquipSlot.Body);
if (body is not null) c.Inventory.TryUnequip(EquipSlot.Body, out _);
int unarmoredAc = DerivedStats.ArmorClass(c);
// Feral CON 14 → +2; DEX 13 (after wolf-folk -1, brown_bear baseline) → varies. Just assert the floor: 10 + DEX + CON ≥ 12.
Assert.True(unarmoredAc >= 11, $"Feral unarmored AC should be at least 11, got {unarmoredAc}");
}
// ── Sentinel Stance (Bulwark) ──────────────────────────────────────
[Fact]
public void SentinelStance_AddsTwoToAcDuringAttackResolution()
{
var enc = MakeMiniEncounter(out var attacker, out var target,
attackerClass: "fangsworn", targetClass: "bulwark");
int beforeAc = target.ArmorClass;
target.SentinelStanceActive = true;
int withStance = target.ArmorClass + FeatureProcessor.ApplyAcBonus(target);
Assert.Equal(beforeAc + 2, withStance);
}
[Fact]
public void ToggleSentinelStance_FlipsFlagAndLogs()
{
var enc = MakeMiniEncounter(out _, out var bulwark, attackerClass: "feral", targetClass: "bulwark");
Assert.False(bulwark.SentinelStanceActive);
bool ok = FeatureProcessor.ToggleSentinelStance(enc, bulwark);
Assert.True(ok);
Assert.True(bulwark.SentinelStanceActive);
FeatureProcessor.ToggleSentinelStance(enc, bulwark);
Assert.False(bulwark.SentinelStanceActive);
}
// ── Feral Rage ─────────────────────────────────────────────────────
[Fact]
public void TryActivateRage_ConsumesUseAndSetsFlag()
{
var enc = MakeMiniEncounter(out var feral, out _, attackerClass: "feral", targetClass: "fangsworn");
var c = feral.SourceCharacter!;
int usesBefore = c.RageUsesRemaining;
bool ok = FeatureProcessor.TryActivateRage(enc, feral);
Assert.True(ok);
Assert.True(feral.RageActive);
Assert.Equal(usesBefore - 1, c.RageUsesRemaining);
}
[Fact]
public void TryActivateRage_NoUsesLeft_ReturnsFalse()
{
var enc = MakeMiniEncounter(out var feral, out _, attackerClass: "feral", targetClass: "fangsworn");
feral.SourceCharacter!.RageUsesRemaining = 0;
Assert.False(FeatureProcessor.TryActivateRage(enc, feral));
}
[Fact]
public void Rage_ResistsBludgeoningPiercingSlashing()
{
var enc = MakeMiniEncounter(out var feral, out _, attackerClass: "feral", targetClass: "fangsworn");
feral.RageActive = true;
Assert.True(FeatureProcessor.IsResisted(feral, DamageType.Bludgeoning));
Assert.True(FeatureProcessor.IsResisted(feral, DamageType.Piercing));
Assert.True(FeatureProcessor.IsResisted(feral, DamageType.Slashing));
Assert.False(FeatureProcessor.IsResisted(feral, DamageType.Fire));
}
[Fact]
public void Rage_AddsDamageBonusOnMeleeOnly()
{
var enc = MakeMiniEncounter(out var feral, out var target, attackerClass: "feral", targetClass: "fangsworn");
feral.RageActive = true;
var melee = new AttackOption { Name = "Test melee", Damage = new DamageRoll(0, 0, 0, DamageType.Slashing) };
int bonusMelee = FeatureProcessor.ApplyDamageBonus(enc, feral, target, melee, isCrit: false);
Assert.Equal(2, bonusMelee);
var ranged = new AttackOption { Name = "Test ranged", Damage = new DamageRoll(0, 0, 0, DamageType.Piercing),
RangeShortTiles = 8, RangeLongTiles = 16 };
int bonusRanged = FeatureProcessor.ApplyDamageBonus(enc, feral, target, ranged, isCrit: false);
Assert.Equal(0, bonusRanged);
}
// ── Sneak Attack (Shadow-Pelt) ────────────────────────────────────
[Fact]
public void SneakAttack_FiresOncePerTurnWithFinesseWeapon()
{
// Shadow-Pelt's starting kit is thorn_blade (finesse) + studded_leather.
var enc = MakeMiniEncounter(out var rogue, out var target, attackerClass: "shadow_pelt", targetClass: "fangsworn");
var attack = rogue.AttackOptions[0];
Assert.False(rogue.SneakAttackUsedThisTurn);
int firstHit = FeatureProcessor.ApplyDamageBonus(enc, rogue, target, attack, isCrit: false);
// After first hit, the flag should be set; second call returns no sneak bonus.
Assert.True(rogue.SneakAttackUsedThisTurn);
int secondHit = FeatureProcessor.ApplyDamageBonus(enc, rogue, target, attack, isCrit: false);
Assert.True(firstHit > 0, "first finesse hit should add Sneak Attack damage");
// Second hit may still have other bonuses from non-rogue features but not Sneak Attack.
// Assert second is strictly less than first (Sneak removed).
Assert.True(secondHit < firstHit, $"second hit ({secondHit}) should be less than first ({firstHit}) — sneak attack consumed");
}
[Fact]
public void OnTurnStart_ResetsSneakAttackFlag()
{
var enc = MakeMiniEncounter(out var rogue, out _, attackerClass: "shadow_pelt", targetClass: "fangsworn");
rogue.SneakAttackUsedThisTurn = true;
rogue.OnTurnStart();
Assert.False(rogue.SneakAttackUsedThisTurn);
}
// ── Fangsworn Duelist ─────────────────────────────────────────────
[Fact]
public void Duelist_AddsTwoDamage_OneHandedWeapon()
{
var enc = MakeMiniEncounter(out var fang, out var target, attackerClass: "fangsworn", targetClass: "fangsworn");
// Fangsworn starting kit: rend_sword + buckler (shield in offhand). Per Duelist spec
// shield in off-hand is OK; only "no other weapon" matters.
Assert.Equal("duelist", fang.SourceCharacter!.FightingStyle);
var attack = fang.AttackOptions[0];
int bonus = FeatureProcessor.ApplyDamageBonus(enc, fang, target, attack, isCrit: false);
Assert.True(bonus >= 2, $"Duelist should add at least 2 damage; got {bonus}");
}
// ── Helpers ──────────────────────────────────────────────────────
private Theriapolis.Core.Rules.Character.Character MakeChar(string classId, string cladeId, string speciesId, AbilityScores a)
{
var b = new CharacterBuilder
{
Clade = _content.Clades[cladeId],
Species = _content.Species[speciesId],
ClassDef = _content.Classes[classId],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = a,
};
// Pick the right number of skills.
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);
}
private Encounter MakeMiniEncounter(
out Combatant attacker, out Combatant target,
string attackerClass = "fangsworn", string targetClass = "fangsworn")
{
var atkChar = MakeChar(attackerClass, "canidae", "wolf", new AbilityScores(15, 14, 13, 12, 10, 8));
var defChar = MakeChar(targetClass, "canidae", "wolf", new AbilityScores(15, 14, 13, 12, 10, 8));
attacker = Combatant.FromCharacter(atkChar, 1, "Attacker", new Vec2(0, 0),
Theriapolis.Core.Rules.Character.Allegiance.Player);
target = Combatant.FromCharacter(defChar, 2, "Target", new Vec2(1, 0),
Theriapolis.Core.Rules.Character.Allegiance.Hostile);
return new Encounter(0xABCDUL, 1, new[] { attacker, target });
}
}
@@ -0,0 +1,140 @@
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 M4 — Medical Incompatibility scales healing received by a
/// hybrid PC at 75% (round down, min 1). Verified end-to-end via the
/// healer features wired in M1.
/// </summary>
public sealed class HybridMedicalIncompatibilityTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void FieldRepair_OnHybridTarget_ScalesAtSeventyFivePercent()
{
var enc = MakeEncounter(out var healer, out var ally,
healerClass: "claw_wright", isAllyHybrid: true);
ally.CurrentHp = 5;
int beforeHp = ally.CurrentHp;
FeatureProcessor.TryFieldRepair(enc, healer, ally);
// 1d8 + INT mod (claw_wright kit gives INT 14 → +2 mod) → range 310.
// After 0.75 scale: range 27 (rounded down, min 1).
int gained = ally.CurrentHp - beforeHp;
Assert.True(gained >= 2, $"hybrid should still gain at least 2 HP after scaling; got {gained}");
Assert.True(gained <= 7, $"hybrid heal should be 75% of raw range; got {gained}");
}
[Fact]
public void FieldRepair_OnPurebredTarget_DoesNotScale()
{
var enc = MakeEncounter(out var healer, out var ally,
healerClass: "claw_wright", isAllyHybrid: false);
ally.CurrentHp = 5;
int beforeHp = ally.CurrentHp;
FeatureProcessor.TryFieldRepair(enc, healer, ally);
int gained = ally.CurrentHp - beforeHp;
// Purebred: full 1d8 + INT 2 → range 310.
Assert.True(gained >= 3, $"purebred should gain full heal; got {gained}");
}
[Fact]
public void LayOnPaws_OnHybridTarget_ScalesDeliveredHpButNotPoolCost()
{
var enc = MakeEncounter(out var healer, out var ally,
healerClass: "covenant_keeper", isAllyHybrid: true);
FeatureProcessor.EnsureLayOnPawsPoolReady(healer.SourceCharacter!);
int poolBefore = healer.SourceCharacter!.LayOnPawsPoolRemaining;
ally.CurrentHp = ally.MaxHp - 4;
FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 4);
// Pool cost is the requested 4 (the inefficiency models the body
// resisting calibration, not the healer wasting effort).
Assert.Equal(poolBefore - 4, healer.SourceCharacter.LayOnPawsPoolRemaining);
// But ally only receives 3 HP (4 * 0.75 = 3, floor).
int gained = ally.CurrentHp - (ally.MaxHp - 4);
Assert.Equal(3, gained);
}
[Fact]
public void LayOnPaws_OnPurebredTarget_DeliversFullHp()
{
var enc = MakeEncounter(out var healer, out var ally,
healerClass: "covenant_keeper", isAllyHybrid: false);
FeatureProcessor.EnsureLayOnPawsPoolReady(healer.SourceCharacter!);
ally.CurrentHp = ally.MaxHp - 4;
FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 4);
Assert.Equal(ally.MaxHp, ally.CurrentHp);
}
// ── Helpers ───────────────────────────────────────────────────────────
private Encounter MakeEncounter(
out Combatant healer, out Combatant ally,
string healerClass, bool isAllyHybrid)
{
var hc = MakeChar(healerClass, new AbilityScores(10, 12, 13, 14, 12, 14));
var ac = isAllyHybrid
? MakeHybrid()
: MakeChar("fangsworn", 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", new Vec2(1, 0), Allegiance.Allied);
return new Encounter(0xCAFEUL, 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);
}
private Theriapolis.Core.Rules.Character.Character MakeHybrid()
{
var b = new CharacterBuilder
{
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
IsHybridOrigin = true,
HybridSireClade = _content.Clades["canidae"],
HybridSireSpecies = _content.Species["wolf"],
HybridDamClade = _content.Clades["leporidae"],
HybridDamSpecies = _content.Species["rabbit"],
HybridDominantParent = ParentLineage.Sire,
};
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 { }
}
bool ok = b.TryBuildHybrid(_content.Items, out var c, out string err);
Assert.True(ok, err);
return c!;
}
}
@@ -0,0 +1,83 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
public sealed class InitiativeTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void NewEncounter_AppendsInitiativeLogEntry()
{
var combatants = MakeThree();
var enc = new Encounter(0xCAFEUL, 1, combatants);
Assert.Contains(enc.Log, l => l.Type == CombatLogEntry.Kind.Initiative);
}
[Fact]
public void InitiativeOrder_ContainsEveryCombatantExactlyOnce()
{
var combatants = MakeThree();
var enc = new Encounter(0xCAFEUL, 1, combatants);
Assert.Equal(combatants.Count, enc.InitiativeOrder.Count);
Assert.Equal(combatants.Count, enc.InitiativeOrder.Distinct().Count());
foreach (int idx in enc.InitiativeOrder)
Assert.InRange(idx, 0, combatants.Count - 1);
}
[Fact]
public void EndTurn_AdvancesToNextLivingCombatant()
{
var combatants = MakeThree();
var enc = new Encounter(0xCAFEUL, 1, combatants);
var first = enc.CurrentActor;
enc.EndTurn();
Assert.NotEqual(first.Id, enc.CurrentActor.Id);
}
[Fact]
public void EndTurn_WrappingIncrementsRoundCounter()
{
var combatants = MakeThree();
var enc = new Encounter(0xCAFEUL, 1, combatants);
Assert.Equal(1, enc.RoundNumber);
// Advance N turns to wrap once.
for (int i = 0; i < combatants.Count; i++) enc.EndTurn();
Assert.Equal(2, enc.RoundNumber);
}
[Fact]
public void CheckForVictory_EndsWhenOnlyOneSideRemains()
{
var brigand = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
var wolf = _content.Npcs.Templates.First(t => t.Id == "wolf");
var hero = Combatant.FromNpcTemplate(brigand, id: 1, new Vec2(0, 0));
var foe = Combatant.FromNpcTemplate(wolf, id: 2, new Vec2(1, 0));
// Force opposite allegiances. Brigand defaults Hostile; rebuild "hero" as Player-side.
hero = Combatant.FromNpcTemplate(brigand with { DefaultAllegiance = "player" }, id: 1, new Vec2(0, 0));
var enc = new Encounter(1UL, 1, new[] { hero, foe });
// Knock out the foe.
Resolver.ApplyDamage(foe, foe.MaxHp);
Assert.True(enc.CheckForVictory());
Assert.True(enc.IsOver);
Assert.Contains(enc.Log, l => l.Type == CombatLogEntry.Kind.EncounterEnd);
}
private List<Combatant> MakeThree()
{
var brigand = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
var wolf = _content.Npcs.Templates.First(t => t.Id == "wolf");
var captain = _content.Npcs.Templates.First(t => t.Id == "brigand_captain");
// Mix allegiances so CheckForVictory doesn't end the encounter immediately.
return new List<Combatant>
{
Combatant.FromNpcTemplate(brigand with { DefaultAllegiance = "player" }, id: 1, new Vec2(0, 0)),
Combatant.FromNpcTemplate(wolf, id: 2, new Vec2(2, 0)),
Combatant.FromNpcTemplate(captain, id: 3, new Vec2(4, 0)),
};
}
}
@@ -0,0 +1,328 @@
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;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -0,0 +1,298 @@
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 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.
/// </summary>
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<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(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);
}
}
@@ -0,0 +1,325 @@
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);
}
}
@@ -0,0 +1,246 @@
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 7 M0 — wires four more L3 subclass features (Phase 6.5 carryover):
/// - Antler-Guard "Retaliatory Strike": melee hit on a Sentinel-Stance
/// antler-guard returns 1d8 + CON to the attacker.
/// - Stampede-Heart "Trampling Charge": +1d8 bludgeoning on the first
/// melee attack of each turn while raging.
/// - Ambush-Artist "Opening Strike": +2d6 on the first melee attack of
/// round 1 of an encounter.
/// - Body-Wright "Combat Medic": Field Repair rolls 2d8 + INT (vs the
/// base 1d8 + INT).
///
/// Combined with the four Phase-6.5 wirings (Lone Fang, Herd-Wall,
/// Pack-Forged, Blood Memory), this brings 8 of 16 L3 subclass features
/// to live runtime. The remaining 8 are scaffolded but unwired and land
/// in Phase 7 M1.
/// </summary>
public sealed class Phase7M0SubclassFeatureTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Antler-Guard Retaliatory Strike ──────────────────────────────────
[Fact]
public void AntlerGuard_RetaliatoryStrike_ReturnsDamageInSentinelStance()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "bulwark", pcSubclass: "antler_guard");
pc.SentinelStanceActive = true;
int hostileHpBefore = hostile.CurrentHp;
var attack = pc.AttackOptions[0];
int dealt = FeatureProcessor.OnAntlerGuardHit(enc, hostile, pc, attack);
Assert.True(dealt >= 1, "retaliatory strike must deal at least 1 damage");
Assert.Equal(hostileHpBefore - dealt, hostile.CurrentHp);
}
[Fact]
public void AntlerGuard_RetaliatoryStrike_DoesNotFireWithoutSentinelStance()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "bulwark", pcSubclass: "antler_guard");
// SentinelStanceActive is false by default.
int hostileHpBefore = hostile.CurrentHp;
int dealt = FeatureProcessor.OnAntlerGuardHit(enc, hostile, pc, pc.AttackOptions[0]);
Assert.Equal(0, dealt);
Assert.Equal(hostileHpBefore, hostile.CurrentHp);
}
[Fact]
public void AntlerGuard_RetaliatoryStrike_DoesNotFireOnRangedHit()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "bulwark", pcSubclass: "antler_guard");
pc.SentinelStanceActive = true;
int hostileHpBefore = hostile.CurrentHp;
var rangedAttack = MakeRangedAttack();
int dealt = FeatureProcessor.OnAntlerGuardHit(enc, hostile, pc, rangedAttack);
Assert.Equal(0, dealt);
Assert.Equal(hostileHpBefore, hostile.CurrentHp);
}
[Fact]
public void AntlerGuard_RetaliatoryStrike_NoFire_IfNotAntlerGuard()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "bulwark", pcSubclass: "herd_wall");
pc.SentinelStanceActive = true;
int dealt = FeatureProcessor.OnAntlerGuardHit(enc, hostile, pc, pc.AttackOptions[0]);
Assert.Equal(0, dealt);
}
// ── Stampede-Heart Trampling Charge ──────────────────────────────────
[Fact]
public void StampedeHeart_TramplingCharge_AddsDamage_FirstMeleeWhileRaging()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "feral", pcSubclass: "stampede_heart");
pc.RageActive = true;
int bonus = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, pc.AttackOptions[0], isCrit: false);
// Bonus = +2 (Rage) + 1d8 (Trampling Charge). Min 2 + 1 = 3, max 2 + 8 = 10.
Assert.True(bonus >= 3 && bonus <= 10, $"expected [3..10], got {bonus}");
Assert.True(pc.TramplingChargeUsedThisTurn);
}
[Fact]
public void StampedeHeart_TramplingCharge_OnlyFiresOncePerTurn()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "feral", pcSubclass: "stampede_heart");
pc.RageActive = true;
var attack = pc.AttackOptions[0];
int first = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, attack, isCrit: false);
int second = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, attack, isCrit: false);
// First: rage (+2) + trampling (+1d8). Second: rage only (+2).
Assert.True(first > second);
Assert.Equal(2, second);
}
[Fact]
public void StampedeHeart_TramplingCharge_DoesNotFireWithoutRage()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "feral", pcSubclass: "stampede_heart");
// RageActive false by default.
int bonus = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, pc.AttackOptions[0], isCrit: false);
Assert.Equal(0, bonus);
}
// ── Ambush-Artist Opening Strike ─────────────────────────────────────
[Fact]
public void AmbushArtist_OpeningStrike_AddsDamageInRoundOne()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "shadow_pelt", pcSubclass: "ambush_artist");
Assert.Equal(1, enc.RoundNumber);
int bonus = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, pc.AttackOptions[0], isCrit: false);
// Sneak attack 1d6 + Opening Strike 2d6 = 3d6. Range [3..18].
Assert.True(bonus >= 3 && bonus <= 18, $"expected [3..18], got {bonus}");
Assert.True(pc.OpeningStrikeUsed);
}
[Fact]
public void AmbushArtist_OpeningStrike_OnlyFiresOncePerEncounter()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "shadow_pelt", pcSubclass: "ambush_artist");
var attack = pc.AttackOptions[0];
int first = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, attack, isCrit: false);
// Reset sneak-attack flag for cross-turn re-fire test.
pc.SneakAttackUsedThisTurn = false;
int second = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, attack, isCrit: false);
// First fires opening strike (3d6 = [3..18]); second only sneak attack (1d6 = [1..6]).
Assert.True(first > second);
Assert.True(second >= 1 && second <= 6, $"second attack should only be sneak attack [1..6], got {second}");
}
// ── Body-Wright Combat Medic ─────────────────────────────────────────
[Fact]
public void BodyWright_FieldRepair_RollsTwoD8()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "claw_wright", pcSubclass: "body_wright");
// Establish the per-encounter pool the way PlayScreen would.
FeatureProcessor.EnsureFieldRepairReady(pc.SourceCharacter!);
// Damage the PC so the heal has somewhere to go.
pc.CurrentHp = 1;
bool ok = FeatureProcessor.TryFieldRepair(enc, pc, pc);
Assert.True(ok);
// Body-Wright heals 2d8 + INT — minimum 2 + INT, max 16 + INT. Since
// healing clamps to MaxHp we just assert the heal exceeded the
// 1d8 + INT base ceiling (8 + INT) at least *sometimes*. To make
// this deterministic per our seed, assert HP gained ≥ 2 (the 2d8 floor).
Assert.True(pc.CurrentHp >= 3,
$"Body-Wright Combat Medic should heal ≥ 2 HP from a 2d8 roll (was at 1, now {pc.CurrentHp})");
}
[Fact]
public void NonBodyWright_FieldRepair_RollsOneD8()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "claw_wright", pcSubclass: null);
FeatureProcessor.EnsureFieldRepairReady(pc.SourceCharacter!);
pc.CurrentHp = 1;
bool ok = FeatureProcessor.TryFieldRepair(enc, pc, pc);
Assert.True(ok);
// Non-body-wright: 1d8 + INT = [1+INT..8+INT], capped to MaxHp.
// Just assert heal ≤ 8 + INT (we don't care about INT exactly here).
int intMod = pc.SourceCharacter!.Abilities.ModFor(AbilityId.INT);
Assert.True(pc.CurrentHp <= 1 + 8 + intMod,
$"Field Repair w/o Body-Wright caps at 1d8+INT = {1 + 8 + intMod}, got {pc.CurrentHp}");
}
// ── Helpers ──────────────────────────────────────────────────────────
private Encounter MakeDuel(out Combatant pc, out Combatant hostile,
string pcClass, string? pcSubclass)
{
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);
return new Encounter(0xCAFEUL, 1, new List<Combatant> { pc, hostile });
}
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);
}
private static AttackOption MakeRangedAttack() =>
new AttackOption
{
Name = "ranged-test",
Damage = new DamageRoll(1, 6, 0, DamageType.Piercing),
ToHitBonus = 0,
// Setting RangeShortTiles>0 flips IsRanged true via the derived prop.
RangeShortTiles = 6,
RangeLongTiles = 18,
};
}
@@ -0,0 +1,99 @@
using Theriapolis.Core.Data;
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;
public sealed class ReachAndCoverTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void EdgeToEdge_Adjacent_ReturnsZero()
{
var a = MakeMediumNpc(new Vec2(5, 5));
var b = MakeMediumNpc(new Vec2(6, 5));
Assert.Equal(0, ReachAndCover.EdgeToEdgeChebyshev(a, b));
}
[Fact]
public void EdgeToEdge_OneTileApart_ReturnsOne()
{
var a = MakeMediumNpc(new Vec2(5, 5));
var b = MakeMediumNpc(new Vec2(7, 5)); // 1 empty tile between
Assert.Equal(1, ReachAndCover.EdgeToEdgeChebyshev(a, b));
}
[Fact]
public void EdgeToEdge_LargeAttacker_FootprintCountedCorrectly()
{
var large = MakeLargeNpc(new Vec2(0, 0)); // occupies (0..1, 0..1)
var medium = MakeMediumNpc(new Vec2(3, 0)); // 1 empty tile between (large's right edge = 1, medium = 3)
Assert.Equal(1, ReachAndCover.EdgeToEdgeChebyshev(large, medium));
}
[Fact]
public void IsInReach_MeleeAdjacent_True()
{
var a = MakeMediumNpc(new Vec2(0, 0));
var b = MakeMediumNpc(new Vec2(1, 0));
var attack = new AttackOption { Name = "Bite", Damage = new DamageRoll(1, 4, 0, DamageType.Piercing), ReachTiles = 1 };
Assert.True(ReachAndCover.IsInReach(a, b, attack));
}
[Fact]
public void IsInReach_MeleeOutOfReach_False()
{
var a = MakeMediumNpc(new Vec2(0, 0));
var b = MakeMediumNpc(new Vec2(3, 0));
var attack = new AttackOption { Name = "Bite", Damage = new DamageRoll(1, 4, 0, DamageType.Piercing), ReachTiles = 1 };
Assert.False(ReachAndCover.IsInReach(a, b, attack));
}
[Fact]
public void IsInReach_RangedShortRange_True()
{
var a = MakeMediumNpc(new Vec2(0, 0));
var b = MakeMediumNpc(new Vec2(8, 0));
var bow = new AttackOption { Name = "Bow", Damage = new DamageRoll(1, 6, 0, DamageType.Piercing),
RangeShortTiles = 16, RangeLongTiles = 64 };
Assert.True(ReachAndCover.IsInReach(a, b, bow));
Assert.False(ReachAndCover.IsLongRange(a, b, bow));
}
[Fact]
public void IsInReach_RangedLongRange_TrueWithLongRangeFlag()
{
var a = MakeMediumNpc(new Vec2(0, 0));
var b = MakeMediumNpc(new Vec2(40, 0));
var bow = new AttackOption { Name = "Bow", Damage = new DamageRoll(1, 6, 0, DamageType.Piercing),
RangeShortTiles = 16, RangeLongTiles = 64 };
Assert.True(ReachAndCover.IsInReach(a, b, bow));
Assert.True(ReachAndCover.IsLongRange(a, b, bow));
}
[Fact]
public void StepToward_MovesOneTileTowardGoal()
{
var step = ReachAndCover.StepToward(new Vec2(0, 0), new Vec2(5, 3));
Assert.Equal(1, step.X);
Assert.Equal(1, step.Y);
}
[Fact]
public void StepToward_AtGoal_ReturnsSamePosition()
{
var step = ReachAndCover.StepToward(new Vec2(5, 5), new Vec2(5, 5));
Assert.Equal(5, step.X);
Assert.Equal(5, step.Y);
}
private Combatant MakeMediumNpc(Vec2 pos)
=> Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "wolf"), id: 1, pos);
private Combatant MakeLargeNpc(Vec2 pos)
=> Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "bear_brown"), id: 2, pos);
}