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

299 lines
12 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 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);
}
}