247 lines
10 KiB
C#
247 lines
10 KiB
C#
|
|
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,
|
||
|
|
};
|
||
|
|
}
|