Files
TheriapolisV3/Theriapolis.Tests/Combat/FeatureProcessorTests.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

180 lines
8.7 KiB
C#

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