b451f83174
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>
180 lines
8.7 KiB
C#
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 });
|
|
}
|
|
}
|