329 lines
13 KiB
C#
329 lines
13 KiB
C#
|
|
using Theriapolis.Core;
|
|||
|
|
using Theriapolis.Core.Data;
|
|||
|
|
using Theriapolis.Core.Entities.Ai;
|
|||
|
|
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 M1 — level-1 class-feature catch-up: Field Repair (Claw-Wright),
|
|||
|
|
/// Lay on Paws (Covenant-Keeper), Vocalization Dice (Muzzle-Speaker).
|
|||
|
|
/// Scent Literacy is a UI-only feature in M1 and is exercised at the
|
|||
|
|
/// integration level rather than here.
|
|||
|
|
/// </summary>
|
|||
|
|
public sealed class Phase65M1FeatureTests
|
|||
|
|
{
|
|||
|
|
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
|
|||
|
|
|
|||
|
|
// ── Field Repair ──────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void FieldRepair_HealsTargetByOneD8PlusInt_AndConsumesUse()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var healer, out var ally,
|
|||
|
|
healerClass: "claw_wright", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
|
|||
|
|
// Damage the ally so the heal has somewhere to land.
|
|||
|
|
ally.CurrentHp = 5;
|
|||
|
|
int beforeUses = healer.SourceCharacter!.FieldRepairUsesRemaining;
|
|||
|
|
|
|||
|
|
bool ok = FeatureProcessor.TryFieldRepair(enc, healer, ally);
|
|||
|
|
Assert.True(ok);
|
|||
|
|
Assert.Equal(beforeUses - 1, healer.SourceCharacter.FieldRepairUsesRemaining);
|
|||
|
|
Assert.True(ally.CurrentHp > 5, $"ally HP should rise; was 5, now {ally.CurrentHp}");
|
|||
|
|
// 1d8 + INT mod (Claw-Wright with INT 13 from default kit → +1) → ≥ 2.
|
|||
|
|
Assert.True(ally.CurrentHp - 5 >= 2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void FieldRepair_RefusesWhenExhausted()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var healer, out var ally,
|
|||
|
|
healerClass: "claw_wright", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
healer.SourceCharacter!.FieldRepairUsesRemaining = 0;
|
|||
|
|
ally.CurrentHp = 5;
|
|||
|
|
bool ok = FeatureProcessor.TryFieldRepair(enc, healer, ally);
|
|||
|
|
Assert.False(ok);
|
|||
|
|
Assert.Equal(5, ally.CurrentHp);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void FieldRepair_OnlyForClawWright()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var notHealer, out var ally,
|
|||
|
|
healerClass: "fangsworn", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
ally.CurrentHp = 5;
|
|||
|
|
bool ok = FeatureProcessor.TryFieldRepair(enc, notHealer, ally);
|
|||
|
|
Assert.False(ok);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void EnsureFieldRepairReady_RestoresUseAfterEncounter()
|
|||
|
|
{
|
|||
|
|
var c = MakeChar("claw_wright", new AbilityScores(10, 12, 13, 14, 12, 8));
|
|||
|
|
c.FieldRepairUsesRemaining = 0;
|
|||
|
|
FeatureProcessor.EnsureFieldRepairReady(c);
|
|||
|
|
Assert.Equal(1, c.FieldRepairUsesRemaining);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Lay on Paws ───────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void LayOnPaws_SpendsPoolAndHealsTarget()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var healer, out var ally,
|
|||
|
|
healerClass: "covenant_keeper", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
FeatureProcessor.EnsureLayOnPawsPoolReady(healer.SourceCharacter!);
|
|||
|
|
int poolBefore = healer.SourceCharacter!.LayOnPawsPoolRemaining;
|
|||
|
|
Assert.True(poolBefore >= 1);
|
|||
|
|
|
|||
|
|
ally.CurrentHp = ally.MaxHp - 4;
|
|||
|
|
bool ok = FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 4);
|
|||
|
|
Assert.True(ok);
|
|||
|
|
Assert.Equal(ally.MaxHp, ally.CurrentHp);
|
|||
|
|
Assert.Equal(poolBefore - 4, healer.SourceCharacter.LayOnPawsPoolRemaining);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void LayOnPaws_ClampsToPoolRemaining()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var healer, out var ally,
|
|||
|
|
healerClass: "covenant_keeper", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
healer.SourceCharacter!.LayOnPawsPoolRemaining = 3;
|
|||
|
|
ally.CurrentHp = 1;
|
|||
|
|
bool ok = FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 99);
|
|||
|
|
Assert.True(ok);
|
|||
|
|
Assert.Equal(0, healer.SourceCharacter.LayOnPawsPoolRemaining);
|
|||
|
|
Assert.Equal(4, ally.CurrentHp);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void LayOnPaws_RefusesWhenPoolEmpty()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var healer, out var ally,
|
|||
|
|
healerClass: "covenant_keeper", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
healer.SourceCharacter!.LayOnPawsPoolRemaining = 0;
|
|||
|
|
ally.CurrentHp = 5;
|
|||
|
|
bool ok = FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 5);
|
|||
|
|
Assert.False(ok);
|
|||
|
|
Assert.Equal(5, ally.CurrentHp);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void EnsureLayOnPawsPool_ScalesWithCha()
|
|||
|
|
{
|
|||
|
|
var c = MakeChar("covenant_keeper", new AbilityScores(15, 10, 13, 10, 12, 16));
|
|||
|
|
c.LayOnPawsPoolRemaining = 0;
|
|||
|
|
FeatureProcessor.EnsureLayOnPawsPoolReady(c);
|
|||
|
|
// CHA 16 → +3 mod → 5 × 3 = 15 pool.
|
|||
|
|
Assert.Equal(15, c.LayOnPawsPoolRemaining);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void EnsureLayOnPawsPool_LowChaStillGetsTokenPool()
|
|||
|
|
{
|
|||
|
|
var c = MakeChar("covenant_keeper", new AbilityScores(15, 10, 13, 10, 12, 8));
|
|||
|
|
c.LayOnPawsPoolRemaining = 0;
|
|||
|
|
FeatureProcessor.EnsureLayOnPawsPoolReady(c);
|
|||
|
|
// CHA 8 → -1 mod → minimum 5 pool.
|
|||
|
|
Assert.True(c.LayOnPawsPoolRemaining >= 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Vocalization Dice ─────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void VocalizationDieSidesFor_FollowsLevelLadder()
|
|||
|
|
{
|
|||
|
|
Assert.Equal(6, FeatureProcessor.VocalizationDieSidesFor(1));
|
|||
|
|
Assert.Equal(6, FeatureProcessor.VocalizationDieSidesFor(4));
|
|||
|
|
Assert.Equal(8, FeatureProcessor.VocalizationDieSidesFor(5));
|
|||
|
|
Assert.Equal(10, FeatureProcessor.VocalizationDieSidesFor(9));
|
|||
|
|
Assert.Equal(12, FeatureProcessor.VocalizationDieSidesFor(15));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void TryGrantVocalizationDie_GivesAllyInspirationAndConsumesUse()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var caster, out var ally,
|
|||
|
|
healerClass: "muzzle_speaker", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
int before = caster.SourceCharacter!.VocalizationDiceRemaining;
|
|||
|
|
|
|||
|
|
bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, ally);
|
|||
|
|
Assert.True(ok);
|
|||
|
|
Assert.Equal(6, ally.InspirationDieSides);
|
|||
|
|
Assert.Equal(before - 1, caster.SourceCharacter.VocalizationDiceRemaining);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void TryGrantVocalizationDie_RefusesSelfTarget()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var caster, out _,
|
|||
|
|
healerClass: "muzzle_speaker", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, caster);
|
|||
|
|
Assert.False(ok);
|
|||
|
|
Assert.Equal(0, caster.InspirationDieSides);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void TryGrantVocalizationDie_RefusesAlreadyInspired()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var caster, out var ally,
|
|||
|
|
healerClass: "muzzle_speaker", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
ally.InspirationDieSides = 6;
|
|||
|
|
int before = caster.SourceCharacter!.VocalizationDiceRemaining;
|
|||
|
|
bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, ally);
|
|||
|
|
Assert.False(ok);
|
|||
|
|
Assert.Equal(before, caster.SourceCharacter.VocalizationDiceRemaining);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void TryGrantVocalizationDie_RefusesOutOfRange()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var caster, out var ally,
|
|||
|
|
healerClass: "muzzle_speaker", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied,
|
|||
|
|
allyPosition: new Vec2(20, 0)); // > 12 tactical tiles
|
|||
|
|
bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, ally);
|
|||
|
|
Assert.False(ok);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void ConsumeInspirationDie_ZeroesAndReturnsRoll()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var caster, out var ally,
|
|||
|
|
healerClass: "muzzle_speaker", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
ally.InspirationDieSides = 6;
|
|||
|
|
int rolled = FeatureProcessor.ConsumeInspirationDie(enc, ally);
|
|||
|
|
Assert.InRange(rolled, 1, 6);
|
|||
|
|
Assert.Equal(0, ally.InspirationDieSides);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void ConsumeInspirationDie_NoOpWhenNoInspiration()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var caster, out var ally,
|
|||
|
|
healerClass: "muzzle_speaker", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
Assert.Equal(0, ally.InspirationDieSides);
|
|||
|
|
Assert.Equal(0, FeatureProcessor.ConsumeInspirationDie(enc, ally));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void EnsureVocalizationDiceReady_RefillsToFour()
|
|||
|
|
{
|
|||
|
|
var c = MakeChar("muzzle_speaker", new AbilityScores(8, 14, 13, 10, 12, 16));
|
|||
|
|
c.VocalizationDiceRemaining = 0;
|
|||
|
|
FeatureProcessor.EnsureVocalizationDiceReady(c);
|
|||
|
|
Assert.Equal(4, c.VocalizationDiceRemaining);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── AiContext targeting helpers ───────────────────────────────────────
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void AiContext_FindClosestAlly_FindsAllyWhenPresent()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var pc, out var ally,
|
|||
|
|
healerClass: "muzzle_speaker", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
var ctx = new AiContext(enc);
|
|||
|
|
Assert.Same(ally, ctx.FindClosestAlly(pc));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void AiContext_FindClosestAlly_NullWhenAlone()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var pc, out var hostile,
|
|||
|
|
healerClass: "muzzle_speaker", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Hostile);
|
|||
|
|
var ctx = new AiContext(enc);
|
|||
|
|
Assert.Null(ctx.FindClosestAlly(pc));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void AiContext_FindMostDamagedFriendly_PrefersWoundedAllyOverFullHpSelf()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var pc, out var ally,
|
|||
|
|
healerClass: "covenant_keeper", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
// PC at full HP, ally damaged.
|
|||
|
|
ally.CurrentHp = 5;
|
|||
|
|
var ctx = new AiContext(enc);
|
|||
|
|
Assert.Same(ally, ctx.FindMostDamagedFriendly(pc));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void AiContext_FindMostDamagedFriendly_NullWhenAllAtFullHp()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var pc, out _,
|
|||
|
|
healerClass: "covenant_keeper", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Allied);
|
|||
|
|
var ctx = new AiContext(enc);
|
|||
|
|
Assert.Null(ctx.FindMostDamagedFriendly(pc));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Inspiration die end-to-end through Resolver ───────────────────────
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void Resolver_ConsumesInspirationDie_OnAttackRoll()
|
|||
|
|
{
|
|||
|
|
var enc = MakeEncounter(out var attacker, out var target,
|
|||
|
|
healerClass: "fangsworn", allyClass: "fangsworn",
|
|||
|
|
allyAllegiance: Allegiance.Hostile);
|
|||
|
|
attacker.InspirationDieSides = 6;
|
|||
|
|
var attack = attacker.AttackOptions[0];
|
|||
|
|
Resolver.AttemptAttack(enc, attacker, target, attack);
|
|||
|
|
// The die should have been consumed regardless of hit/miss.
|
|||
|
|
Assert.Equal(0, attacker.InspirationDieSides);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
private Encounter MakeEncounter(
|
|||
|
|
out Combatant healer, out Combatant ally,
|
|||
|
|
string healerClass, string allyClass,
|
|||
|
|
Allegiance allyAllegiance,
|
|||
|
|
Vec2? allyPosition = null)
|
|||
|
|
{
|
|||
|
|
var hc = MakeChar(healerClass, new AbilityScores(10, 12, 13, 14, 12, 14));
|
|||
|
|
var ac = MakeChar(allyClass, new AbilityScores(15, 12, 13, 10, 10, 8));
|
|||
|
|
healer = Combatant.FromCharacter(hc, 1, "Healer", new Vec2(0, 0), Allegiance.Player);
|
|||
|
|
ally = Combatant.FromCharacter(ac, 2, "Ally",
|
|||
|
|
allyPosition ?? new Vec2(2, 0), allyAllegiance);
|
|||
|
|
return new Encounter(0xFEEDUL, 1, new[] { healer, ally });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|