Files
TheriapolisV3/Theriapolis.Tests/Combat/Phase7M0SubclassFeatureTests.cs
Christopher Wiebe b451f83174 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>
2026-04-30 20:40:51 -07:00

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,
};
}