Files
TheriapolisV3/Theriapolis.Tests/Combat/Phase65M1FeatureTests.cs
T
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

329 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}