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