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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 3–10.
|
||||
// After 0.75 scale: range 2–7 (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 3–10.
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user