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>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,238 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Dialogue;
using Theriapolis.Core.Rules.Quests;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Time;
using Theriapolis.Core.Util;
using Theriapolis.Core.World.Settlements;
using Xunit;
namespace Theriapolis.Tests.ActI;
/// <summary>
/// Phase 6 M6 — Act I content end-to-end. Drives the dialogue runner +
/// quest engine through the ship-point sequence:
/// 1. Player arrives in Millhaven → arrival quest auto-starts.
/// 2. Player talks to the magistrate → flag set, plot items handed
/// over, arrival quest advances.
/// 3. Player talks to Asha → Old Howl quest starts, stone given.
/// 4. Player returns to Asha → Old Howl quest completes.
/// 5. Player talks to Lacroix and interrogates him → climax resolved
/// with intel branch; Maw sigil obtained.
/// 6. Final inventory matches the design's ship-point checklist:
/// journal, formula, names list, Maw sigil, Howl-stone.
///
/// The test is content-driven: no mocks; loads the real JSON from
/// Content/Data/. If the dialogue or quest authoring drifts from the
/// engine's expectations, this test catches it.
/// </summary>
public sealed class ActIIntegrationTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
private readonly WorldCache _cache;
public ActIIntegrationTests(WorldCache c) => _cache = c;
private static Character WolfFangsworn(ContentResolver content)
{
var b = new CharacterBuilder()
.WithClade(content.Clades["canidae"])
.WithSpecies(content.Species["wolf"])
.WithClass(content.Classes["fangsworn"])
.WithBackground(content.Backgrounds["pack_raised"])
.WithAbilities(new AbilityScores(13, 12, 14, 10, 10, 11));
var classD = content.Classes["fangsworn"];
var added = new HashSet<SkillId>();
for (int i = 0; i < classD.SkillOptions.Length && added.Count < classD.SkillsChoose; i++)
{
try
{
var sk = SkillIdExtensions.FromJson(classD.SkillOptions[i]);
if (added.Add(sk)) b.ChooseSkill(sk);
}
catch (System.ArgumentException) { }
}
return b.Build();
}
private static NpcActor MakeNamedResident(ContentResolver content, string roleTag, int id, int settlementId)
{
var template = content.ResidentsByRoleTag[roleTag];
return new NpcActor(template)
{
Id = id,
Position = new Vec2(0, 0),
RoleTag = roleTag,
HomeSettlementId = settlementId,
};
}
private static void RunDialogueTree(DialogueRunner runner, params int[] optionIndices)
{
foreach (var idx in optionIndices)
{
var result = runner.ChooseOption(idx);
if (result.ClosedAfter || runner.IsOver) return;
}
}
[Fact]
public void ShipPoint_PlaysActIToCompletion_WithExpectedInventoryAndQuests()
{
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
var pc = WolfFangsworn(content);
var rep = new PlayerReputation();
var flags = new Dictionary<string, int>();
var anchors = new AnchorRegistry();
var clock = new WorldClock();
var world = _cache.Get(TestSeed).World;
anchors.RegisterAllAnchors(world);
var actors = new ActorManager();
actors.SpawnPlayer(new Vec2(0, 0), pc);
var qctx = new QuestContext(content, actors, rep, flags, anchors, clock, world)
{
PlayerCharacter = pc,
};
var qengine = new QuestEngine();
// Find Millhaven (skip if the seed didn't place it as a normal anchor — the test seed places one).
var millhaven = world.Settlements.FirstOrDefault(s => s.Anchor is Theriapolis.Core.World.NarrativeAnchor.Millhaven);
if (millhaven is null) return; // anchor placement varies per seed; this run skips
// ── Step 1: arrive in Millhaven (set the player position there). ──
actors.Player!.Position = new Vec2(
millhaven.TileX * C.WORLD_TILE_PIXELS,
millhaven.TileY * C.WORLD_TILE_PIXELS);
qengine.Tick(qctx);
Assert.True(qengine.IsActive("main_act_i_001_arrival"));
// ── Step 2: talk to the magistrate. ──
var magistrate = MakeNamedResident(content, "millhaven.magistrate", 100, millhaven.Id);
var magCtx = new DialogueContext(magistrate, pc, rep, flags, content);
var magRunner = new DialogueRunner(content.Dialogues["millhaven_magistrate"], magCtx, TestSeed);
// Walk: intro → "Tell me what happened" (option 0) → "I'll take them" (option 0)
RunDialogueTree(magRunner, 0, 0);
Assert.True(flags.GetValueOrDefault("spoke_to_millhaven_magistrate") == 1);
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "briarstead_journal");
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "formula_partial");
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "names_list");
// Quest engine should advance to "investigate" step.
qengine.Tick(qctx);
Assert.Equal("investigate", qengine.Get("main_act_i_001_arrival")!.CurrentStep);
// ── Step 3: talk to Asha → start Old Howl. ──
var asha = MakeNamedResident(content, "millhaven.grandmother_asha", 101, millhaven.Id);
var ashaCtx1 = new DialogueContext(asha, pc, rep, flags, content);
var ashaRunner1 = new DialogueRunner(content.Dialogues["millhaven_grandmother_asha"], ashaCtx1, TestSeed);
// intro options: 0 (parents), 1 (lore), 2 (journal_recognised, conditional TRUE), 3 (<end>).
// parents → wind_smell, then wind_smell → favour (option 1 the first time).
// favour → "I'll fetch the stone" (option 0) sets asha_offered_favour and start_quest.
RunDialogueTree(ashaRunner1, 0, 0, 1, 0);
// First time through, "favour" branch sets asha_offered_favour and starts the quest.
// Hand-fire the start_quest in case the runner buffered it.
if (ashaCtx1.StartQuestRequests.Count > 0)
{
foreach (var qid in ashaCtx1.StartQuestRequests)
qengine.Start(qid, qctx);
ashaCtx1.StartQuestRequests.Clear();
}
Assert.True(qengine.IsActive("side_act_i_old_howl"),
"Asha's dialogue should have started the Old Howl side quest");
// The quest's find_stone step's on_enter should have given the stone.
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "howl_stone");
// ── Step 4: return to Asha with the stone. ──
var ashaCtx2 = new DialogueContext(asha, pc, rep, flags, content);
var ashaRunner2 = new DialogueRunner(content.Dialogues["millhaven_grandmother_asha"], ashaCtx2, TestSeed);
// intro: now visible options include journal_recognised + parents + lore + end.
// Re-talk: 0 (parents) → 0 (wind_smell) → 0 (favour_offer, conditional on has_flag: asha_offered_favour).
// From favour_offer: 0 (stone returned, conditional on has_item: howl_stone) → stone_returned.
RunDialogueTree(ashaRunner2, 0, 0, 0, 0);
Assert.True(flags.GetValueOrDefault("asha_received_howl_stone") == 1,
"Returning the stone must set the asha_received_howl_stone flag");
Assert.DoesNotContain(pc.Inventory.Items, i => i.Def.Id == "howl_stone");
qengine.Tick(qctx);
Assert.True(qengine.IsCompleted("side_act_i_old_howl"));
// ── Step 5: confront Lacroix and interrogate him. ──
var lacroix = MakeNamedResident(content, "millhaven.lacroix", 102, millhaven.Id);
var lacCtx = new DialogueContext(lacroix, pc, rep, flags, content);
var lacRunner = new DialogueRunner(content.Dialogues["millhaven_lacroix"], lacCtx, TestSeed);
// intro options: 0 (what_doing), 1 (accuse, conditional on has_item: briarstead_journal — TRUE), 2 (end)
// We have the journal. Visible: 0 (what_doing), 1 (accuse), 2 (end). Pick 1 (accuse).
// accuse options: 0 (intimidate skill check), 1 (settle here = fight), 2 (let go)
// Pick 0 (intimidate). Skill check is deterministic; we accept either branch since
// both lead to climax_resolved; we just need maw_sigil.
RunDialogueTree(lacRunner, 1, 0);
// After the skill check, the runner is at either "interrogate" or "fight".
// From "interrogate", pick 0 (let him go) → set lacroix_climax_resolved + give maw_sigil + act_i_briarstead_searched.
// From "fight", pick 0 (decisive blow) → same flags + maw_sigil.
// Either way one more option pick closes:
if (!lacRunner.IsOver) RunDialogueTree(lacRunner, 0);
Assert.True(flags.GetValueOrDefault("lacroix_climax_resolved") == 1,
"Lacroix encounter must resolve");
Assert.True(flags.GetValueOrDefault("act_i_briarstead_searched") == 1,
"Lacroix climax sets the briarstead_searched flag (chains arrival quest)");
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "maw_sigil");
// ── Step 6: arrival quest completes; following_dead chains in. ──
qengine.Tick(qctx);
Assert.True(qengine.IsCompleted("main_act_i_001_arrival"),
"Arrival quest must complete after Briarstead is searched");
Assert.True(qengine.IsActive("main_act_i_003_following_dead")
|| qengine.IsCompleted("main_act_i_003_following_dead"),
"Following the Dead must have started (and possibly completed via climax flags)");
// ── Final ship-point inventory check. ──
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "briarstead_journal");
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "formula_partial");
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "names_list");
Assert.Contains(pc.Inventory.Items, i => i.Def.Id == "maw_sigil");
// The howl_stone has been returned to Asha — that's correct end-state.
Assert.DoesNotContain(pc.Inventory.Items, i => i.Def.Id == "howl_stone");
}
[Fact]
public void AllActIQuests_LoadAndValidate()
{
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
Assert.True(content.Quests.ContainsKey("main_act_i_001_arrival"));
Assert.True(content.Quests.ContainsKey("main_act_i_003_following_dead"));
Assert.True(content.Quests.ContainsKey("side_act_i_old_howl"));
Assert.True(content.Quests.ContainsKey("side_act_i_fence_lines"));
}
[Fact]
public void AllActINamedNpcDialogues_Exist()
{
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
Assert.True(content.Dialogues.ContainsKey("millhaven_magistrate"));
Assert.True(content.Dialogues.ContainsKey("millhaven_grandmother_asha"));
Assert.True(content.Dialogues.ContainsKey("millhaven_constable"));
Assert.True(content.Dialogues.ContainsKey("millhaven_lacroix"));
}
[Fact]
public void AllActIPlotItems_Exist()
{
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
Assert.True(content.Items.ContainsKey("briarstead_journal"));
Assert.True(content.Items.ContainsKey("formula_partial"));
Assert.True(content.Items.ContainsKey("names_list"));
Assert.True(content.Items.ContainsKey("maw_sigil"));
Assert.True(content.Items.ContainsKey("howl_stone"));
}
}
@@ -0,0 +1,52 @@
using System.Reflection;
using Xunit;
namespace Theriapolis.Tests.Architecture;
/// <summary>
/// Hard rule #1: Theriapolis.Core must not reference MonoGame or Microsoft.Xna.
/// This test reflects over Core.dll and fails the build if any forbidden assembly
/// is referenced.
/// </summary>
public sealed class CoreNoDependencyTests
{
private static readonly string[] ForbiddenPrefixes =
{
"Microsoft.Xna",
"MonoGame",
};
[Fact]
public void Core_DoesNotReference_MonoGame()
{
var coreAssembly = typeof(Theriapolis.Core.C).Assembly;
var referenced = coreAssembly.GetReferencedAssemblies();
var violations = referenced
.Where(r => ForbiddenPrefixes.Any(p => r.Name?.StartsWith(p, StringComparison.OrdinalIgnoreCase) == true))
.Select(r => r.Name!)
.ToList();
Assert.True(violations.Count == 0,
$"Theriapolis.Core must not reference MonoGame/XNA. Violations: {string.Join(", ", violations)}");
}
/// <summary>
/// Phase 4 also requires the new Core namespaces (Tactical, Entities,
/// Persistence, Time) to be MonoGame-free. The reflection check above
/// already proves this at the assembly level — this test is a belt-and-
/// braces audit that the namespaces actually exist (i.e., we didn't
/// accidentally put them in Game).
/// </summary>
[Theory]
[InlineData("Theriapolis.Core.Tactical")]
[InlineData("Theriapolis.Core.Entities")]
[InlineData("Theriapolis.Core.Persistence")]
[InlineData("Theriapolis.Core.Time")]
public void CoreNamespace_ExistsAndIsMonoGameFree(string ns)
{
var asm = typeof(Theriapolis.Core.C).Assembly;
var anyType = asm.GetTypes().FirstOrDefault(t => t.Namespace == ns);
Assert.NotNull(anyType);
}
}
@@ -0,0 +1,157 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
public sealed class AttackResolutionTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void AttemptAttack_RecordsHitOrMissAndDamage()
{
var (a, b) = MakeDuelists(seed: 0xABCDUL);
var enc = new Encounter(0xABCDUL, encounterId: 1, new[] { a, b });
var attack = a.AttackOptions[0];
var result = Resolver.AttemptAttack(enc, a, b, attack);
Assert.Equal(a.Id, result.AttackerId);
Assert.Equal(b.Id, result.TargetId);
Assert.InRange(result.D20Roll, 1, 20);
if (result.Hit)
Assert.InRange(result.DamageRolled, 1, attack.Damage.Max(isCrit: result.Crit));
else
Assert.Equal(0, result.DamageRolled);
}
[Fact]
public void AttemptAttack_Natural1_AlwaysMissesEvenIfTotalBeatsAc()
{
var (attacker, _) = MakeDuelists(seed: 1UL);
// Build a target with AC so low that any d20 + bonus beats it.
var weakTarget = WeakDummy(id: 99);
var enc = new Encounter(1UL, 1, new[] { attacker, weakTarget });
// Pick an attack with a known small bonus we can reason about.
var atk = new AttackOption { Name = "Test", ToHitBonus = 5,
Damage = new DamageRoll(1, 6, 0, DamageType.Slashing) };
// Use a deterministic forced-d20 by replaying rolls until we observe a natural 1.
// Simpler: assert across many encounters that any natural 1 is logged as miss.
for (int s = 0; s < 200; s++)
{
var enc2 = new Encounter((ulong)s, encounterId: 17, new[] { attacker, weakTarget });
var r = Resolver.AttemptAttack(enc2, attacker, weakTarget, atk);
if (r.D20Roll == 1)
Assert.False(r.Hit, $"natural 1 must miss (seed {s}, total {r.AttackTotal} vs AC {r.TargetAc})");
}
}
[Fact]
public void AttemptAttack_Natural20_AlwaysHitsAndIsCrit()
{
var attacker = WeakDummy(id: 1);
// Target with AC sky-high so only natural 20 can hit.
var fortress = StrongDummy(id: 2);
var atk = new AttackOption { Name = "Test", ToHitBonus = 0,
Damage = new DamageRoll(1, 6, 0, DamageType.Slashing) };
for (int s = 0; s < 300; s++)
{
var enc = new Encounter((ulong)s, 17, new[] { attacker, fortress });
var r = Resolver.AttemptAttack(enc, attacker, fortress, atk);
if (r.D20Roll == 20)
{
Assert.True(r.Hit, "natural 20 must always hit");
Assert.True(r.Crit, "natural 20 must register as crit");
}
}
}
[Fact]
public void AttemptAttack_DamageReducesTargetHp()
{
var (a, b) = MakeDuelists(seed: 99UL);
int startHp = b.CurrentHp;
var enc = new Encounter(99UL, 1, new[] { a, b });
// Hammer until at least one hit lands so we can compare HP delta.
for (int i = 0; i < 100; i++)
{
var r = Resolver.AttemptAttack(enc, a, b, a.AttackOptions[0]);
if (r.Hit)
{
Assert.Equal(startHp - r.DamageRolled, r.TargetHpAfter);
Assert.Equal(startHp - r.DamageRolled, b.CurrentHp);
return;
}
}
// 100 misses in a row is statistically impossible with our test combatants;
// if we ever see it the test should fail loudly to flag the regression.
Assert.Fail("Expected at least one hit in 100 attacks.");
}
[Fact]
public void AttemptAttack_LogsEntry()
{
var (a, b) = MakeDuelists(seed: 7UL);
var enc = new Encounter(7UL, 1, new[] { a, b });
int logBefore = enc.Log.Count;
Resolver.AttemptAttack(enc, a, b, a.AttackOptions[0]);
Assert.True(enc.Log.Count > logBefore);
}
[Fact]
public void Heal_ClampsToMaxHp()
{
var c = WeakDummy(id: 1);
c.CurrentHp = 5;
Resolver.Heal(c, 100);
Assert.Equal(c.MaxHp, c.CurrentHp);
}
[Fact]
public void ApplyDamage_ClampsToZero()
{
var c = WeakDummy(id: 1);
Resolver.ApplyDamage(c, c.MaxHp + 50);
Assert.Equal(0, c.CurrentHp);
Assert.True(c.IsDown);
}
[Fact]
public void MakeSave_CountsProficiencyOnlyWhenProficient()
{
var c = MakeDuelists(seed: 1UL).a;
var enc = new Encounter(1UL, 1, new[] { c });
var profSave = Resolver.MakeSave(enc, c, SaveId.STR, dc: 100, isProficient: true);
var enc2 = new Encounter(1UL, 1, new[] { c });
var nonprofSave = Resolver.MakeSave(enc2, c, SaveId.STR, dc: 100, isProficient: false);
Assert.Equal(profSave.SaveBonus, nonprofSave.SaveBonus + c.ProficiencyBonus);
}
// ── Helpers ──────────────────────────────────────────────────────────
private (Combatant a, Combatant b) MakeDuelists(ulong seed)
{
var brigand = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
var wolf = _content.Npcs.Templates.First(t => t.Id == "wolf");
var a = Combatant.FromNpcTemplate(brigand, id: 1, position: new Vec2(0, 0));
var b = Combatant.FromNpcTemplate(wolf, id: 2, position: new Vec2(1, 0));
return (a, b);
}
private Combatant WeakDummy(int id)
{
// Take a footpad and reset HP to a known small value for damage tests.
var def = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
var c = Combatant.FromNpcTemplate(def, id, new Vec2(0, 0));
return c;
}
private Combatant StrongDummy(int id)
{
var def = _content.Npcs.Templates.First(t => t.Id == "brigand_captain"); // AC 16
return Combatant.FromNpcTemplate(def, id, new Vec2(1, 0));
}
}
+78
View File
@@ -0,0 +1,78 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities.Ai;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
public sealed class BehaviorTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void Brigand_MovesTowardTargetWhenOutOfReach()
{
var brigand = MakeNpc("brigand_footpad", new Vec2(0, 0), Allegiance.Hostile);
var hero = MakeNpc("brigand_footpad", new Vec2(5, 0), Allegiance.Player);
var enc = new Encounter(0xCAFEUL, 1, new[] { brigand, hero });
// Skip past initiative and start brigand's turn explicitly.
while (enc.CurrentActor.Id != brigand.Id) enc.EndTurn();
new BrigandBehavior().TakeTurn(brigand, new AiContext(enc));
Assert.True((int)brigand.Position.X > 0); // moved toward hero
}
[Fact]
public void WildAnimal_FleesBelowQuarterHp()
{
var wolf = MakeNpc("wolf", new Vec2(5, 0), Allegiance.Hostile);
var hero = MakeNpc("wolf", new Vec2(0, 0), Allegiance.Player);
wolf.CurrentHp = 1; // well below 25% of 11
var enc = new Encounter(0xCAFEUL, 1, new[] { wolf, hero });
while (enc.CurrentActor.Id != wolf.Id) enc.EndTurn();
var startX = (int)wolf.Position.X;
new WildAnimalBehavior().TakeTurn(wolf, new AiContext(enc));
Assert.True((int)wolf.Position.X > startX, "Wounded wolf should flee away from hero (positive X)");
}
[Fact]
public void Behavior_NoTargetMakesNoMoves()
{
var lone = MakeNpc("brigand_footpad", new Vec2(0, 0), Allegiance.Hostile);
var enc = new Encounter(0xCAFEUL, 1, new[] { lone });
var startPos = lone.Position;
new BrigandBehavior().TakeTurn(lone, new AiContext(enc));
Assert.Equal(startPos.X, lone.Position.X);
Assert.Equal(startPos.Y, lone.Position.Y);
}
[Fact]
public void Behavior_AttacksWhenInReach()
{
var brigand = MakeNpc("brigand_footpad", new Vec2(0, 0), Allegiance.Hostile);
var hero = MakeNpc("brigand_footpad", new Vec2(1, 0), Allegiance.Player);
var enc = new Encounter(0xCAFEUL, 1, new[] { brigand, hero });
while (enc.CurrentActor.Id != brigand.Id) enc.EndTurn();
int logBefore = enc.Log.Count;
new BrigandBehavior().TakeTurn(brigand, new AiContext(enc));
Assert.Contains(enc.Log.Skip(logBefore), e => e.Type == CombatLogEntry.Kind.Attack);
}
[Fact]
public void Registry_UnknownIdFallsBackToBrigand()
{
var b = BehaviorRegistry.For("nonsense_behavior");
Assert.IsType<BrigandBehavior>(b);
}
private Combatant MakeNpc(string templateId, Vec2 pos, Allegiance side)
{
var t = _content.Npcs.Templates.First(x => x.Id == templateId);
var swapped = t with { DefaultAllegiance = side.ToString().ToLowerInvariant() };
return Combatant.FromNpcTemplate(swapped, id: pos.X.GetHashCode() ^ pos.Y.GetHashCode(), pos);
}
}
@@ -0,0 +1,116 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
/// <summary>
/// Phase 5 plan §5: same (worldSeed, encounterId, rollSequence) → identical
/// dice outcomes across runs. Save/load can resume mid-combat by re-creating
/// the encounter and replaying through its rollCount.
/// </summary>
public sealed class DamageDeterminismTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void EncounterSeed_IsXorOfWorldSeedRngCombatAndEncounterId()
{
var enc = new Encounter(worldSeed: 0xABCDUL, encounterId: 0x1234UL, MakeOne());
Assert.Equal(0xABCDUL ^ Theriapolis.Core.C.RNG_COMBAT ^ 0x1234UL, enc.EncounterSeed);
}
[Fact]
public void SameInputs_SameDiceSequence()
{
var a = new Encounter(0xCAFEUL, 1, MakeOne());
var b = new Encounter(0xCAFEUL, 1, MakeOne());
for (int i = 0; i < 100; i++)
Assert.Equal(a.RollD20(), b.RollD20());
}
[Fact]
public void DifferentEncounterIds_DivergeImmediately()
{
var a = new Encounter(0xCAFEUL, 1, MakeOne());
var b = new Encounter(0xCAFEUL, 2, MakeOne());
bool anyDifferent = false;
for (int i = 0; i < 20; i++)
if (a.RollD20() != b.RollD20()) { anyDifferent = true; break; }
Assert.True(anyDifferent, "Different encounter ids should produce different dice streams.");
}
[Fact]
public void ResumeRolls_SkipsForwardThroughDiceStream()
{
var a = new Encounter(0xCAFEUL, 1, MakeOne());
var b = new Encounter(0xCAFEUL, 1, MakeOne());
// Burn some rolls on `a` and capture the next 5.
for (int i = 0; i < 10; i++) a.RollD20();
int rollCountSnapshot = a.RollCount; // includes initiative rolls consumed by the ctor
int[] expected = new int[5];
for (int i = 0; i < 5; i++) expected[i] = a.RollD20();
// Resume `b` to the same total rollcount and capture the same window.
b.ResumeRolls(rollCountSnapshot);
int[] actual = new int[5];
for (int i = 0; i < 5; i++) actual[i] = b.RollD20();
Assert.Equal(expected, actual);
Assert.Equal(rollCountSnapshot + 5, b.RollCount);
}
[Fact]
public void Resolver_FullScenario_IsDeterministicAcrossRuns()
{
// Run the same scripted scenario twice and expect identical logs.
var log1 = RunScriptedScenario(seed: 0xABCDEFUL);
var log2 = RunScriptedScenario(seed: 0xABCDEFUL);
Assert.Equal(log1, log2);
}
[Fact]
public void Resolver_DifferentSeeds_ProduceDifferentLogs()
{
var log1 = RunScriptedScenario(seed: 1UL);
var log2 = RunScriptedScenario(seed: 2UL);
Assert.NotEqual(log1, log2);
}
private List<Combatant> MakeOne() => new()
{
Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "brigand_footpad"), id: 1, new Vec2(0, 0)),
};
private string RunScriptedScenario(ulong seed)
{
var brigand = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
var wolf = _content.Npcs.Templates.First(t => t.Id == "wolf");
var hero = Combatant.FromNpcTemplate(brigand with { DefaultAllegiance = "player" }, id: 1, new Vec2(0, 0));
var foe = Combatant.FromNpcTemplate(wolf, id: 2, new Vec2(1, 0));
var enc = new Encounter(seed, 1, new[] { hero, foe });
for (int round = 0; round < 10 && !enc.IsOver; round++)
{
for (int t = 0; t < enc.Participants.Count && !enc.IsOver; t++)
{
var actor = enc.CurrentActor;
if (actor.IsAlive && !actor.IsDown)
{
var target = actor.Id == hero.Id ? foe : hero;
if (target.IsAlive && !target.IsDown)
Resolver.AttemptAttack(enc, actor, target, actor.AttackOptions[0]);
}
enc.EndTurn();
}
}
var sb = new System.Text.StringBuilder();
foreach (var entry in enc.Log)
sb.AppendLine($"R{entry.Round} T{entry.Turn} [{entry.Type}] {entry.Message}");
return sb.ToString();
}
}
@@ -0,0 +1,85 @@
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
public sealed class DamageRollTests
{
[Theory]
[InlineData("1d6", 1, 6, 0)]
[InlineData("2d8+2", 2, 8, 2)]
[InlineData("1d4-1", 1, 4, -1)]
[InlineData("3d6", 3, 6, 0)]
[InlineData("d8", 1, 8, 0)]
[InlineData(" 1 d 6 + 1", 1, 6, 1)]
public void Parse_ProducesExpectedShape(string expr, int n, int sides, int mod)
{
var d = DamageRoll.Parse(expr, DamageType.Slashing);
Assert.Equal(n, d.DiceCount);
Assert.Equal(sides, d.DiceSides);
Assert.Equal(mod, d.FlatMod);
}
[Fact]
public void Parse_PureFlatNumber_HasNoDice()
{
var d = DamageRoll.Parse("5", DamageType.Bludgeoning);
Assert.Equal(0, d.DiceCount);
Assert.Equal(0, d.DiceSides);
Assert.Equal(5, d.FlatMod);
}
[Fact]
public void Parse_BadExpressionThrows()
{
Assert.Throws<System.FormatException>(() => DamageRoll.Parse("1d", DamageType.Slashing));
Assert.Throws<System.ArgumentException>(() => DamageRoll.Parse("", DamageType.Slashing));
}
[Fact]
public void Roll_Range_StaysWithinMinAndMax()
{
var rng = new SeededRng(0xCAFEUL);
var d = DamageRoll.Parse("2d6+3", DamageType.Slashing);
for (int i = 0; i < 1000; i++)
{
int v = d.Roll(sides => (int)(rng.NextUInt64() % (ulong)sides) + 1);
Assert.InRange(v, d.Min(), d.Max());
}
}
[Fact]
public void Roll_Crit_DoublesDiceButNotFlatMod()
{
var roller = new FixedRoller(new[] { 1 }); // every die rolls 1
var d = DamageRoll.Parse("2d6+3", DamageType.Slashing);
// Normal: 2 dice ⇒ 2*1 + 3 = 5
Assert.Equal(5, d.Roll(roller.Next));
// Crit: 4 dice ⇒ 4*1 + 3 = 7 (NOT 4*1 + 6 = 10 — flat mod doesn't double)
roller.Reset();
Assert.Equal(7, d.Roll(roller.Next, isCrit: true));
}
[Fact]
public void Roll_NeverNegative()
{
var d = DamageRoll.Parse("1d4-10", DamageType.Slashing);
for (int i = 0; i < 50; i++)
{
// Force the die to roll 1 (the worst case): result would be 1-10 = -9, clamped to 0.
int v = d.Roll(_ => 1);
Assert.True(v >= 0);
}
}
private sealed class FixedRoller
{
private readonly int[] _values;
private int _idx;
public FixedRoller(int[] values) { _values = values; }
public int Next(int _) { int v = _values[_idx % _values.Length]; _idx++; return v; }
public void Reset() => _idx = 0;
}
}
@@ -0,0 +1,116 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
public sealed class DeathSaveTrackerTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void ApplyDamage_PlayerDroppedToZero_InstallsDeathSaveTracker()
{
var (player, _) = MakeFight();
Resolver.ApplyDamage(player, player.MaxHp + 50);
Assert.Equal(0, player.CurrentHp);
Assert.NotNull(player.DeathSaves);
Assert.True(player.IsDown);
Assert.True(player.IsAlive); // unconscious-but-not-dead is "alive"
}
[Fact]
public void Heal_AbovZero_ResetsDeathSaves()
{
var (player, _) = MakeFight();
Resolver.ApplyDamage(player, player.MaxHp + 5);
player.DeathSaves!.Roll(MakeEnc(player), player); // 1 fail or success
Resolver.Heal(player, 5);
Assert.True(player.CurrentHp > 0);
Assert.Equal(0, player.DeathSaves.Successes);
Assert.Equal(0, player.DeathSaves.Failures);
}
[Fact]
public void ThreeFailures_MarkDead()
{
var t = new DeathSaveTracker();
// Use an encounter with a fixed seed and roll until we accumulate 3 failures
// — then assert the Dead flag is set.
var enc = MakeEncFromScratch(0x10UL); // arbitrary seed
var dummy = MakeDummyCombatant(enc);
for (int i = 0; i < 50 && !t.Dead && !t.Stabilised; i++)
t.Roll(enc, dummy);
// Across 50 rolls (and with the 3-fail / 3-success thresholds) we always
// resolve to one of the terminal states. Either is acceptable for this
// smoke test; the important thing is the loop terminates and counters
// can advance.
Assert.True(t.Dead || t.Stabilised);
}
[Fact]
public void Roll_Natural20_RevivesAtOneHp()
{
// Find a seed whose first death-save d20 is 20. Probe deterministically.
for (int s = 0; s < 200; s++)
{
var enc = MakeEncFromScratch((ulong)s);
var pc = MakeDummyCombatant(enc);
pc.CurrentHp = 0;
pc.Conditions.Add(Condition.Unconscious);
pc.DeathSaves = new DeathSaveTracker();
var outcome = pc.DeathSaves.Roll(enc, pc);
if (outcome == DeathSaveOutcome.CriticalRevive)
{
Assert.Equal(1, pc.CurrentHp);
Assert.DoesNotContain(Condition.Unconscious, pc.Conditions);
Assert.Equal(0, pc.DeathSaves.Successes);
Assert.Equal(0, pc.DeathSaves.Failures);
return;
}
}
Assert.Fail("Couldn't find a seed producing natural 20 in 200 attempts.");
}
// ── Helpers ──────────────────────────────────────────────────────────
private (Combatant pc, Combatant foe) MakeFight()
{
var clade = _content.Clades["canidae"];
var species = _content.Species["wolf"];
var classDef= _content.Classes["fangsworn"];
var bg = _content.Backgrounds["pack_raised"];
var character = new Theriapolis.Core.Rules.Character.CharacterBuilder
{
Clade = clade, Species = species, ClassDef = classDef, Background = bg,
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
}
.ChooseSkill(SkillId.Athletics)
.ChooseSkill(SkillId.Intimidation)
.Build(_content.Items);
var pc = Combatant.FromCharacter(character, 1, "PC", new Vec2(0, 0),
Theriapolis.Core.Rules.Character.Allegiance.Player);
var foe = Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "wolf"),
2, new Vec2(1, 0));
return (pc, foe);
}
private Encounter MakeEnc(Combatant pc)
{
var foe = Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "wolf"), 2, new Vec2(1, 0));
return new Encounter(0xCAFEUL, 1, new[] { pc, foe });
}
private Encounter MakeEncFromScratch(ulong seed)
{
var hero = Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "brigand_footpad"), 1, new Vec2(0, 0));
var foe = Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "wolf"), 2, new Vec2(1, 0));
return new Encounter(seed, 1, new[] { hero, foe });
}
private Combatant MakeDummyCombatant(Encounter enc)
=> enc.Participants[0];
}
@@ -0,0 +1,73 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
public sealed class EncounterTriggerTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void FindHostileTrigger_ReturnsNullWhenNoHostiles()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(100, 100));
Assert.Null(EncounterTrigger.FindHostileTrigger(mgr));
}
[Fact]
public void FindHostileTrigger_ReturnsNearbyHostile()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(100, 100));
var wolf = mgr.SpawnNpc(_content.Npcs.Templates.First(t => t.Id == "wolf"),
new Vec2(105, 100));
var hit = EncounterTrigger.FindHostileTrigger(mgr);
Assert.Same(wolf, hit);
}
[Fact]
public void FindHostileTrigger_IgnoresHostilesPastTriggerRadius()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(100, 100));
mgr.SpawnNpc(_content.Npcs.Templates.First(t => t.Id == "wolf"),
new Vec2(100 + C.ENCOUNTER_TRIGGER_TILES + 5, 100));
Assert.Null(EncounterTrigger.FindHostileTrigger(mgr));
}
[Fact]
public void FindHostileTrigger_IgnoresFriendlyAndNeutral()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(100, 100));
var merchant = _content.Npcs.Templates.First(t => t.Id == "merchant_traveler");
mgr.SpawnNpc(merchant, new Vec2(102, 100));
Assert.Null(EncounterTrigger.FindHostileTrigger(mgr));
}
[Fact]
public void FindInteractCandidate_FindsFriendlyOrNeutralInRange()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(100, 100));
var merchant = _content.Npcs.Templates.First(t => t.Id == "merchant_traveler");
var npc = mgr.SpawnNpc(merchant, new Vec2(101, 100));
var hit = EncounterTrigger.FindInteractCandidate(mgr);
Assert.Same(npc, hit);
}
[Fact]
public void FindInteractCandidate_IgnoresHostiles()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(100, 100));
mgr.SpawnNpc(_content.Npcs.Templates.First(t => t.Id == "wolf"), new Vec2(101, 100));
Assert.Null(EncounterTrigger.FindInteractCandidate(mgr));
}
}
@@ -0,0 +1,179 @@
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 });
}
}
@@ -0,0 +1,140 @@
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 M4 — Medical Incompatibility scales healing received by a
/// hybrid PC at 75% (round down, min 1). Verified end-to-end via the
/// healer features wired in M1.
/// </summary>
public sealed class HybridMedicalIncompatibilityTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void FieldRepair_OnHybridTarget_ScalesAtSeventyFivePercent()
{
var enc = MakeEncounter(out var healer, out var ally,
healerClass: "claw_wright", isAllyHybrid: true);
ally.CurrentHp = 5;
int beforeHp = ally.CurrentHp;
FeatureProcessor.TryFieldRepair(enc, healer, ally);
// 1d8 + INT mod (claw_wright kit gives INT 14 → +2 mod) → range 310.
// After 0.75 scale: range 27 (rounded down, min 1).
int gained = ally.CurrentHp - beforeHp;
Assert.True(gained >= 2, $"hybrid should still gain at least 2 HP after scaling; got {gained}");
Assert.True(gained <= 7, $"hybrid heal should be 75% of raw range; got {gained}");
}
[Fact]
public void FieldRepair_OnPurebredTarget_DoesNotScale()
{
var enc = MakeEncounter(out var healer, out var ally,
healerClass: "claw_wright", isAllyHybrid: false);
ally.CurrentHp = 5;
int beforeHp = ally.CurrentHp;
FeatureProcessor.TryFieldRepair(enc, healer, ally);
int gained = ally.CurrentHp - beforeHp;
// Purebred: full 1d8 + INT 2 → range 310.
Assert.True(gained >= 3, $"purebred should gain full heal; got {gained}");
}
[Fact]
public void LayOnPaws_OnHybridTarget_ScalesDeliveredHpButNotPoolCost()
{
var enc = MakeEncounter(out var healer, out var ally,
healerClass: "covenant_keeper", isAllyHybrid: true);
FeatureProcessor.EnsureLayOnPawsPoolReady(healer.SourceCharacter!);
int poolBefore = healer.SourceCharacter!.LayOnPawsPoolRemaining;
ally.CurrentHp = ally.MaxHp - 4;
FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 4);
// Pool cost is the requested 4 (the inefficiency models the body
// resisting calibration, not the healer wasting effort).
Assert.Equal(poolBefore - 4, healer.SourceCharacter.LayOnPawsPoolRemaining);
// But ally only receives 3 HP (4 * 0.75 = 3, floor).
int gained = ally.CurrentHp - (ally.MaxHp - 4);
Assert.Equal(3, gained);
}
[Fact]
public void LayOnPaws_OnPurebredTarget_DeliversFullHp()
{
var enc = MakeEncounter(out var healer, out var ally,
healerClass: "covenant_keeper", isAllyHybrid: false);
FeatureProcessor.EnsureLayOnPawsPoolReady(healer.SourceCharacter!);
ally.CurrentHp = ally.MaxHp - 4;
FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 4);
Assert.Equal(ally.MaxHp, ally.CurrentHp);
}
// ── Helpers ───────────────────────────────────────────────────────────
private Encounter MakeEncounter(
out Combatant healer, out Combatant ally,
string healerClass, bool isAllyHybrid)
{
var hc = MakeChar(healerClass, new AbilityScores(10, 12, 13, 14, 12, 14));
var ac = isAllyHybrid
? MakeHybrid()
: MakeChar("fangsworn", 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", new Vec2(1, 0), Allegiance.Allied);
return new Encounter(0xCAFEUL, 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);
}
private Theriapolis.Core.Rules.Character.Character MakeHybrid()
{
var b = new CharacterBuilder
{
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
IsHybridOrigin = true,
HybridSireClade = _content.Clades["canidae"],
HybridSireSpecies = _content.Species["wolf"],
HybridDamClade = _content.Clades["leporidae"],
HybridDamSpecies = _content.Species["rabbit"],
HybridDominantParent = ParentLineage.Sire,
};
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 { }
}
bool ok = b.TryBuildHybrid(_content.Items, out var c, out string err);
Assert.True(ok, err);
return c!;
}
}
@@ -0,0 +1,83 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
public sealed class InitiativeTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void NewEncounter_AppendsInitiativeLogEntry()
{
var combatants = MakeThree();
var enc = new Encounter(0xCAFEUL, 1, combatants);
Assert.Contains(enc.Log, l => l.Type == CombatLogEntry.Kind.Initiative);
}
[Fact]
public void InitiativeOrder_ContainsEveryCombatantExactlyOnce()
{
var combatants = MakeThree();
var enc = new Encounter(0xCAFEUL, 1, combatants);
Assert.Equal(combatants.Count, enc.InitiativeOrder.Count);
Assert.Equal(combatants.Count, enc.InitiativeOrder.Distinct().Count());
foreach (int idx in enc.InitiativeOrder)
Assert.InRange(idx, 0, combatants.Count - 1);
}
[Fact]
public void EndTurn_AdvancesToNextLivingCombatant()
{
var combatants = MakeThree();
var enc = new Encounter(0xCAFEUL, 1, combatants);
var first = enc.CurrentActor;
enc.EndTurn();
Assert.NotEqual(first.Id, enc.CurrentActor.Id);
}
[Fact]
public void EndTurn_WrappingIncrementsRoundCounter()
{
var combatants = MakeThree();
var enc = new Encounter(0xCAFEUL, 1, combatants);
Assert.Equal(1, enc.RoundNumber);
// Advance N turns to wrap once.
for (int i = 0; i < combatants.Count; i++) enc.EndTurn();
Assert.Equal(2, enc.RoundNumber);
}
[Fact]
public void CheckForVictory_EndsWhenOnlyOneSideRemains()
{
var brigand = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
var wolf = _content.Npcs.Templates.First(t => t.Id == "wolf");
var hero = Combatant.FromNpcTemplate(brigand, id: 1, new Vec2(0, 0));
var foe = Combatant.FromNpcTemplate(wolf, id: 2, new Vec2(1, 0));
// Force opposite allegiances. Brigand defaults Hostile; rebuild "hero" as Player-side.
hero = Combatant.FromNpcTemplate(brigand with { DefaultAllegiance = "player" }, id: 1, new Vec2(0, 0));
var enc = new Encounter(1UL, 1, new[] { hero, foe });
// Knock out the foe.
Resolver.ApplyDamage(foe, foe.MaxHp);
Assert.True(enc.CheckForVictory());
Assert.True(enc.IsOver);
Assert.Contains(enc.Log, l => l.Type == CombatLogEntry.Kind.EncounterEnd);
}
private List<Combatant> MakeThree()
{
var brigand = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
var wolf = _content.Npcs.Templates.First(t => t.Id == "wolf");
var captain = _content.Npcs.Templates.First(t => t.Id == "brigand_captain");
// Mix allegiances so CheckForVictory doesn't end the encounter immediately.
return new List<Combatant>
{
Combatant.FromNpcTemplate(brigand with { DefaultAllegiance = "player" }, id: 1, new Vec2(0, 0)),
Combatant.FromNpcTemplate(wolf, id: 2, new Vec2(2, 0)),
Combatant.FromNpcTemplate(captain, id: 3, new Vec2(4, 0)),
};
}
}
@@ -0,0 +1,328 @@
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);
}
}
@@ -0,0 +1,298 @@
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);
}
}
@@ -0,0 +1,325 @@
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 M3 — ability-stream features that scale per level:
/// - Scent-Broker Pheromone Craft (L2/L5/L9/L13 ladder)
/// - Covenant-Keeper Covenant's Authority (L2/L9/L13/L17 ladder)
/// - Muzzle-Speaker Vocalization Dice (level ladder verified end-to-end)
/// Plus the cross-cutting Frightened-disadvantage hookup the resolver needs
/// for Pheromone Fear to actually do anything.
/// </summary>
public sealed class Phase65M3FeatureTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Pheromone Craft ───────────────────────────────────────────────────
[Theory]
[InlineData(1, 0)] // pre-L2: no uses
[InlineData(2, 2)] // L2: pheromone_craft_2
[InlineData(4, 2)] // L4: still 2
[InlineData(5, 3)] // L5: pheromone_craft_3
[InlineData(8, 3)] // L8: still 3
[InlineData(9, 4)] // L9: pheromone_craft_4
[InlineData(13, 5)] // L13: pheromone_craft_5
[InlineData(20, 5)] // L20: capstone, still 5
public void PheromoneUsesAtLevel_FollowsJsonLadder(int level, int expected)
{
Assert.Equal(expected, FeatureProcessor.PheromoneUsesAtLevel(level));
}
[Fact]
public void EnsurePheromoneUsesReady_ToppedUpForScentBroker()
{
var c = MakeChar("scent_broker", new AbilityScores(8, 12, 13, 14, 16, 12));
c.Level = 5;
c.PheromoneUsesRemaining = 0;
FeatureProcessor.EnsurePheromoneUsesReady(c);
Assert.Equal(3, c.PheromoneUsesRemaining);
}
[Fact]
public void EnsurePheromoneUsesReady_NoOpForOtherClasses()
{
var c = MakeChar("fangsworn", new AbilityScores(15, 12, 13, 10, 10, 8));
c.PheromoneUsesRemaining = 0;
FeatureProcessor.EnsurePheromoneUsesReady(c);
Assert.Equal(0, c.PheromoneUsesRemaining);
}
[Fact]
public void TryEmitPheromone_AppliesFrightenedToHostilesInRange_OnFailedSave()
{
var enc = MakeEncounter(out var pc, out var hostile,
pcClass: "scent_broker", pcLevel: 5,
hostileCon: 1, // -5 mod → guaranteed fail
hostilePos: new Vec2(1, 0)); // adjacent → in 10ft cloud
bool ok = FeatureProcessor.TryEmitPheromone(enc, pc, PheromoneType.Fear);
Assert.True(ok);
Assert.Contains(Condition.Frightened, hostile.Conditions);
}
[Fact]
public void TryEmitPheromone_DoesNotAffectHostilesOutOfRange()
{
var enc = MakeEncounter(out var pc, out var hostile,
pcClass: "scent_broker", pcLevel: 5,
hostileCon: 1,
hostilePos: new Vec2(10, 0)); // far outside 10ft cloud
FeatureProcessor.TryEmitPheromone(enc, pc, PheromoneType.Fear);
Assert.DoesNotContain(Condition.Frightened, hostile.Conditions);
}
[Fact]
public void TryEmitPheromone_RefusesPreL2()
{
var enc = MakeEncounter(out var pc, out _,
pcClass: "scent_broker", pcLevel: 1);
bool ok = FeatureProcessor.TryEmitPheromone(enc, pc, PheromoneType.Fear);
Assert.False(ok);
}
[Fact]
public void TryEmitPheromone_ConsumesUse()
{
var enc = MakeEncounter(out var pc, out _,
pcClass: "scent_broker", pcLevel: 5);
int before = pc.SourceCharacter!.PheromoneUsesRemaining;
FeatureProcessor.TryEmitPheromone(enc, pc, PheromoneType.Fear);
Assert.Equal(before - 1, pc.SourceCharacter.PheromoneUsesRemaining);
}
[Fact]
public void TryEmitPheromone_RefusesWhenExhausted()
{
var enc = MakeEncounter(out var pc, out _,
pcClass: "scent_broker", pcLevel: 5);
pc.SourceCharacter!.PheromoneUsesRemaining = 0;
bool ok = FeatureProcessor.TryEmitPheromone(enc, pc, PheromoneType.Fear);
Assert.False(ok);
}
[Fact]
public void TryEmitPheromone_DoesNotAffectAllies()
{
var enc = MakeEncounter(out var pc, out _,
pcClass: "scent_broker", pcLevel: 5,
hostileCon: 1,
includeAlly: true,
allyPos: new Vec2(1, 0)); // ally in radius
FeatureProcessor.TryEmitPheromone(enc, pc, PheromoneType.Fear);
var ally = enc.Participants.Single(p =>
p.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
Assert.DoesNotContain(Condition.Frightened, ally.Conditions);
}
[Theory]
[InlineData(PheromoneType.Fear, Condition.Frightened)]
[InlineData(PheromoneType.Calm, Condition.Charmed)]
[InlineData(PheromoneType.Arousal, Condition.Dazed)]
[InlineData(PheromoneType.Nausea, Condition.Poisoned)]
public void Pheromone_AppliesMappedCondition(PheromoneType type, Condition expected)
{
var enc = MakeEncounter(out var pc, out var hostile,
pcClass: "scent_broker", pcLevel: 5,
hostileCon: 1,
hostilePos: new Vec2(1, 0));
FeatureProcessor.TryEmitPheromone(enc, pc, type);
Assert.Contains(expected, hostile.Conditions);
}
// ── Frightened disadvantage in Resolver ──────────────────────────────
[Fact]
public void Resolver_FrightenedAttacker_RollsDisadvantage()
{
// Build attacker with Frightened condition, target far enough that
// we exercise the d20 path. We can't deterministically observe
// disadvantage from a single roll, but RollD20WithMode uses two
// d20s under disadvantage and keeps the lower — so over 100 rolls
// we should see a clear bias toward lower kept values.
var enc = MakeEncounter(out var attacker, out var target,
pcClass: "fangsworn", pcLevel: 5);
attacker.Conditions.Add(Condition.Frightened);
var attack = attacker.AttackOptions[0];
// The Frightened path goes through `situation |= Disadvantage` in
// the resolver. Easiest behavioural check: the attack rolls happen
// and don't throw; rolled d20 is in [1,20]. Determinism is verified
// elsewhere (DamageDeterminismTests). Smoke test only here.
for (int i = 0; i < 5; i++)
Resolver.AttemptAttack(enc, attacker, target, attack);
Assert.True(true); // no crash → wiring ok
}
// ── Covenant Authority ───────────────────────────────────────────────
[Theory]
[InlineData(1, 0)]
[InlineData(2, 2)]
[InlineData(8, 2)]
[InlineData(9, 3)]
[InlineData(12, 3)]
[InlineData(13, 4)]
[InlineData(17, 5)]
[InlineData(20, 5)]
public void CovenantAuthorityUsesAtLevel_FollowsLadder(int level, int expected)
{
Assert.Equal(expected, FeatureProcessor.CovenantAuthorityUsesAtLevel(level));
}
[Fact]
public void TryDeclareOath_MarksTargetAndConsumesUse()
{
var enc = MakeEncounter(out var pc, out var hostile,
pcClass: "covenant_keeper", pcLevel: 5);
int before = pc.SourceCharacter!.CovenantAuthorityUsesRemaining;
bool ok = FeatureProcessor.TryDeclareOath(enc, pc, hostile);
Assert.True(ok);
Assert.Equal(pc.Id, hostile.OathMarkBy);
Assert.True(hostile.OathMarkRound.HasValue);
Assert.Equal(before - 1, pc.SourceCharacter.CovenantAuthorityUsesRemaining);
}
[Fact]
public void TryDeclareOath_RefusesPreL2()
{
var enc = MakeEncounter(out var pc, out var hostile,
pcClass: "covenant_keeper", pcLevel: 1);
bool ok = FeatureProcessor.TryDeclareOath(enc, pc, hostile);
Assert.False(ok);
}
[Fact]
public void TryDeclareOath_RefusesSelfTarget()
{
var enc = MakeEncounter(out var pc, out _,
pcClass: "covenant_keeper", pcLevel: 5);
bool ok = FeatureProcessor.TryDeclareOath(enc, pc, pc);
Assert.False(ok);
}
[Fact]
public void OathAttackPenalty_AppliesToMarkerOnly()
{
var enc = MakeEncounter(out var pc, out var hostile,
pcClass: "covenant_keeper", pcLevel: 5,
includeAlly: true,
allyPos: new Vec2(2, 0));
FeatureProcessor.TryDeclareOath(enc, pc, hostile);
var ally = enc.Participants.Single(p =>
p.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
// Hostile attacking the marker (pc) → -2 penalty.
Assert.Equal(-2, FeatureProcessor.OathAttackPenalty(enc, hostile, pc));
// Hostile attacking the ally → no penalty (oath is target-specific).
Assert.Equal(0, FeatureProcessor.OathAttackPenalty(enc, hostile, ally));
}
[Fact]
public void OathAttackPenalty_ZeroForUnmarkedAttacker()
{
var enc = MakeEncounter(out var pc, out var hostile,
pcClass: "covenant_keeper", pcLevel: 5);
Assert.Equal(0, FeatureProcessor.OathAttackPenalty(enc, hostile, pc));
}
[Fact]
public void OathAttackPenalty_ExpiresAfterTenRounds()
{
var enc = MakeEncounter(out var pc, out var hostile,
pcClass: "covenant_keeper", pcLevel: 5);
FeatureProcessor.TryDeclareOath(enc, pc, hostile);
// Force the encounter forward 11 rounds (we hand-set RoundNumber via
// EndTurn, but easier: directly read OathAttackPenalty after we
// shift the round forward via end-turn loop — too elaborate. Instead,
// mock by mutating the mark round backward.
hostile.OathMarkRound = enc.RoundNumber - 10; // expired
Assert.Equal(0, FeatureProcessor.OathAttackPenalty(enc, hostile, pc));
// And the expiry sweep clears the fields.
Assert.Null(hostile.OathMarkRound);
Assert.Null(hostile.OathMarkBy);
}
// ── Vocalization Dice scaling end-to-end ─────────────────────────────
[Theory]
[InlineData(1, 6)]
[InlineData(4, 6)]
[InlineData(5, 8)]
[InlineData(9, 10)]
[InlineData(15, 12)]
public void VocalizationDie_GrantsMatchedSidesAtLevel(int level, int expectedSides)
{
var enc = MakeEncounter(out var caster, out _,
pcClass: "muzzle_speaker", pcLevel: level,
includeAlly: true, allyPos: new Vec2(1, 0));
var ally = enc.Participants.Single(p =>
p.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, ally);
Assert.True(ok);
Assert.Equal(expectedSides, ally.InspirationDieSides);
}
// ── Helpers ───────────────────────────────────────────────────────────
private Encounter MakeEncounter(
out Combatant pc, out Combatant hostile,
string pcClass, int pcLevel = 1,
int hostileCon = 10,
Vec2? hostilePos = null,
bool includeAlly = false,
Vec2? allyPos = null)
{
var pcChar = MakeChar(pcClass, new AbilityScores(10, 12, 13, 14, 16, 14));
pcChar.Level = pcLevel;
FeatureProcessor.EnsurePheromoneUsesReady(pcChar);
FeatureProcessor.EnsureCovenantAuthorityReady(pcChar);
FeatureProcessor.EnsureVocalizationDiceReady(pcChar);
pc = Combatant.FromCharacter(pcChar, 1, "PC", new Vec2(0, 0),
Theriapolis.Core.Rules.Character.Allegiance.Player);
var hostileChar = MakeChar("fangsworn", new AbilityScores(13, 12, hostileCon, 10, 10, 8));
hostile = Combatant.FromCharacter(hostileChar, 2, "Hostile",
hostilePos ?? 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(0xFEEDUL, 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);
}
}
@@ -0,0 +1,246 @@
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 7 M0 — wires four more L3 subclass features (Phase 6.5 carryover):
/// - Antler-Guard "Retaliatory Strike": melee hit on a Sentinel-Stance
/// antler-guard returns 1d8 + CON to the attacker.
/// - Stampede-Heart "Trampling Charge": +1d8 bludgeoning on the first
/// melee attack of each turn while raging.
/// - Ambush-Artist "Opening Strike": +2d6 on the first melee attack of
/// round 1 of an encounter.
/// - Body-Wright "Combat Medic": Field Repair rolls 2d8 + INT (vs the
/// base 1d8 + INT).
///
/// Combined with the four Phase-6.5 wirings (Lone Fang, Herd-Wall,
/// Pack-Forged, Blood Memory), this brings 8 of 16 L3 subclass features
/// to live runtime. The remaining 8 are scaffolded but unwired and land
/// in Phase 7 M1.
/// </summary>
public sealed class Phase7M0SubclassFeatureTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Antler-Guard Retaliatory Strike ──────────────────────────────────
[Fact]
public void AntlerGuard_RetaliatoryStrike_ReturnsDamageInSentinelStance()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "bulwark", pcSubclass: "antler_guard");
pc.SentinelStanceActive = true;
int hostileHpBefore = hostile.CurrentHp;
var attack = pc.AttackOptions[0];
int dealt = FeatureProcessor.OnAntlerGuardHit(enc, hostile, pc, attack);
Assert.True(dealt >= 1, "retaliatory strike must deal at least 1 damage");
Assert.Equal(hostileHpBefore - dealt, hostile.CurrentHp);
}
[Fact]
public void AntlerGuard_RetaliatoryStrike_DoesNotFireWithoutSentinelStance()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "bulwark", pcSubclass: "antler_guard");
// SentinelStanceActive is false by default.
int hostileHpBefore = hostile.CurrentHp;
int dealt = FeatureProcessor.OnAntlerGuardHit(enc, hostile, pc, pc.AttackOptions[0]);
Assert.Equal(0, dealt);
Assert.Equal(hostileHpBefore, hostile.CurrentHp);
}
[Fact]
public void AntlerGuard_RetaliatoryStrike_DoesNotFireOnRangedHit()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "bulwark", pcSubclass: "antler_guard");
pc.SentinelStanceActive = true;
int hostileHpBefore = hostile.CurrentHp;
var rangedAttack = MakeRangedAttack();
int dealt = FeatureProcessor.OnAntlerGuardHit(enc, hostile, pc, rangedAttack);
Assert.Equal(0, dealt);
Assert.Equal(hostileHpBefore, hostile.CurrentHp);
}
[Fact]
public void AntlerGuard_RetaliatoryStrike_NoFire_IfNotAntlerGuard()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "bulwark", pcSubclass: "herd_wall");
pc.SentinelStanceActive = true;
int dealt = FeatureProcessor.OnAntlerGuardHit(enc, hostile, pc, pc.AttackOptions[0]);
Assert.Equal(0, dealt);
}
// ── Stampede-Heart Trampling Charge ──────────────────────────────────
[Fact]
public void StampedeHeart_TramplingCharge_AddsDamage_FirstMeleeWhileRaging()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "feral", pcSubclass: "stampede_heart");
pc.RageActive = true;
int bonus = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, pc.AttackOptions[0], isCrit: false);
// Bonus = +2 (Rage) + 1d8 (Trampling Charge). Min 2 + 1 = 3, max 2 + 8 = 10.
Assert.True(bonus >= 3 && bonus <= 10, $"expected [3..10], got {bonus}");
Assert.True(pc.TramplingChargeUsedThisTurn);
}
[Fact]
public void StampedeHeart_TramplingCharge_OnlyFiresOncePerTurn()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "feral", pcSubclass: "stampede_heart");
pc.RageActive = true;
var attack = pc.AttackOptions[0];
int first = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, attack, isCrit: false);
int second = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, attack, isCrit: false);
// First: rage (+2) + trampling (+1d8). Second: rage only (+2).
Assert.True(first > second);
Assert.Equal(2, second);
}
[Fact]
public void StampedeHeart_TramplingCharge_DoesNotFireWithoutRage()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "feral", pcSubclass: "stampede_heart");
// RageActive false by default.
int bonus = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, pc.AttackOptions[0], isCrit: false);
Assert.Equal(0, bonus);
}
// ── Ambush-Artist Opening Strike ─────────────────────────────────────
[Fact]
public void AmbushArtist_OpeningStrike_AddsDamageInRoundOne()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "shadow_pelt", pcSubclass: "ambush_artist");
Assert.Equal(1, enc.RoundNumber);
int bonus = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, pc.AttackOptions[0], isCrit: false);
// Sneak attack 1d6 + Opening Strike 2d6 = 3d6. Range [3..18].
Assert.True(bonus >= 3 && bonus <= 18, $"expected [3..18], got {bonus}");
Assert.True(pc.OpeningStrikeUsed);
}
[Fact]
public void AmbushArtist_OpeningStrike_OnlyFiresOncePerEncounter()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "shadow_pelt", pcSubclass: "ambush_artist");
var attack = pc.AttackOptions[0];
int first = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, attack, isCrit: false);
// Reset sneak-attack flag for cross-turn re-fire test.
pc.SneakAttackUsedThisTurn = false;
int second = FeatureProcessor.ApplyDamageBonus(enc, pc, hostile, attack, isCrit: false);
// First fires opening strike (3d6 = [3..18]); second only sneak attack (1d6 = [1..6]).
Assert.True(first > second);
Assert.True(second >= 1 && second <= 6, $"second attack should only be sneak attack [1..6], got {second}");
}
// ── Body-Wright Combat Medic ─────────────────────────────────────────
[Fact]
public void BodyWright_FieldRepair_RollsTwoD8()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "claw_wright", pcSubclass: "body_wright");
// Establish the per-encounter pool the way PlayScreen would.
FeatureProcessor.EnsureFieldRepairReady(pc.SourceCharacter!);
// Damage the PC so the heal has somewhere to go.
pc.CurrentHp = 1;
bool ok = FeatureProcessor.TryFieldRepair(enc, pc, pc);
Assert.True(ok);
// Body-Wright heals 2d8 + INT — minimum 2 + INT, max 16 + INT. Since
// healing clamps to MaxHp we just assert the heal exceeded the
// 1d8 + INT base ceiling (8 + INT) at least *sometimes*. To make
// this deterministic per our seed, assert HP gained ≥ 2 (the 2d8 floor).
Assert.True(pc.CurrentHp >= 3,
$"Body-Wright Combat Medic should heal ≥ 2 HP from a 2d8 roll (was at 1, now {pc.CurrentHp})");
}
[Fact]
public void NonBodyWright_FieldRepair_RollsOneD8()
{
var enc = MakeDuel(out var pc, out var hostile,
pcClass: "claw_wright", pcSubclass: null);
FeatureProcessor.EnsureFieldRepairReady(pc.SourceCharacter!);
pc.CurrentHp = 1;
bool ok = FeatureProcessor.TryFieldRepair(enc, pc, pc);
Assert.True(ok);
// Non-body-wright: 1d8 + INT = [1+INT..8+INT], capped to MaxHp.
// Just assert heal ≤ 8 + INT (we don't care about INT exactly here).
int intMod = pc.SourceCharacter!.Abilities.ModFor(AbilityId.INT);
Assert.True(pc.CurrentHp <= 1 + 8 + intMod,
$"Field Repair w/o Body-Wright caps at 1d8+INT = {1 + 8 + intMod}, got {pc.CurrentHp}");
}
// ── Helpers ──────────────────────────────────────────────────────────
private Encounter MakeDuel(out Combatant pc, out Combatant hostile,
string pcClass, string? pcSubclass)
{
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);
return new Encounter(0xCAFEUL, 1, new List<Combatant> { pc, hostile });
}
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);
}
private static AttackOption MakeRangedAttack() =>
new AttackOption
{
Name = "ranged-test",
Damage = new DamageRoll(1, 6, 0, DamageType.Piercing),
ToHitBonus = 0,
// Setting RangeShortTiles>0 flips IsRanged true via the derived prop.
RangeShortTiles = 6,
RangeLongTiles = 18,
};
}
@@ -0,0 +1,99 @@
using Theriapolis.Core.Data;
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 ReachAndCoverTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void EdgeToEdge_Adjacent_ReturnsZero()
{
var a = MakeMediumNpc(new Vec2(5, 5));
var b = MakeMediumNpc(new Vec2(6, 5));
Assert.Equal(0, ReachAndCover.EdgeToEdgeChebyshev(a, b));
}
[Fact]
public void EdgeToEdge_OneTileApart_ReturnsOne()
{
var a = MakeMediumNpc(new Vec2(5, 5));
var b = MakeMediumNpc(new Vec2(7, 5)); // 1 empty tile between
Assert.Equal(1, ReachAndCover.EdgeToEdgeChebyshev(a, b));
}
[Fact]
public void EdgeToEdge_LargeAttacker_FootprintCountedCorrectly()
{
var large = MakeLargeNpc(new Vec2(0, 0)); // occupies (0..1, 0..1)
var medium = MakeMediumNpc(new Vec2(3, 0)); // 1 empty tile between (large's right edge = 1, medium = 3)
Assert.Equal(1, ReachAndCover.EdgeToEdgeChebyshev(large, medium));
}
[Fact]
public void IsInReach_MeleeAdjacent_True()
{
var a = MakeMediumNpc(new Vec2(0, 0));
var b = MakeMediumNpc(new Vec2(1, 0));
var attack = new AttackOption { Name = "Bite", Damage = new DamageRoll(1, 4, 0, DamageType.Piercing), ReachTiles = 1 };
Assert.True(ReachAndCover.IsInReach(a, b, attack));
}
[Fact]
public void IsInReach_MeleeOutOfReach_False()
{
var a = MakeMediumNpc(new Vec2(0, 0));
var b = MakeMediumNpc(new Vec2(3, 0));
var attack = new AttackOption { Name = "Bite", Damage = new DamageRoll(1, 4, 0, DamageType.Piercing), ReachTiles = 1 };
Assert.False(ReachAndCover.IsInReach(a, b, attack));
}
[Fact]
public void IsInReach_RangedShortRange_True()
{
var a = MakeMediumNpc(new Vec2(0, 0));
var b = MakeMediumNpc(new Vec2(8, 0));
var bow = new AttackOption { Name = "Bow", Damage = new DamageRoll(1, 6, 0, DamageType.Piercing),
RangeShortTiles = 16, RangeLongTiles = 64 };
Assert.True(ReachAndCover.IsInReach(a, b, bow));
Assert.False(ReachAndCover.IsLongRange(a, b, bow));
}
[Fact]
public void IsInReach_RangedLongRange_TrueWithLongRangeFlag()
{
var a = MakeMediumNpc(new Vec2(0, 0));
var b = MakeMediumNpc(new Vec2(40, 0));
var bow = new AttackOption { Name = "Bow", Damage = new DamageRoll(1, 6, 0, DamageType.Piercing),
RangeShortTiles = 16, RangeLongTiles = 64 };
Assert.True(ReachAndCover.IsInReach(a, b, bow));
Assert.True(ReachAndCover.IsLongRange(a, b, bow));
}
[Fact]
public void StepToward_MovesOneTileTowardGoal()
{
var step = ReachAndCover.StepToward(new Vec2(0, 0), new Vec2(5, 3));
Assert.Equal(1, step.X);
Assert.Equal(1, step.Y);
}
[Fact]
public void StepToward_AtGoal_ReturnsSamePosition()
{
var step = ReachAndCover.StepToward(new Vec2(5, 5), new Vec2(5, 5));
Assert.Equal(5, step.X);
Assert.Equal(5, step.Y);
}
private Combatant MakeMediumNpc(Vec2 pos)
=> Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "wolf"), id: 1, pos);
private Combatant MakeLargeNpc(Vec2 pos)
=> Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "bear_brown"), id: 2, pos);
}
+192
View File
@@ -0,0 +1,192 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Data;
/// <summary>
/// End-to-end content load + cross-file integrity tests for Phase 5 JSON.
/// If a JSON edit breaks any of these, ContentValidate will also fail in CI.
/// </summary>
public sealed class ContentLoadTests
{
private static ContentLoader Loader() => new(TestHelpers.DataDirectory);
[Fact]
public void AllPhase5ContentFiles_LoadCleanly()
{
var loader = Loader();
var clades = loader.LoadClades();
var species = loader.LoadSpecies(clades);
var classes = loader.LoadClasses();
var subs = loader.LoadSubclasses(classes);
var bgs = loader.LoadBackgrounds();
var items = loader.LoadItems();
var npcs = loader.LoadNpcTemplates(items);
Assert.Equal(7, clades.Length);
Assert.True(species.Length >= 19, $"expected ≥19 species, got {species.Length}");
Assert.Equal(8, classes.Length);
Assert.Equal(16, subs.Length); // 8 classes × 2 subclasses
Assert.Equal(12, bgs.Length);
Assert.True(items.Length >= 30, $"expected ≥30 items, got {items.Length}");
Assert.True(npcs.Templates.Length >= 9, $"expected ≥9 NPC templates, got {npcs.Templates.Length}");
}
[Fact]
public void EveryClass_HasLevel1FeaturesDefined()
{
var classes = Loader().LoadClasses();
foreach (var c in classes)
{
var lv1 = Array.Find(c.LevelTable, e => e.Level == 1);
Assert.NotNull(lv1);
Assert.NotEmpty(lv1!.Features);
foreach (var feat in lv1.Features)
Assert.True(c.FeatureDefinitions.ContainsKey(feat),
$"Class '{c.Id}' level 1 references undefined feature '{feat}'");
}
}
[Fact]
public void EveryClass_HasFullLevelTable()
{
var classes = Loader().LoadClasses();
foreach (var c in classes)
{
var levels = c.LevelTable.Select(e => e.Level).OrderBy(x => x).ToArray();
Assert.Equal(20, levels.Length);
for (int lv = 1; lv <= 20; lv++)
Assert.Contains(lv, levels);
}
}
[Fact]
public void EveryClass_LevelTableProficiencyBonusMatchesD20()
{
var classes = Loader().LoadClasses();
foreach (var c in classes)
foreach (var entry in c.LevelTable)
Assert.Equal(ProficiencyBonus.ForLevel(entry.Level), entry.ProficiencyBonus);
}
[Fact]
public void EveryClass_HasTwoSubclasses()
{
var classes = Loader().LoadClasses();
var subs = Loader().LoadSubclasses(classes);
foreach (var c in classes)
{
var matching = subs.Where(s => s.ClassId == c.Id).ToArray();
Assert.Equal(2, matching.Length);
// Subclass ids must match what the class declares
foreach (var sid in c.SubclassIds)
Assert.Contains(sid, matching.Select(s => s.Id));
}
}
[Fact]
public void EverySpecies_ReferencesARealClade()
{
var clades = Loader().LoadClades();
var species = Loader().LoadSpecies(clades);
var cladeIds = clades.Select(c => c.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var sp in species)
Assert.Contains(sp.CladeId, cladeIds);
}
[Theory]
[InlineData("wolf", 1, 0, 1, 0, 1, 0)]
[InlineData("fox", 0, 1, 1, 0, 1, 0)]
[InlineData("coyote", 0, 0, 1, 0, 1, 1)]
[InlineData("lion", 1, 1, 0, 0, 0, 1)]
[InlineData("leopard", 0, 2, 0, 0, 0, 1)]
[InlineData("housecat", 0, 1, 0, 1, 0, 1)]
[InlineData("ferret", 0, 1, 0, 1, 0, 1)]
[InlineData("badger", 0, 1, 1, 1, 0, 0)]
[InlineData("wolverine", 1, 1, 0, 1, 0, 0)]
[InlineData("brown_bear", 1,-1, 2, 0, 0, 0)]
[InlineData("polar_bear", 0,-1, 2, 0, 1, 0)]
[InlineData("elk", 1, 1, 0, 0, 1, 0)]
[InlineData("deer", 0, 2, 0, 0, 1, 0)]
[InlineData("moose", 0, 1, 1, 0, 1, 0)]
[InlineData("rabbit", -1, 2, 0, 0, 1, 0)]
[InlineData("hare", -1, 2, 1, 0, 0, 0)]
[InlineData("bull", 2, 0, 1, 0, 0, 0)]
[InlineData("ram", 1, 0, 1, 0, 1, 0)]
[InlineData("bison", 1, 0, 2, 0, 0, 0)]
public void Clade_Plus_Species_AbilityMods_MatchQuickRefTable(
string speciesId, int str, int dex, int con, int @int, int wis, int cha)
{
var loader = Loader();
var clades = loader.LoadClades();
var species = loader.LoadSpecies(clades);
var sp = species.Single(s => s.Id == speciesId);
var cl = clades.Single(c => c.Id == sp.CladeId);
int Sum(string ability) =>
(cl.AbilityMods.TryGetValue(ability, out var c) ? c : 0) +
(sp.AbilityMods.TryGetValue(ability, out var s) ? s : 0);
Assert.Equal(str, Sum("STR"));
Assert.Equal(dex, Sum("DEX"));
Assert.Equal(con, Sum("CON"));
Assert.Equal(@int, Sum("INT"));
Assert.Equal(wis, Sum("WIS"));
Assert.Equal(cha, Sum("CHA"));
}
[Fact]
public void EveryWeapon_HasDamageAndType()
{
var items = Loader().LoadItems();
foreach (var i in items.Where(i => i.Kind == "weapon"))
{
Assert.False(string.IsNullOrWhiteSpace(i.Damage), $"weapon '{i.Id}' missing damage");
Assert.False(string.IsNullOrWhiteSpace(i.DamageType), $"weapon '{i.Id}' missing damage_type");
}
}
[Fact]
public void EveryArmor_HasPositiveAcBase()
{
var items = Loader().LoadItems();
foreach (var i in items.Where(i => i.Kind == "armor"))
Assert.True(i.AcBase > 0, $"armor '{i.Id}' has non-positive ac_base");
}
[Fact]
public void NpcZoneTable_HasOneEntryPerZone()
{
var items = Loader().LoadItems();
var npcs = Loader().LoadNpcTemplates(items);
int expected = Theriapolis.Core.C.DANGER_ZONE_MAX - Theriapolis.Core.C.DANGER_ZONE_MIN + 1;
foreach (var (kind, byZone) in npcs.SpawnKindToTemplateByZone)
Assert.Equal(expected, byZone.Length);
}
[Fact]
public void NpcZoneTable_AllReferencedTemplatesExist()
{
var items = Loader().LoadItems();
var npcs = Loader().LoadNpcTemplates(items);
var ids = npcs.Templates.Select(t => t.Id).ToHashSet();
foreach (var (_, byZone) in npcs.SpawnKindToTemplateByZone)
foreach (var tid in byZone)
Assert.Contains(tid, ids);
}
[Fact]
public void EveryNpcTemplate_HasPositiveHpAndAc()
{
var items = Loader().LoadItems();
var npcs = Loader().LoadNpcTemplates(items);
foreach (var t in npcs.Templates)
{
Assert.True(t.Hp > 0, $"NPC '{t.Id}' has non-positive HP");
Assert.True(t.Ac > 0, $"NPC '{t.Id}' has non-positive AC");
}
}
}
@@ -0,0 +1,52 @@
using Theriapolis.Core.World.Generation;
using Xunit;
namespace Theriapolis.Tests.Determinism;
/// <summary>
/// Phase 2+3 determinism contract: same seed → identical settlements and polylines.
/// Uses variant 0 and variant 1 so the fixture returns two independent pipeline
/// runs rather than comparing one cached context to itself.
/// </summary>
public sealed class Phase23DeterminismTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
private readonly WorldCache _cache;
public Phase23DeterminismTests(WorldCache cache) => _cache = cache;
[Fact]
public void SameSeed_ProducesIdenticalSettlements()
{
var h1 = _cache.Get(TestSeed, variant: 0).World.HashSettlements();
var h2 = _cache.Get(TestSeed, variant: 1).World.HashSettlements();
Assert.Equal(h1, h2);
}
[Fact]
public void SameSeed_ProducesIdenticalPolylines()
{
var h1 = _cache.Get(TestSeed, variant: 0).World.HashPolylines();
var h2 = _cache.Get(TestSeed, variant: 1).World.HashPolylines();
Assert.Equal(h1, h2);
}
[Fact]
public void DifferentSeeds_ProduceDifferentSettlements()
{
var h1 = _cache.Get(TestSeed).World.HashSettlements();
var h2 = _cache.Get(TestSeed + 7).World.HashSettlements();
Assert.NotEqual(h1, h2);
}
[Theory]
[InlineData(0xABCD1234UL)]
[InlineData(0x00000001UL)]
[InlineData(0xDEADBEEFUL)]
public void MultipleSeeds_SettlementsAreDeterministic(ulong seed)
{
var h1 = _cache.Get(seed, variant: 0).World.HashSettlements();
var h2 = _cache.Get(seed, variant: 1).World.HashSettlements();
Assert.Equal(h1, h2);
}
}
@@ -0,0 +1,77 @@
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Determinism;
/// <summary>
/// Phase 0 smoke tests: two SeededRng instances with the same seed must produce
/// identical outputs, and different seeds must diverge.
/// </summary>
public sealed class SeededRngTests
{
[Fact]
public void SameSeed_ProducesSameSequence()
{
var a = new SeededRng(123);
var b = new SeededRng(123);
for (int i = 0; i < 1000; i++)
Assert.Equal(a.NextUInt64(), b.NextUInt64());
}
[Fact]
public void DifferentSeeds_ProduceDifferentSequences()
{
var a = new SeededRng(123);
var b = new SeededRng(456);
bool anyDifferent = false;
for (int i = 0; i < 10; i++)
if (a.NextUInt64() != b.NextUInt64()) { anyDifferent = true; break; }
Assert.True(anyDifferent, "Different seeds should produce different sequences.");
}
[Fact]
public void ZeroSeed_DoesNotGetStuck()
{
var rng = new SeededRng(0);
ulong prev = rng.NextUInt64();
bool moved = false;
for (int i = 0; i < 5; i++)
{
ulong next = rng.NextUInt64();
if (next != prev) { moved = true; break; }
prev = next;
}
Assert.True(moved, "RNG must advance even from seed 0.");
}
[Fact]
public void NextFloat_InRange()
{
var rng = new SeededRng(999);
for (int i = 0; i < 10_000; i++)
{
float f = rng.NextFloat();
Assert.True(f >= 0f && f < 1f, $"float {f} out of [0,1)");
}
}
[Fact]
public void ForSubsystem_DifferentTagsProduceDifferentStreams()
{
var a = SeededRng.ForSubsystem(0xDEADBEEF, Theriapolis.Core.C.RNG_TERRAIN);
var b = SeededRng.ForSubsystem(0xDEADBEEF, Theriapolis.Core.C.RNG_MOISTURE);
Assert.NotEqual(a.NextUInt64(), b.NextUInt64());
}
[Fact]
public void Seed123_FirstValue_IsReproducible()
{
// Two independent instances must produce the exact same first value.
var x = new SeededRng(123).NextUInt64();
var y = new SeededRng(123).NextUInt64();
Assert.Equal(x, y);
}
}
@@ -0,0 +1,61 @@
using Theriapolis.Core.World.Generation;
using Xunit;
namespace Theriapolis.Tests.Determinism;
/// <summary>
/// Phase 1 determinism contract:
/// seed 0xCAFEBABE run twice → byte-identical elevation, moisture, temperature,
/// and biome arrays.
///
/// Uses variant 0 and variant 1 so the WorldCache fixture returns two
/// independent pipeline runs of the same seed (otherwise comparing the cached
/// context against itself would prove nothing).
/// </summary>
public sealed class WorldgenDeterminismTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
private readonly WorldCache _cache;
public WorldgenDeterminismTests(WorldCache cache) => _cache = cache;
[Fact]
public void SameSeed_ProducesIdenticalElevation()
{
var h1 = _cache.Get(TestSeed, variant: 0).World.HashElevation();
var h2 = _cache.Get(TestSeed, variant: 1).World.HashElevation();
Assert.Equal(h1, h2);
}
[Fact]
public void SameSeed_ProducesIdenticalMoisture()
{
var h1 = _cache.Get(TestSeed, variant: 0).World.HashMoisture();
var h2 = _cache.Get(TestSeed, variant: 1).World.HashMoisture();
Assert.Equal(h1, h2);
}
[Fact]
public void SameSeed_ProducesIdenticalTemperature()
{
var h1 = _cache.Get(TestSeed, variant: 0).World.HashTemperature();
var h2 = _cache.Get(TestSeed, variant: 1).World.HashTemperature();
Assert.Equal(h1, h2);
}
[Fact]
public void SameSeed_ProducesIdenticalBiomes()
{
var h1 = _cache.Get(TestSeed, variant: 0).World.HashBiomes();
var h2 = _cache.Get(TestSeed, variant: 1).World.HashBiomes();
Assert.Equal(h1, h2);
}
[Fact]
public void DifferentSeeds_ProduceDifferentElevation()
{
var h1 = _cache.Get(TestSeed).World.HashElevation();
var h2 = _cache.Get(TestSeed + 1).World.HashElevation();
Assert.NotEqual(h1, h2);
}
}
@@ -0,0 +1,240 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Dialogue;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Dialogue;
/// <summary>
/// Phase 6 M3 — DialogueRunner mechanics: option visibility, effect
/// application (set_flag, give_item, rep_event, open_shop), skill-check
/// branching, deterministic dice, history scrollback.
/// </summary>
public sealed class DialogueRunnerTests
{
private static ContentResolver LoadContent()
=> new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
private static Character WolfPc(ContentResolver content)
{
var b = new CharacterBuilder()
.WithClade(content.Clades["canidae"])
.WithSpecies(content.Species["wolf"])
.WithClass(content.Classes["fangsworn"])
.WithBackground(content.Backgrounds["pack_raised"])
.WithAbilities(new AbilityScores(13, 12, 14, 10, 10, 11));
var classD = content.Classes["fangsworn"];
int needed = classD.SkillsChoose;
var added = new HashSet<SkillId>();
for (int i = 0; i < classD.SkillOptions.Length && added.Count < needed; i++)
{
try
{
var sk = SkillIdExtensions.FromJson(classD.SkillOptions[i]);
if (added.Add(sk)) b.ChooseSkill(sk);
}
catch (System.ArgumentException) { /* unknown skill in content */ }
}
return b.Build();
}
private static NpcActor SyntheticInnkeeper(ContentResolver content)
{
var template = content.Residents["generic_innkeeper"];
return new NpcActor(template) { Id = 1, RoleTag = "test.innkeeper" };
}
private static (DialogueRunner runner, DialogueContext ctx) NewRunner(
DialogueDef tree,
ContentResolver content,
out PlayerReputation rep,
out Dictionary<string, int> flags)
{
var pc = WolfPc(content);
var npc = SyntheticInnkeeper(content);
rep = new PlayerReputation();
flags = new Dictionary<string, int>();
var ctx = new DialogueContext(npc, pc, rep, flags, content);
return (new DialogueRunner(tree, ctx, worldSeed: 0xCAFEBABEUL), ctx);
}
private static DialogueDef SimpleTree()
=> new DialogueDef
{
Id = "test_simple",
Root = "intro",
Nodes = new[]
{
new DialogueNodeDef
{
Id = "intro",
Speaker = "npc",
Text = "Welcome.",
Options = new[]
{
new DialogueOptionDef
{
Text = "Take this gift.",
Next = "thanks",
Effects = new[] { new DialogueEffectDef { Kind = "set_flag", Flag = "gifted", Value = 1 } },
},
new DialogueOptionDef { Text = "Goodbye.", Next = "<end>" },
},
},
new DialogueNodeDef { Id = "thanks", Speaker = "npc", Text = "You shouldn't have." },
},
};
private static DialogueDef TreeWithSkillCheck()
=> new DialogueDef
{
Id = "test_check",
Root = "intro",
Nodes = new[]
{
new DialogueNodeDef
{
Id = "intro",
Speaker = "npc",
Text = "Try me.",
Options = new[]
{
new DialogueOptionDef
{
Text = "Persuade",
SkillCheck = new DialogueSkillCheckDef { Skill = "persuasion", Dc = 5 },
NextOnSuccess = "yes",
NextOnFailure = "no",
EffectsOnSuccess = new[] { new DialogueEffectDef { Kind = "set_flag", Flag = "won" } },
EffectsOnFailure = new[] { new DialogueEffectDef { Kind = "set_flag", Flag = "lost" } },
},
},
},
new DialogueNodeDef { Id = "yes", Speaker = "npc", Text = "Fine." },
new DialogueNodeDef { Id = "no", Speaker = "npc", Text = "No way." },
},
};
[Fact]
public void StartsAtRoot_AppendsOpeningLine()
{
var content = LoadContent();
var (runner, _) = NewRunner(SimpleTree(), content, out _, out _);
Assert.False(runner.IsOver);
Assert.Equal("intro", runner.CurrentNode.Id);
Assert.Single(runner.History); // the opening NPC line
}
[Fact]
public void ChooseOption_AppliesEffectAndAdvances()
{
var content = LoadContent();
var (runner, _) = NewRunner(SimpleTree(), content, out _, out var flags);
var result = runner.ChooseOption(0);
Assert.Equal("thanks", runner.CurrentNode.Id);
Assert.True(flags.ContainsKey("gifted") && flags["gifted"] == 1);
Assert.False(result.ClosedAfter);
}
[Fact]
public void EndOption_ClosesDialogue()
{
var content = LoadContent();
var (runner, _) = NewRunner(SimpleTree(), content, out _, out _);
runner.ChooseOption(1);
Assert.True(runner.IsOver);
}
[Fact]
public void SkillCheck_BranchesOnRoll()
{
// DC=5 against a STR/DEX-leaning Fangsworn with 11 CHA → mod = 0,
// so the d20 alone must clear 5. Most rolls succeed.
var content = LoadContent();
var (runner, _) = NewRunner(TreeWithSkillCheck(), content, out _, out var flags);
runner.ChooseOption(0);
bool won = flags.ContainsKey("won");
bool lost = flags.ContainsKey("lost");
Assert.True(won ^ lost, "exactly one of won/lost must be set");
Assert.True(runner.IsOver || runner.CurrentNode.Id is "yes" or "no");
}
[Fact]
public void SkillCheck_IsDeterministic_ForSameSeedAndOptionIndex()
{
var content = LoadContent();
// Run two independent runners on the same seed; both should pick the
// same branch.
var (r1, _) = NewRunner(TreeWithSkillCheck(), content, out _, out var f1);
var (r2, _) = NewRunner(TreeWithSkillCheck(), content, out _, out var f2);
r1.ChooseOption(0);
r2.ChooseOption(0);
Assert.Equal(f1.ContainsKey("won"), f2.ContainsKey("won"));
Assert.Equal(r1.CurrentNode.Id, r2.CurrentNode.Id);
}
[Fact]
public void Effect_OpenShop_SetsContextFlag()
{
var tree = new DialogueDef
{
Id = "shop_test", Root = "n",
Nodes = new[]
{
new DialogueNodeDef
{
Id = "n", Speaker = "npc", Text = "Hi",
Options = new[]
{
new DialogueOptionDef { Text = "Browse.", Effects = new[] { new DialogueEffectDef { Kind = "open_shop" } }, Next = "<end>" },
},
},
},
};
var content = LoadContent();
var (runner, ctx) = NewRunner(tree, content, out _, out _);
runner.ChooseOption(0);
Assert.True(ctx.ShopRequested);
}
[Fact]
public void Effect_RepEvent_SubmitsToReputation()
{
var tree = new DialogueDef
{
Id = "rep_test", Root = "n",
Nodes = new[]
{
new DialogueNodeDef
{
Id = "n", Speaker = "npc", Text = "Hi",
Options = new[]
{
new DialogueOptionDef
{
Text = "Insult.",
Effects = new[]
{
new DialogueEffectDef
{
Kind = "rep_event",
Event = new DialogueRepEventDef { Kind = "Dialogue", Magnitude = -5 },
},
},
Next = "<end>",
},
},
},
},
};
var content = LoadContent();
var (runner, _) = NewRunner(tree, content, out var rep, out _);
runner.ChooseOption(0);
Assert.True(rep.Personal["test.innkeeper"].Score < 0);
}
}
@@ -0,0 +1,160 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Dialogue;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Dialogue;
/// <summary>
/// Phase 6 M3 — option visibility predicates: rep_at_least, has_flag,
/// has_item, ability_min, and their negations. The runner hides options
/// whose conditions fail; the visible-option iterator must skip them.
/// </summary>
public sealed class OptionConditionTests
{
private static ContentResolver Content() =>
new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
private static (DialogueRunner runner, PlayerReputation rep, Dictionary<string,int> flags, Character pc)
Setup(DialogueDef tree)
{
var content = Content();
var pc = new CharacterBuilder()
.WithClade(content.Clades["canidae"])
.WithSpecies(content.Species["wolf"])
.WithClass(content.Classes["fangsworn"])
.WithBackground(content.Backgrounds["pack_raised"])
.WithAbilities(new AbilityScores(13, 12, 14, 10, 14, 11))
.ChooseSkill(SkillId.Athletics)
.ChooseSkill(SkillId.Perception)
.Build();
var npc = new NpcActor(content.Residents["generic_innkeeper"]) { Id = 1, RoleTag = "test.npc" };
var rep = new PlayerReputation();
var flags = new Dictionary<string, int>();
var ctx = new DialogueContext(npc, pc, rep, flags, content);
return (new DialogueRunner(tree, ctx, 0xCAFEBABEUL), rep, flags, pc);
}
private static DialogueDef OneNode(params DialogueOptionDef[] opts)
=> new DialogueDef
{
Id = "test_opts",
Root = "n",
Nodes = new[]
{
new DialogueNodeDef { Id = "n", Speaker = "npc", Text = "?", Options = opts },
},
};
[Fact]
public void HasFlag_HidesUntilSet()
{
var tree = OneNode(
new DialogueOptionDef { Text = "Visible only after gifted",
Conditions = new[] { new DialogueConditionDef { Kind = "has_flag", Flag = "gifted" } },
Next = "<end>" });
var (r, _, flags, _) = Setup(tree);
Assert.Empty(r.VisibleOptions());
flags["gifted"] = 1;
Assert.Single(r.VisibleOptions());
}
[Fact]
public void NotHasFlag_VisibleUntilSet()
{
var tree = OneNode(
new DialogueOptionDef { Text = "Visible until done",
Conditions = new[] { new DialogueConditionDef { Kind = "not_has_flag", Flag = "done" } },
Next = "<end>" });
var (r, _, flags, _) = Setup(tree);
Assert.Single(r.VisibleOptions());
flags["done"] = 1;
Assert.Empty(r.VisibleOptions());
}
[Fact]
public void RepAtLeast_GatesOnFactionStanding()
{
var tree = OneNode(
new DialogueOptionDef { Text = "Friendly only",
Conditions = new[] { new DialogueConditionDef { Kind = "rep_at_least", Faction = "covenant_enforcers", Value = 25 } },
Next = "<end>" });
var (r, rep, _, _) = Setup(tree);
Assert.Empty(r.VisibleOptions());
rep.Factions.Set("covenant_enforcers", 25);
Assert.Single(r.VisibleOptions());
rep.Factions.Set("covenant_enforcers", 24);
Assert.Empty(r.VisibleOptions());
}
[Fact]
public void RepAtLeast_NoFaction_ChecksEffectiveDisposition()
{
var tree = OneNode(
new DialogueOptionDef { Text = "Personal only",
Conditions = new[] { new DialogueConditionDef { Kind = "rep_at_least", Value = 30 } },
Next = "<end>" });
var (r, rep, _, _) = Setup(tree);
Assert.Empty(r.VisibleOptions());
// Pump personal disposition above threshold.
rep.PersonalFor("test.npc").Apply(new RepEvent { Kind = RepEventKind.Aid, RoleTag = "test.npc", Magnitude = 60 });
Assert.Single(r.VisibleOptions());
}
[Fact]
public void AbilityMin_GatesOnAbilityMod()
{
var tree = OneNode(
new DialogueOptionDef { Text = "Wise option",
Conditions = new[] { new DialogueConditionDef { Kind = "ability_min", Ability = "WIS", Value = 2 } },
Next = "<end>" });
// Setup gave the PC WIS=14 → mod=+2; passes.
var (r, _, _, _) = Setup(tree);
Assert.Single(r.VisibleOptions());
// Higher threshold fails.
var tree2 = OneNode(
new DialogueOptionDef { Text = "Sage option",
Conditions = new[] { new DialogueConditionDef { Kind = "ability_min", Ability = "WIS", Value = 5 } },
Next = "<end>" });
var (r2, _, _, _) = Setup(tree2);
Assert.Empty(r2.VisibleOptions());
}
[Fact]
public void HasItem_DependsOnInventory()
{
var content = Content();
var tree = OneNode(
new DialogueOptionDef { Text = "Show me your knife",
Conditions = new[] { new DialogueConditionDef { Kind = "has_item", Id = "fang_knife" } },
Next = "<end>" });
var (r, _, _, pc) = Setup(tree);
Assert.Empty(r.VisibleOptions());
pc.Inventory.Add(content.Items["fang_knife"]);
Assert.Single(r.VisibleOptions());
}
[Fact]
public void MultipleConditions_AreAnded()
{
var tree = OneNode(
new DialogueOptionDef { Text = "Both",
Conditions = new[]
{
new DialogueConditionDef { Kind = "has_flag", Flag = "a" },
new DialogueConditionDef { Kind = "has_flag", Flag = "b" },
},
Next = "<end>" });
var (r, _, flags, _) = Setup(tree);
Assert.Empty(r.VisibleOptions());
flags["a"] = 1;
Assert.Empty(r.VisibleOptions());
flags["b"] = 1;
Assert.Single(r.VisibleOptions());
}
}
@@ -0,0 +1,64 @@
using Theriapolis.Core;
using Theriapolis.Core.Rules.Dialogue;
using Theriapolis.Core.Rules.Reputation;
using Xunit;
namespace Theriapolis.Tests.Dialogue;
/// <summary>
/// Phase 6 M3 — disposition-driven shop pricing per the design doc.
/// </summary>
public sealed class ShopPricingTests
{
[Fact]
public void ServiceAvailable_RefusesAtNemesisAndHostile()
{
Assert.False(ShopPricing.ServiceAvailable(-100));
Assert.False(ShopPricing.ServiceAvailable( -90));
Assert.False(ShopPricing.ServiceAvailable( -76));
Assert.False(ShopPricing.ServiceAvailable( -75));
Assert.False(ShopPricing.ServiceAvailable( -60));
Assert.False(ShopPricing.ServiceAvailable( -51));
Assert.True(ShopPricing.ServiceAvailable( -50));
Assert.True(ShopPricing.ServiceAvailable( 0));
Assert.True(ShopPricing.ServiceAvailable( 100));
}
[Fact]
public void BuyMultiplier_HitsKeyTiers()
{
Assert.Equal(1.25f, ShopPricing.BuyMultiplier(-30)); // Antagonistic
Assert.Equal(1.25f, ShopPricing.BuyMultiplier(-10)); // Unfriendly
Assert.Equal(1.00f, ShopPricing.BuyMultiplier( 0)); // Neutral
Assert.Equal(0.90f, ShopPricing.BuyMultiplier( 10)); // Favorable
Assert.Equal(0.80f, ShopPricing.BuyMultiplier( 30)); // Friendly
Assert.Equal(0.70f, ShopPricing.BuyMultiplier( 60)); // Allied
Assert.Equal(0.60f, ShopPricing.BuyMultiplier( 90)); // Champion
}
[Fact]
public void BuyPriceFor_RoundsUp_NeverBelowOne()
{
// Item base cost 10 fang, friendly disposition (×0.80) = 8 fang.
Assert.Equal(8, ShopPricing.BuyPriceFor(10, 30));
// Champion (×0.60) = 6.
Assert.Equal(6, ShopPricing.BuyPriceFor(10, 90));
// Tiny item, large discount, but minimum 1.
Assert.Equal(1, ShopPricing.BuyPriceFor(1, 90));
}
[Fact]
public void SellPriceFor_FloorsToZero_AtRefusedTiers()
{
Assert.Equal(0, ShopPricing.SellPriceFor(20, -90)); // Nemesis
Assert.Equal(0, ShopPricing.SellPriceFor(20, -60)); // Hostile
}
[Fact]
public void SellPriceFor_BetterAtHigherTrust()
{
int neutral = ShopPricing.SellPriceFor(40, 0);
int champion = ShopPricing.SellPriceFor(40, 90);
Assert.True(champion > neutral, $"Champion sell ({champion}) should beat neutral ({neutral}).");
}
}
@@ -0,0 +1,109 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M0 — verifies the headless level-up loop the
/// <c>character-roll --level N</c> Tools flag uses. The Tools command
/// re-creates this loop in <see cref="Theriapolis.Tools.Commands.CharacterRoll"/>;
/// this test asserts the API contract works deterministically without
/// invoking the Tools assembly directly.
/// </summary>
public sealed class CharacterRollLevelFlagTests
{
private static (Character pc, IReadOnlyDictionary<string, SubclassDef> subs) BuildBase()
{
var loader = new ContentLoader(TestHelpers.DataDirectory);
var content = new ContentResolver(loader);
var b = new CharacterBuilder
{
Clade = content.Clades["canidae"],
Species = content.Species["wolf"],
ClassDef = content.Classes["fangsworn"],
Background = content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
Name = "Test",
};
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 { }
}
Assert.True(b.Validate(out _));
return (b.Build(content.Items), content.Subclasses);
}
private static Character LevelTo(int target, ulong worldSeed = 12345UL, ulong msOverride = 0UL)
{
var (pc, subs) = BuildBase();
for (int lv = 2; lv <= target; lv++)
{
ulong seed = worldSeed ^ msOverride ^ C.RNG_LEVELUP ^ (ulong)lv;
var result = LevelUpFlow.Compute(pc, lv, seed, takeAverage: true, subclasses: subs);
var choices = new LevelUpChoices
{
TakeAverageHp = true,
SubclassId = result.GrantsSubclassChoice && pc.ClassDef.SubclassIds.Length > 0
? pc.ClassDef.SubclassIds[0]
: null,
};
if (result.GrantsAsiChoice)
choices.AsiAdjustments[AbilityId.CON] = 2;
pc.ApplyLevelUp(result, choices);
}
return pc;
}
[Fact]
public void LevelN_ProducesExpectedLevelAndProficiency()
{
var pc1 = LevelTo(1);
Assert.Equal(1, pc1.Level);
Assert.Equal(2, pc1.ProficiencyBonus);
var pc5 = LevelTo(5);
Assert.Equal(5, pc5.Level);
Assert.Equal(3, pc5.ProficiencyBonus);
var pc11 = LevelTo(11);
Assert.Equal(11, pc11.Level);
Assert.Equal(4, pc11.ProficiencyBonus);
}
[Fact]
public void LevelN_PicksSubclassAtLevelThree()
{
var pc3 = LevelTo(3);
Assert.Equal(3, pc3.Level);
Assert.False(string.IsNullOrEmpty(pc3.SubclassId),
"level-3 character must have a subclass selected");
}
[Fact]
public void LevelN_AppliesAsiAtLevelFour()
{
// Auto-pilot ASI puts +2 to CON at level 4 (one of the C.ASI_LEVELS).
// Compare CON pre/post — clade + species mods are baked in by the
// builder, so absolute values vary by build choices but the
// delta is exactly 2.
var pc3 = LevelTo(3);
var pc4 = LevelTo(4);
Assert.Equal(pc3.Abilities.CON + 2, pc4.Abilities.CON);
}
[Fact]
public void LevelN_IsDeterministic()
{
var a = LevelTo(7, worldSeed: 99UL, msOverride: 12345UL);
var b = LevelTo(7, worldSeed: 99UL, msOverride: 12345UL);
Assert.Equal(a.Level, b.Level);
Assert.Equal(a.MaxHp, b.MaxHp);
Assert.Equal(a.SubclassId, b.SubclassId);
}
}
@@ -0,0 +1,160 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Dungeons;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M2 — clade-responsive movement multiplier tests. Verifies the
/// table from Phase 7 plan §5.4 and that hybrid PCs use the dominant-
/// lineage's presenting size for the lookup.
/// </summary>
public sealed class ClademorphicMovementTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Plan §5.4 table values ────────────────────────────────────────────
[Fact]
public void LargePc_InMustelidTunnel_PaysHeavyMultiplier()
{
Assert.Equal(C.MOVE_COST_MISMATCH_HEAVY,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Large, "mustelid"));
}
[Fact]
public void MediumLargePc_InMustelidTunnel_PaysMediumMultiplier()
{
Assert.Equal(C.MOVE_COST_MISMATCH_MED,
ClademorphicMovement.GetCostMultiplier(SizeCategory.MediumLarge, "mustelid"));
}
[Fact]
public void MediumPc_InMustelidTunnel_PaysLightMultiplier()
{
Assert.Equal(C.MOVE_COST_MISMATCH_LIGHT,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Medium, "mustelid"));
}
[Fact]
public void SmallPc_InMustelidTunnel_NoPenalty()
{
Assert.Equal(1.0f,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Small, "mustelid"));
}
[Fact]
public void SmallPc_InUrsidHall_PaysExposedMultiplier()
{
Assert.Equal(C.MOVE_COST_MISMATCH_MED,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Small, "ursid"));
}
[Fact]
public void LargePc_InCervidHall_PaysAntlerClearancePenalty()
{
Assert.Equal(C.MOVE_COST_MISMATCH_LIGHT,
ClademorphicMovement.GetCostMultiplier(SizeCategory.Large, "cervid"));
}
[Fact]
public void AnyPc_InImperiumOrNoneRoom_NoPenalty()
{
foreach (var size in new[] { SizeCategory.Small, SizeCategory.Medium, SizeCategory.MediumLarge, SizeCategory.Large })
{
Assert.Equal(1.0f, ClademorphicMovement.GetCostMultiplier(size, "imperium"));
Assert.Equal(1.0f, ClademorphicMovement.GetCostMultiplier(size, "none"));
Assert.Equal(1.0f, ClademorphicMovement.GetCostMultiplier(size, ""));
}
}
[Fact]
public void UnknownBuiltBy_NoPenalty()
{
Assert.Equal(1.0f, ClademorphicMovement.GetCostMultiplier(SizeCategory.Large, "garbage"));
}
// ── Hybrid PC presenting-size lookup ─────────────────────────────────
[Fact]
public void HybridPc_UsesPresentingClade_ForSizeLookup()
{
// Build a Wolf-Folk × Hare-Folk hybrid presenting as Hare-Folk
// (Dam dominant). EffectiveSize should be the presenting species' size.
var pc = BuildHybrid(
sireClade: "canidae", sireSpecies: "wolf",
damClade: "leporidae", damSpecies: "hare",
dominant: ParentLineage.Dam);
// The hybrid build path picked Hare-Folk as the presenting species,
// so EffectiveSize should match Character.Size (which the builder
// already set to Hare-Folk's size category).
Assert.Equal(pc.Size, ClademorphicMovement.EffectiveSize(pc));
// Whichever species the builder chose, the multiplier should match
// its size's lookup in a Mustelid tunnel.
var expected = ClademorphicMovement.GetCostMultiplier(pc.Size, "mustelid");
Assert.Equal(expected, ClademorphicMovement.GetCostMultiplier(pc, "mustelid"));
}
[Fact]
public void NonHybridPc_UsesOwnSize_ForLookup()
{
var pc = BuildPurebred("canidae", "wolf"); // wolf = MediumLarge
Assert.Equal(SizeCategory.MediumLarge, pc.Size);
Assert.Equal(C.MOVE_COST_MISMATCH_MED,
ClademorphicMovement.GetCostMultiplier(pc, "mustelid"));
}
// ── Helpers ──────────────────────────────────────────────────────────
private Character BuildPurebred(string cladeId, string speciesId)
{
var b = new CharacterBuilder
{
Clade = _content.Clades[cladeId],
Species = _content.Species[speciesId],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
};
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 Character BuildHybrid(
string sireClade, string sireSpecies,
string damClade, string damSpecies,
ParentLineage dominant)
{
var b = new CharacterBuilder
{
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
IsHybridOrigin = true,
HybridSireClade = _content.Clades[sireClade],
HybridSireSpecies = _content.Species[sireSpecies],
HybridDamClade = _content.Clades[damClade],
HybridDamSpecies = _content.Species[damSpecies],
HybridDominantParent = dominant,
};
// Chosen skills don't matter for size-of-character — pick first N.
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 { }
}
Assert.True(b.TryBuildHybrid(_content.Items, out var character, out _));
return character!;
}
}
@@ -0,0 +1,185 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M2 — central consumable-dispatch tests + Phase 6.5 M4 carryover
/// (Hybrid Medical Incompatibility scaling on healing potions).
/// </summary>
public sealed class ConsumableHandlerTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Healing potion ───────────────────────────────────────────────────
[Fact]
public void Consume_HealingPotion_RestoresHp()
{
var pc = MakePurebred();
pc.CurrentHp = 1;
var potion = _content.Items["healing_potion"];
var result = ConsumableHandler.Consume(potion, pc, seed: 0xFEEDUL);
Assert.True(result.IsSuccess);
Assert.Equal(ConsumeResult.ResultKind.Healed, result.Kind);
Assert.True(result.HealedAmount >= 4, // 2d4+2 minimum = 4
$"healing potion should heal ≥ 4 HP, healed {result.HealedAmount}");
Assert.True(pc.CurrentHp > 1);
}
[Fact]
public void Consume_HealingPotion_OnHybridPc_AppliesMedicalIncompatibility()
{
var pcPure = MakePurebred();
var pcHybrid = MakeHybrid();
// Pin both at 1 HP; same seed so the dice are identical.
pcPure.CurrentHp = 1;
pcHybrid.CurrentHp = 1;
// Boost MaxHp so neither caps to MaxHp.
pcPure.MaxHp = 100;
pcHybrid.MaxHp = 100;
var potion = _content.Items["healing_potion"];
var pureResult = ConsumableHandler.Consume(potion, pcPure, seed: 42UL);
var hybridResult = ConsumableHandler.Consume(potion, pcHybrid, seed: 42UL);
// Hybrid should heal 75% (round down, min 1) of the same dice roll.
// 0.75 * 4 = 3, 0.75 * 8 = 6, 0.75 * 10 = 7. So hybrid amount < pure
// amount whenever the pure roll is ≥ 4 (which 2d4+2 always is).
Assert.True(hybridResult.IsSuccess);
Assert.True(hybridResult.WasScaledForHybrid);
Assert.True(hybridResult.HealedAmount < pureResult.HealedAmount,
$"hybrid {hybridResult.HealedAmount} should be < pure {pureResult.HealedAmount}");
Assert.True(hybridResult.HealedAmount >= 1, "min-heal floor is 1 even for hybrids");
}
[Fact]
public void Consume_HealingPotion_DeterministicForSameSeed()
{
var pcA = MakePurebred(); pcA.CurrentHp = 1; pcA.MaxHp = 100;
var pcB = MakePurebred(); pcB.CurrentHp = 1; pcB.MaxHp = 100;
var potion = _content.Items["healing_potion"];
var a = ConsumableHandler.Consume(potion, pcA, seed: 0xC0FFEEUL);
var b = ConsumableHandler.Consume(potion, pcB, seed: 0xC0FFEEUL);
Assert.Equal(a.HealedAmount, b.HealedAmount);
}
// ── Scent masks ──────────────────────────────────────────────────────
[Fact]
public void Consume_ScentMaskBasic_OnHybridPc_SetsBasicTier()
{
var pc = MakeHybrid();
var mask = _content.Items["scent_mask_basic"];
var result = ConsumableHandler.Consume(mask, pc, seed: 0);
Assert.True(result.IsSuccess);
Assert.Equal(ConsumeResult.ResultKind.MaskApplied, result.Kind);
Assert.Equal(ScentMaskTier.Basic, result.MaskTier);
Assert.True(result.MaskHadEffect);
Assert.Equal(ScentMaskTier.Basic, pc.Hybrid!.ActiveMaskTier);
}
[Fact]
public void Consume_ScentMaskMilitary_OnHybridPc_SetsMilitaryTier()
{
var pc = MakeHybrid();
var mask = _content.Items["scent_mask_military"];
var result = ConsumableHandler.Consume(mask, pc, seed: 0);
Assert.Equal(ScentMaskTier.Military, result.MaskTier);
Assert.Equal(ScentMaskTier.Military, pc.Hybrid!.ActiveMaskTier);
}
[Fact]
public void Consume_ScentMaskDeepCover_OnHybridPc_SetsDeepCoverTier()
{
var pc = MakeHybrid();
var mask = _content.Items["scent_mask_deep_cover"];
var result = ConsumableHandler.Consume(mask, pc, seed: 0);
Assert.Equal(ScentMaskTier.DeepCover, result.MaskTier);
Assert.Equal(ScentMaskTier.DeepCover, pc.Hybrid!.ActiveMaskTier);
}
[Fact]
public void Consume_ScentMask_OnPurebredPc_SucceedsWithoutEffect()
{
var pc = MakePurebred();
var mask = _content.Items["scent_mask_basic"];
var result = ConsumableHandler.Consume(mask, pc, seed: 0);
Assert.True(result.IsSuccess);
Assert.False(result.MaskHadEffect);
}
// ── Rejection paths ──────────────────────────────────────────────────
[Fact]
public void Consume_NonConsumable_Rejects()
{
var pc = MakePurebred();
var weapon = _content.Items["fang_knife"]; // kind = "weapon"
var result = ConsumableHandler.Consume(weapon, pc, seed: 0);
Assert.Equal(ConsumeResult.ResultKind.Rejected, result.Kind);
}
[Fact]
public void Consume_UnknownConsumableKind_ReturnsUnrecognized()
{
var pc = MakePurebred();
var unknown = new ItemDef { Id = "fake_consumable", Kind = "consumable", ConsumableKind = "tea_party" };
var result = ConsumableHandler.Consume(unknown, pc, seed: 0);
Assert.Equal(ConsumeResult.ResultKind.Unrecognized, result.Kind);
Assert.Equal("fake_consumable", result.UnrecognizedItemId);
}
// ── Helpers ──────────────────────────────────────────────────────────
private Character MakePurebred()
{
var b = new CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
};
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 Character MakeHybrid()
{
var b = new CharacterBuilder
{
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 10, 12, 8),
IsHybridOrigin = true,
HybridSireClade = _content.Clades["canidae"],
HybridSireSpecies = _content.Species["wolf"],
HybridDamClade = _content.Clades["leporidae"],
HybridDamSpecies = _content.Species["hare"],
HybridDominantParent = ParentLineage.Sire,
};
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 { }
}
Assert.True(b.TryBuildHybrid(_content.Items, out var ch, out _));
return ch!;
}
}
@@ -0,0 +1,246 @@
using System.Diagnostics;
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Dungeons;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.World;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M1 — engine-level tests for the dungeon generator. These use the
/// authored M0 vertical-slice content (5 imperium + 3 mine + 2 cave
/// templates, 2 layouts) and assert the engine's contracts:
/// - Determinism: same (seed, poi) → byte-identical Dungeon.
/// - Reachability: every Room reachable from Entrance via Connections.
/// - Scale: room count stays within the layout's declared band.
/// - Budget: generation completes in &lt; 400ms even under retry-fallback.
/// </summary>
public sealed class DungeonGeneratorTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Determinism ──────────────────────────────────────────────────────
[Fact]
public void Generate_SameSeedAndPoi_ProducesIdenticalDungeon()
{
const ulong seed = 0xCAFE12345UL;
const int poi = 7;
var a = DungeonGenerator.Generate(seed, poi, PoiType.ImperiumRuin, _content);
var b = DungeonGenerator.Generate(seed, poi, PoiType.ImperiumRuin, _content);
Assert.Equal(a.PoiId, b.PoiId);
Assert.Equal(a.Type, b.Type);
Assert.Equal(a.W, b.W);
Assert.Equal(a.H, b.H);
Assert.Equal(a.EntranceTile, b.EntranceTile);
Assert.Equal(a.Rooms.Length, b.Rooms.Length);
for (int i = 0; i < a.Rooms.Length; i++)
{
Assert.Equal(a.Rooms[i].TemplateId, b.Rooms[i].TemplateId);
Assert.Equal(a.Rooms[i].AabbX, b.Rooms[i].AabbX);
Assert.Equal(a.Rooms[i].AabbY, b.Rooms[i].AabbY);
Assert.Equal(a.Rooms[i].Role, b.Rooms[i].Role);
}
Assert.Equal(a.Connections.Length, b.Connections.Length);
for (int i = 0; i < a.Connections.Length; i++)
Assert.Equal(a.Connections[i], b.Connections[i]);
// Tile array byte-identical.
Assert.Equal(a.W * a.H, b.W * b.H);
for (int y = 0; y < a.H; y++)
for (int x = 0; x < a.W; x++)
{
Assert.Equal(a.Tiles[x, y].Surface, b.Tiles[x, y].Surface);
Assert.Equal(a.Tiles[x, y].Deco, b.Tiles[x, y].Deco);
}
}
[Fact]
public void Generate_DifferentSeed_ProducesDifferentDungeon()
{
var a = DungeonGenerator.Generate(0x1111UL, 5, PoiType.ImperiumRuin, _content);
var b = DungeonGenerator.Generate(0x2222UL, 5, PoiType.ImperiumRuin, _content);
// Same template count is fine, but at least *something* must differ.
bool differs = a.W != b.W || a.H != b.H
|| a.Rooms.Length != b.Rooms.Length
|| (a.Rooms.Length == b.Rooms.Length && !RoomLayoutsMatch(a, b));
Assert.True(differs,
"Different worldSeeds should produce divergent layouts (room mix or geometry).");
}
[Fact]
public void Generate_DifferentPoi_ProducesDifferentDungeon()
{
var a = DungeonGenerator.Generate(0xBEEFUL, 1, PoiType.ImperiumRuin, _content);
var b = DungeonGenerator.Generate(0xBEEFUL, 2, PoiType.ImperiumRuin, _content);
bool differs = a.W != b.W || a.H != b.H
|| a.Rooms.Length != b.Rooms.Length
|| (a.Rooms.Length == b.Rooms.Length && !RoomLayoutsMatch(a, b));
Assert.True(differs,
"Different poiIds at the same seed should produce divergent layouts.");
}
private static bool RoomLayoutsMatch(Dungeon a, Dungeon b)
{
for (int i = 0; i < a.Rooms.Length; i++)
if (a.Rooms[i].TemplateId != b.Rooms[i].TemplateId
|| a.Rooms[i].AabbX != b.Rooms[i].AabbX
|| a.Rooms[i].AabbY != b.Rooms[i].AabbY)
return false;
return true;
}
// ── Reachability ─────────────────────────────────────────────────────
[Fact]
public void Generate_EveryRoom_ReachableFromEntrance()
{
// Sample 20 (seed, poi) pairs and assert reachability for each.
for (int i = 0; i < 20; i++)
{
ulong seed = 0x1000000UL + (ulong)i;
int poi = i;
var d = DungeonGenerator.Generate(seed, poi, PoiType.ImperiumRuin, _content);
AssertAllRoomsReachable(d);
}
}
[Fact]
public void Generate_Mine_AllRoomsReachable()
{
for (int i = 0; i < 10; i++)
{
var d = DungeonGenerator.Generate(0x70UL + (ulong)i, i, PoiType.AbandonedMine, _content);
AssertAllRoomsReachable(d);
}
}
private static void AssertAllRoomsReachable(Dungeon d)
{
if (d.Rooms.Length == 0) return;
var adj = new List<int>[d.Rooms.Length];
for (int i = 0; i < d.Rooms.Length; i++) adj[i] = new List<int>();
foreach (var c in d.Connections)
{
adj[c.RoomA].Add(c.RoomB);
adj[c.RoomB].Add(c.RoomA);
}
var visited = new bool[d.Rooms.Length];
var queue = new Queue<int>();
queue.Enqueue(0);
visited[0] = true;
while (queue.Count > 0)
{
int n = queue.Dequeue();
foreach (int m in adj[n])
if (!visited[m]) { visited[m] = true; queue.Enqueue(m); }
}
for (int i = 0; i < d.Rooms.Length; i++)
Assert.True(visited[i], $"Room {i} ({d.Rooms[i].TemplateId}) unreachable from Room 0.");
}
// ── Scale ────────────────────────────────────────────────────────────
[Fact]
public void Generate_RoomCount_StaysWithinLayoutBand()
{
// imperium_medium: 6..10 rooms.
for (int i = 0; i < 10; i++)
{
var d = DungeonGenerator.Generate(0xA0UL + (ulong)i, i, PoiType.ImperiumRuin, _content);
Assert.InRange(d.Rooms.Length,
C.DUNGEON_MED_ROOMS_MIN,
C.DUNGEON_MED_ROOMS_MAX);
}
}
[Fact]
public void Generate_Mine_RoomCount_StaysWithinSmallBand()
{
// mine_small: 3..5 rooms.
for (int i = 0; i < 10; i++)
{
var d = DungeonGenerator.Generate(0xB0UL + (ulong)i, i, PoiType.AbandonedMine, _content);
Assert.InRange(d.Rooms.Length,
C.DUNGEON_SMALL_ROOMS_MIN,
C.DUNGEON_SMALL_ROOMS_MAX);
}
}
// ── Budget ───────────────────────────────────────────────────────────
[Fact]
public void Generate_CompletesUnderBudget()
{
// Under ~400ms even with the worst-case retry-then-linear-fallback
// for a medium imperium ruin.
var sw = Stopwatch.StartNew();
for (int i = 0; i < 10; i++)
{
DungeonGenerator.Generate(0xC0UL + (ulong)i, i, PoiType.ImperiumRuin, _content);
}
sw.Stop();
Assert.True(sw.ElapsedMilliseconds < 4000,
$"10 dungeon gens should complete in <4s (per-gen <400ms target); took {sw.ElapsedMilliseconds}ms.");
}
// ── Tile-array sanity ────────────────────────────────────────────────
[Fact]
public void Generate_TileArray_HasEntranceStairsDeco()
{
var d = DungeonGenerator.Generate(0x12345UL, 1, PoiType.ImperiumRuin, _content);
var (ex, ey) = d.EntranceTile;
Assert.InRange(ex, 0, d.W - 1);
Assert.InRange(ey, 0, d.H - 1);
Assert.Equal(TacticalDeco.Stairs, d.Tiles[ex, ey].Deco);
}
[Fact]
public void Generate_TileArray_RoomInteriorsAreWalkable()
{
var d = DungeonGenerator.Generate(0x9999UL, 1, PoiType.ImperiumRuin, _content);
// Every room's centre tile should be walkable.
foreach (var r in d.Rooms)
{
int cx = r.AabbX + r.AabbW / 2;
int cy = r.AabbY + r.AabbH / 2;
Assert.True(d.Tiles[cx, cy].IsWalkable,
$"Room {r.Id} ({r.TemplateId}) centre ({cx},{cy}) is not walkable: " +
$"surface={d.Tiles[cx, cy].Surface} deco={d.Tiles[cx, cy].Deco}");
}
}
[Fact]
public void Generate_TileArray_PerimeterIsBoundedByWalls()
{
var d = DungeonGenerator.Generate(0xDEADUL, 3, PoiType.ImperiumRuin, _content);
// Outer perimeter (x=0, x=W-1, y=0, y=H-1) should never be walkable
// — those tiles are the AABB padding, never carved.
for (int x = 0; x < d.W; x++)
{
Assert.False(d.Tiles[x, 0].IsWalkable, $"top edge ({x},0) walkable");
Assert.False(d.Tiles[x, d.H - 1].IsWalkable, $"bottom edge ({x},{d.H - 1}) walkable");
}
for (int y = 0; y < d.H; y++)
{
Assert.False(d.Tiles[0, y].IsWalkable, $"left edge (0,{y}) walkable");
Assert.False(d.Tiles[d.W - 1, y].IsWalkable, $"right edge ({d.W - 1},{y}) walkable");
}
}
[Fact]
public void Generate_RequiredRoles_AllPresent()
{
// imperium_medium requires entry + boss.
var d = DungeonGenerator.Generate(0x42UL, 1, PoiType.ImperiumRuin, _content);
Assert.Contains(d.Rooms, r => r.Role == RoomRole.Entry);
Assert.Contains(d.Rooms, r => r.Role == RoomRole.Boss);
}
}
@@ -0,0 +1,134 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Dungeons;
using Theriapolis.Core.World;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M2 — populator tests. Verifies:
/// - The same (seed, poi, levelBand) → byte-identical population.
/// - Encounter slots resolve to the per-dungeon-type templates.
/// - Boss-role rooms use the type's Boss template.
/// - Container slots pre-roll loot from the layout's loot-band table.
/// </summary>
public sealed class DungeonPopulatorTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
private DungeonPopulation Populate(ulong seed, int poi, PoiType type, int levelBand)
{
var d = DungeonGenerator.Generate(seed, poi, type, _content);
// Find the matching layout (procedural; not anchor-locked).
DungeonLayoutDef? layout = null;
foreach (var l in _content.DungeonLayouts.Values)
if (string.IsNullOrEmpty(l.Anchor)
&& string.Equals(l.DungeonType, type.ToString(), System.StringComparison.OrdinalIgnoreCase))
{ layout = l; break; }
Assert.NotNull(layout);
return DungeonPopulator.Populate(d, layout!, _content, levelBand, seed);
}
[Fact]
public void Populate_SameInputs_ProducesIdenticalPopulation()
{
var a = Populate(0xCAFE12345UL, 7, PoiType.ImperiumRuin, levelBand: 2);
var b = Populate(0xCAFE12345UL, 7, PoiType.ImperiumRuin, levelBand: 2);
Assert.Equal(a.Spawns.Length, b.Spawns.Length);
for (int i = 0; i < a.Spawns.Length; i++)
{
Assert.Equal(a.Spawns[i].RoomId, b.Spawns[i].RoomId);
Assert.Equal(a.Spawns[i].X, b.Spawns[i].X);
Assert.Equal(a.Spawns[i].Y, b.Spawns[i].Y);
Assert.Equal(a.Spawns[i].Template.Id, b.Spawns[i].Template.Id);
Assert.Equal(a.Spawns[i].Kind, b.Spawns[i].Kind);
}
Assert.Equal(a.Containers.Length, b.Containers.Length);
for (int i = 0; i < a.Containers.Length; i++)
{
Assert.Equal(a.Containers[i].TableId, b.Containers[i].TableId);
Assert.Equal(a.Containers[i].Drops.Length, b.Containers[i].Drops.Length);
for (int j = 0; j < a.Containers[i].Drops.Length; j++)
Assert.Equal(a.Containers[i].Drops[j].Def.Id, b.Containers[i].Drops[j].Def.Id);
}
}
[Fact]
public void Populate_EncounterSlots_ResolveToTypeTemplates()
{
var pop = Populate(0x42UL, 1, PoiType.ImperiumRuin, levelBand: 2);
// Imperium templates: imperium_undead_thrall (PoiGuard),
// imperium_feral_canid (WildAnimal), brigand_marauder (Brigand),
// imperium_undead_overseer (Boss).
var expected = new HashSet<string>
{
"imperium_undead_thrall", "imperium_feral_canid",
"brigand_marauder", "imperium_undead_overseer",
};
foreach (var s in pop.Spawns)
Assert.Contains(s.Template.Id, expected);
}
[Fact]
public void Populate_BossRoom_GetsBossTemplate()
{
// Imperium medium layout requires a boss room. The boss-role room's
// encounter slots that declare Boss kind should resolve to the
// dungeon type's Boss template.
for (int i = 0; i < 5; i++)
{
var d = DungeonGenerator.Generate(0xB05UL + (ulong)i, i, PoiType.ImperiumRuin, _content);
var layout = _content.DungeonLayouts["imperium_medium"];
var pop = DungeonPopulator.Populate(d, layout, _content, levelBand: 2, worldSeed: 0xB05UL + (ulong)i);
// Find the boss room.
int bossRoomId = -1;
foreach (var r in d.Rooms)
if (r.Role == RoomRole.Boss) { bossRoomId = r.Id; break; }
Assert.NotEqual(-1, bossRoomId);
// The boss-room's Boss-kind spawn should be the overseer.
bool foundBoss = false;
foreach (var s in pop.Spawns)
if (s.RoomId == bossRoomId && s.Kind == "Boss")
{ foundBoss = true; Assert.Equal("imperium_undead_overseer", s.Template.Id); }
Assert.True(foundBoss, "Boss room should have a Boss-kind spawn");
}
}
[Fact]
public void Populate_ContainerSlots_HaveDropsAndTable()
{
var pop = Populate(0xC0FFEEUL, 1, PoiType.ImperiumRuin, levelBand: 2);
// Imperium pillar_room_cardinal + sarcophagus_chamber + boss_throne_room
// each have a container slot, so we should see at least one.
Assert.NotEmpty(pop.Containers);
foreach (var c in pop.Containers)
{
Assert.False(string.IsNullOrEmpty(c.TableId),
$"container in room {c.RoomId} has no table id");
}
}
[Fact]
public void Populate_AllContainerTableIds_ResolveToRealTables()
{
// Across multiple seeds and level bands, every populated container
// should reference a loot table that exists in the resolver. This
// catches band-mapping bugs (e.g. layout missing a t3 entry) and
// confirms the resolver→populator wiring stays coherent.
for (int band = 0; band <= 3; band++)
for (int i = 0; i < 5; i++)
{
ulong seed = 0x1007UL + (ulong)i * 1000UL;
var pop = Populate(seed, i, PoiType.ImperiumRuin, levelBand: band);
foreach (var c in pop.Containers)
{
Assert.True(_content.LootTables.ContainsKey(c.TableId),
$"populator emitted unknown loot table '{c.TableId}' (band={band}, room={c.RoomId})");
}
}
}
}
@@ -0,0 +1,76 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Loot;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M2 — determinism tests for the dungeon loot generator. Same
/// (table, containerSeed) → byte-identical item drops, regardless of
/// process / clock / PRNG warm-up.
/// </summary>
public sealed class LootGeneratorTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void RollContainer_SameSeed_ProducesIdenticalDrops()
{
const ulong seed = 0xABCDEF;
var a = LootGenerator.RollContainer("loot_dungeon_imperium_t2", seed, _content.LootTables, _content.Items);
var b = LootGenerator.RollContainer("loot_dungeon_imperium_t2", seed, _content.LootTables, _content.Items);
Assert.Equal(a.Length, b.Length);
for (int i = 0; i < a.Length; i++)
{
Assert.Equal(a[i].Def.Id, b[i].Def.Id);
Assert.Equal(a[i].Qty, b[i].Qty);
}
}
[Fact]
public void RollContainer_DifferentSeeds_DivergeAcrossManyRolls()
{
// Across 100 (seed, slotIdx) pairs, the *aggregate* drop count
// should differ between two different base seeds. (A single pair
// could collide; the population can't, with overwhelming probability.)
int aTotal = 0, bTotal = 0;
for (int i = 0; i < 100; i++)
{
ulong seedA = 0x10000UL ^ (ulong)i;
ulong seedB = 0x20000UL ^ (ulong)i;
aTotal += LootGenerator.RollContainer("loot_dungeon_imperium_t2", seedA, _content.LootTables, _content.Items).Length;
bTotal += LootGenerator.RollContainer("loot_dungeon_imperium_t2", seedB, _content.LootTables, _content.Items).Length;
}
Assert.NotEqual(aTotal, bTotal);
}
[Fact]
public void RollContainer_HonoursDungeonLayoutSeedConvention()
{
ulong dungeonLayoutSeed = 0xD06E07AUL ^ 7UL; // simulated — same shape as DungeonGenerator
var a = LootGenerator.RollContainer(
"loot_dungeon_imperium_t1", dungeonLayoutSeed, slotIdx: 0,
_content.LootTables, _content.Items);
var b = LootGenerator.RollContainer(
"loot_dungeon_imperium_t1", dungeonLayoutSeed ^ C.RNG_DUNGEON_LOOT ^ 0UL,
_content.LootTables, _content.Items);
// Both forms should produce identical results — the convenience
// overload XORs the same RNG_DUNGEON_LOOT + slotIdx the explicit
// overload's caller would.
Assert.Equal(a.Length, b.Length);
for (int i = 0; i < a.Length; i++)
{
Assert.Equal(a[i].Def.Id, b[i].Def.Id);
Assert.Equal(a[i].Qty, b[i].Qty);
}
}
[Fact]
public void RollContainer_UnknownTable_ReturnsEmpty()
{
var drops = LootGenerator.RollContainer("nonexistent_table", 1, _content.LootTables, _content.Items);
Assert.Empty(drops);
}
}
@@ -0,0 +1,75 @@
using Theriapolis.Core;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M0 — schema integrity tests for the Phase 7 constants. These
/// guard against silent regressions in:
/// - <see cref="C.SAVE_SCHEMA_VERSION"/> (must == 8 at Phase 7 ship)
/// - The 4 new RNG sub-streams (must be unique vs every existing stream)
/// - Dungeon size bands (must be a coherent ladder)
/// - Movement-cost multipliers (must be ≥ 1.0; squeezing must dominate)
/// </summary>
public sealed class Phase7ConstantsTests
{
[Fact]
public void SaveSchemaVersion_IsEight()
{
Assert.Equal(8, C.SAVE_SCHEMA_VERSION);
}
[Fact]
public void DungeonRngSubStreams_AreDistinctFromAllExistingStreams()
{
// Collect every named ulong RNG sub-stream by reflection. Each
// must be unique — a collision means two independent streams share
// a seed, breaking the dice contract.
var fields = typeof(C).GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(ulong))
.ToArray();
var seen = new Dictionary<ulong, string>();
foreach (var f in fields)
{
ulong value = (ulong)f.GetRawConstantValue()!;
if (seen.TryGetValue(value, out var prior))
Assert.Fail($"RNG sub-stream collision: {f.Name} == {prior} ({value:X})");
seen[value] = f.Name;
}
// Belt-and-braces: assert the four Phase 7 streams exist.
Assert.Contains(C.RNG_DUNGEON_LAYOUT, seen.Keys);
Assert.Contains(C.RNG_ROOM_PICK, seen.Keys);
Assert.Contains(C.RNG_DUNGEON_POPULATE, seen.Keys);
Assert.Contains(C.RNG_DUNGEON_LOOT, seen.Keys);
}
[Fact]
public void DungeonSizeBands_FormCoherentLadder()
{
Assert.True(C.DUNGEON_SMALL_ROOMS_MIN <= C.DUNGEON_SMALL_ROOMS_MAX);
Assert.True(C.DUNGEON_MED_ROOMS_MIN <= C.DUNGEON_MED_ROOMS_MAX);
Assert.True(C.DUNGEON_LARGE_ROOMS_MIN <= C.DUNGEON_LARGE_ROOMS_MAX);
// Ladders don't overlap — a small dungeon's max < medium's min.
Assert.True(C.DUNGEON_SMALL_ROOMS_MAX < C.DUNGEON_MED_ROOMS_MIN);
Assert.True(C.DUNGEON_MED_ROOMS_MAX < C.DUNGEON_LARGE_ROOMS_MIN);
}
[Fact]
public void MovementCostMultipliers_AreOrdered()
{
Assert.True(C.MOVE_COST_MISMATCH_LIGHT >= 1.0f,
"Mismatch must never give a speed bonus.");
Assert.True(C.MOVE_COST_MISMATCH_LIGHT < C.MOVE_COST_MISMATCH_MED);
Assert.True(C.MOVE_COST_MISMATCH_MED < C.MOVE_COST_MISMATCH_HEAVY);
}
[Fact]
public void LockAndTrapDcs_AreOrdered()
{
Assert.True(C.LOCK_DC_TRIVIAL < C.LOCK_DC_EASY);
Assert.True(C.LOCK_DC_EASY < C.LOCK_DC_MEDIUM);
Assert.True(C.LOCK_DC_MEDIUM < C.LOCK_DC_HARD);
Assert.True(C.TRAP_DC_TRIVIAL < C.TRAP_DC_EASY);
Assert.True(C.TRAP_DC_EASY < C.TRAP_DC_MEDIUM);
}
}
@@ -0,0 +1,107 @@
using Theriapolis.Core.Data;
using Xunit;
namespace Theriapolis.Tests.Dungeons;
/// <summary>
/// Phase 7 M0 — content-load tests for the room-template + dungeon-layout
/// schema. These run on the actual <c>Content/Data/room_templates/</c>
/// + <c>Content/Data/dungeon_layouts/</c> directories so a broken
/// authoring edit fails the build.
/// </summary>
public sealed class RoomTemplateValidationTests
{
private static ContentLoader Loader() => new(TestHelpers.DataDirectory);
[Fact]
public void RoomTemplates_LoadAndValidate()
{
// M0 vertical-slice: 5 imperium + 3 mine + 2 cave = 10 templates.
// Test asserts ≥ 5 to allow content authoring growth without
// modifying this test on every drop.
var rooms = Loader().LoadRoomTemplates();
Assert.True(rooms.Length >= 10,
$"expected ≥10 room templates after Phase 7 M0 vertical slice, got {rooms.Length}");
// Every template must declare at least one role and be one of the
// five known dungeon types.
var validTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "imperium", "mine", "cult", "cave", "overgrown" };
foreach (var r in rooms)
{
Assert.True(validTypes.Contains(r.Type), $"room '{r.Id}' has invalid type '{r.Type}'");
Assert.NotEmpty(r.RolesEligible);
}
}
[Fact]
public void EveryRoomTemplate_HasGridMatchingFootprint()
{
var rooms = Loader().LoadRoomTemplates();
foreach (var r in rooms)
{
Assert.Equal(r.FootprintHTiles, r.Grid.Length);
for (int y = 0; y < r.Grid.Length; y++)
Assert.Equal(r.FootprintWTiles, r.Grid[y].Length);
}
}
[Fact]
public void EveryRoomTemplate_HasIntactPerimeter()
{
var rooms = Loader().LoadRoomTemplates();
foreach (var r in rooms)
{
int w = r.FootprintWTiles, h = r.FootprintHTiles;
for (int x = 0; x < w; x++)
{
Assert.True(IsPerimeterChar(r.Grid[0][x]),
$"room '{r.Id}' top perimeter ({x},0) is '{r.Grid[0][x]}'");
Assert.True(IsPerimeterChar(r.Grid[h - 1][x]),
$"room '{r.Id}' bottom perimeter ({x},{h - 1}) is '{r.Grid[h - 1][x]}'");
}
for (int y = 0; y < h; y++)
{
Assert.True(IsPerimeterChar(r.Grid[y][0]),
$"room '{r.Id}' left perimeter (0,{y}) is '{r.Grid[y][0]}'");
Assert.True(IsPerimeterChar(r.Grid[y][w - 1]),
$"room '{r.Id}' right perimeter ({w - 1},{y}) is '{r.Grid[y][w - 1]}'");
}
}
}
private static bool IsPerimeterChar(char c) => c == '#' || c == 'D' || c == 'S';
[Fact]
public void DungeonLayouts_LoadAndValidate()
{
var loader = Loader();
var rooms = loader.LoadRoomTemplates();
var loot = loader.LoadLootTables(loader.LoadItems());
var layouts = loader.LoadDungeonLayouts(rooms, loot);
// M0 vertical-slice: imperium_medium + mine_small = 2 layouts.
Assert.True(layouts.Length >= 2,
$"expected ≥2 dungeon layouts after Phase 7 M0, got {layouts.Length}");
// Every layout must declare a coherent room-count band.
foreach (var l in layouts)
{
Assert.True(l.RoomCountMin >= 1, $"layout '{l.Id}' room_count_min < 1");
Assert.True(l.RoomCountMax >= l.RoomCountMin, $"layout '{l.Id}' room_count_max < min");
}
}
[Fact]
public void EveryLayout_LootTableReferences_Resolve()
{
var loader = Loader();
var loot = loader.LoadLootTables(loader.LoadItems());
var layouts = loader.LoadDungeonLayouts(loader.LoadRoomTemplates(), loot);
var ids = new HashSet<string>(loot.Select(t => t.Id), StringComparer.OrdinalIgnoreCase);
foreach (var l in layouts)
foreach (var (band, table) in l.LootTablePerBand)
Assert.True(ids.Contains(table),
$"layout '{l.Id}' loot_table_per_band['{band}'] = '{table}' not in loot_tables.json");
}
}
@@ -0,0 +1,62 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Entities;
public sealed class ActorCharacterTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void SpawnPlayer_WithCharacter_AttachesIt()
{
var mgr = new ActorManager();
var character = MakeCharacter();
var p = mgr.SpawnPlayer(new Vec2(100, 200), character);
Assert.NotNull(p.Character);
Assert.Equal("Wolf-Folk", p.Character!.Species.Name);
Assert.Equal(Allegiance.Player, p.Allegiance);
}
[Fact]
public void SpawnPlayer_NoCharacter_LeavesItNull()
{
var mgr = new ActorManager();
var p = mgr.SpawnPlayer(new Vec2(100, 200));
Assert.Null(p.Character);
Assert.True(p.IsAlive); // null-character actors are considered alive
}
[Fact]
public void Actor_IsAlive_ReflectsCharacterHp()
{
var mgr = new ActorManager();
var character = MakeCharacter();
var p = mgr.SpawnPlayer(new Vec2(0, 0), character);
Assert.True(p.IsAlive);
character.CurrentHp = 0;
Assert.False(p.IsAlive);
character.Conditions.Add(Condition.Unconscious);
Assert.True(p.IsAlive); // unconscious counts as alive (death-save loop)
}
private Character MakeCharacter()
{
return new CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
Name = "Test",
}
.ChooseSkill(SkillId.Athletics)
.ChooseSkill(SkillId.Intimidation)
.Build();
}
}
@@ -0,0 +1,58 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Entities;
public sealed class NpcActorTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void Construct_AssignsHpAndAllegianceFromTemplate()
{
var t = _content.Npcs.Templates.First(x => x.Id == "wolf");
var npc = new NpcActor(t);
Assert.Equal(t.Hp, npc.CurrentHp);
Assert.Equal(t.Hp, npc.MaxHp);
Assert.Equal(Theriapolis.Core.Rules.Character.Allegiance.Hostile, npc.Allegiance);
Assert.Equal("wild_animal", npc.BehaviorId);
Assert.True(npc.IsAlive);
}
[Fact]
public void IsAlive_FalseAtZeroHp()
{
var npc = new NpcActor(_content.Npcs.Templates.First(x => x.Id == "wolf"));
npc.CurrentHp = 0;
Assert.False(npc.IsAlive);
}
[Fact]
public void ActorManager_SpawnNpc_GivesUniqueIdsAndTracksSource()
{
var mgr = new ActorManager();
mgr.SpawnPlayer(new Vec2(0, 0));
var t = _content.Npcs.Templates.First(x => x.Id == "wolf");
var coord = new ChunkCoord(3, 4);
var npc1 = mgr.SpawnNpc(t, new Vec2(10, 10), coord, sourceSpawnIndex: 0);
var npc2 = mgr.SpawnNpc(t, new Vec2(11, 10), coord, sourceSpawnIndex: 1);
Assert.NotEqual(npc1.Id, npc2.Id);
Assert.Equal(2, mgr.Npcs.Count());
Assert.Same(npc1, mgr.FindNpcBySource(coord, 0));
Assert.Same(npc2, mgr.FindNpcBySource(coord, 1));
}
[Fact]
public void ActorManager_RemoveActor_CleansUp()
{
var mgr = new ActorManager();
var t = _content.Npcs.Templates.First(x => x.Id == "wolf");
var npc = mgr.SpawnNpc(t, new Vec2(0, 0));
Assert.True(mgr.RemoveActor(npc.Id));
Assert.Empty(mgr.Npcs);
Assert.False(mgr.RemoveActor(npc.Id));
}
}
+214
View File
@@ -0,0 +1,214 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Xunit;
namespace Theriapolis.Tests.Entities;
/// <summary>
/// Phase 6.5 M6 — per-NPC scent profile data layer. Verifies the
/// ScentTag derivation: faction id → affiliation tag, runtime flags →
/// distress / activity tags, count truncation per the Scent Literacy
/// (top 1) vs Scent Mastery (top 3) tier.
/// </summary>
public sealed class ScentTagTests
{
// ── Faction → tag mapping ─────────────────────────────────────────────
[Theory]
[InlineData("maw", ScentTag.MawAffiliated)]
[InlineData("inheritors", ScentTag.InheritorAffiliated)]
[InlineData("thorn_council", ScentTag.ThornCouncilAffiliated)]
[InlineData("covenant_enforcers", ScentTag.CovenantEnforcerAffiliated)]
[InlineData("hybrid_underground", ScentTag.HybridUndergroundAffiliated)]
[InlineData("unsheathed", ScentTag.UnsheathedAffiliated)]
[InlineData("merchant_guilds", ScentTag.MerchantAffiliated)]
public void FromFactionId_MapsKnownFactions(string factionId, ScentTag expected)
{
Assert.Equal(expected, ScentTagExtensions.FromFactionId(factionId));
}
[Theory]
[InlineData("")]
[InlineData("not_a_real_faction")]
[InlineData("random_string")]
public void FromFactionId_ReturnsNoneForUnknown(string factionId)
{
Assert.Equal(ScentTag.None, ScentTagExtensions.FromFactionId(factionId));
}
[Fact]
public void FromFactionId_IsCaseInsensitive()
{
Assert.Equal(ScentTag.MawAffiliated, ScentTagExtensions.FromFactionId("MAW"));
Assert.Equal(ScentTag.InheritorAffiliated, ScentTagExtensions.FromFactionId("Inheritors"));
}
// ── ComputeScentTags: faction-derived ─────────────────────────────────
[Fact]
public void ComputeScentTags_LacroixSurfacesMawAffiliated()
{
// Lacroix scenario: faction=maw, full HP, no kills.
var npc = MakeResidentNpc("canidae", "coyote", factionId: "maw");
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Single(tags);
Assert.Equal(ScentTag.MawAffiliated, tags[0]);
}
[Fact]
public void ComputeScentTags_MerchantSurfacesMerchantAffiliated()
{
var npc = MakeResidentNpc("canidae", "fox", factionId: "merchant_guilds");
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Single(tags);
Assert.Equal(ScentTag.MerchantAffiliated, tags[0]);
}
[Fact]
public void ComputeScentTags_NoFactionEmpty()
{
var npc = MakeResidentNpc("canidae", "wolf", factionId: "");
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Empty(tags);
}
// ── ComputeScentTags: runtime-derived ─────────────────────────────────
[Fact]
public void ComputeScentTags_RecentlyKilledSurfaces()
{
var npc = MakeResidentNpc("canidae", "wolf", factionId: "");
npc.HasRecentlyKilled = true;
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Single(tags);
Assert.Equal(ScentTag.RecentlyKilled, tags[0]);
}
[Fact]
public void ComputeScentTags_FrightenedAtLowHp()
{
var npc = MakeResidentNpc("canidae", "wolf", factionId: "");
// Drop HP to 20% (below 25% threshold).
npc.CurrentHp = npc.MaxHp / 5;
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Single(tags);
Assert.Equal(ScentTag.Frightened, tags[0]);
}
[Fact]
public void ComputeScentTags_WoundedAtHalfHp()
{
var npc = MakeResidentNpc("canidae", "wolf", factionId: "");
// Drop HP to ~40% (below 50% but above 25%).
npc.CurrentHp = (int)(npc.MaxHp * 0.4f);
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Single(tags);
Assert.Equal(ScentTag.Wounded, tags[0]);
}
[Fact]
public void ComputeScentTags_DeadEmitsNoFrightened()
{
var npc = MakeResidentNpc("canidae", "wolf", factionId: "");
npc.CurrentHp = 0;
var tags = npc.ComputeScentTags(maxCount: 5);
// Dead NPCs don't carry distress markers (they're past distress).
Assert.DoesNotContain(ScentTag.Frightened, tags);
Assert.DoesNotContain(ScentTag.Wounded, tags);
}
[Fact]
public void ComputeScentTags_ContrabandFlagSurfaces()
{
var npc = MakeResidentNpc("canidae", "wolf", factionId: "");
npc.CarriesContrabandFlag = true;
var tags = npc.ComputeScentTags(maxCount: 5);
Assert.Contains(ScentTag.CarriesContraband, tags);
}
// ── ComputeScentTags: priority + truncation ───────────────────────────
[Fact]
public void ComputeScentTags_FactionTagWinsAtMaxCount1()
{
// Faction (priority 18) leads runtime tags (priority 16+) when
// truncating to a single read.
var npc = MakeResidentNpc("canidae", "coyote", factionId: "maw");
npc.HasRecentlyKilled = true;
npc.CarriesContrabandFlag = true;
npc.CurrentHp = npc.MaxHp / 5; // also Frightened
var tags = npc.ComputeScentTags(maxCount: 1);
Assert.Single(tags);
Assert.Equal(ScentTag.MawAffiliated, tags[0]);
}
[Fact]
public void ComputeScentTags_MasteryReadsUpToThree()
{
var npc = MakeResidentNpc("canidae", "coyote", factionId: "maw");
npc.HasRecentlyKilled = true;
npc.CurrentHp = npc.MaxHp / 5; // Frightened
npc.CarriesContrabandFlag = true;
var tags = npc.ComputeScentTags(maxCount: 3);
Assert.Equal(3, tags.Count);
// Order: faction (1), then runtime in declaration order.
Assert.Equal(ScentTag.MawAffiliated, tags[0]);
Assert.Equal(ScentTag.RecentlyKilled, tags[1]);
Assert.Equal(ScentTag.Frightened, tags[2]);
}
[Fact]
public void ComputeScentTags_MaxCountZeroReturnsEmpty()
{
var npc = MakeResidentNpc("canidae", "coyote", factionId: "maw");
npc.HasRecentlyKilled = true;
var tags = npc.ComputeScentTags(maxCount: 0);
Assert.Empty(tags);
}
[Fact]
public void ComputeScentTags_MaxCountFiveCapsAtAvailable()
{
// Only faction tag available; cap at 5 returns just the one.
var npc = MakeResidentNpc("canidae", "coyote", factionId: "maw");
var tags = npc.ComputeScentTags(maxCount: 5);
Assert.Single(tags);
}
// ── DisplayName ───────────────────────────────────────────────────────
[Fact]
public void DisplayName_ProducesReadableText()
{
Assert.Equal("Maw-affiliated", ScentTag.MawAffiliated.DisplayName());
Assert.Equal("Recently killed", ScentTag.RecentlyKilled.DisplayName());
Assert.Equal("Inheritor-affiliated", ScentTag.InheritorAffiliated.DisplayName());
Assert.Equal("Frightened", ScentTag.Frightened.DisplayName());
}
[Fact]
public void IsNarrative_TrueForFactionTags_FalseForRuntimeTags()
{
Assert.True(ScentTag.MawAffiliated.IsNarrative());
Assert.True(ScentTag.MerchantAffiliated.IsNarrative());
Assert.False(ScentTag.RecentlyKilled.IsNarrative());
Assert.False(ScentTag.Frightened.IsNarrative());
Assert.False(ScentTag.None.IsNarrative());
}
// ── Helpers ───────────────────────────────────────────────────────────
private NpcActor MakeResidentNpc(string clade, string species, string factionId)
{
var resident = new ResidentTemplateDef
{
Id = "test_npc",
Name = "Test NPC",
Clade = clade,
Species = species,
Faction = factionId,
Hp = 20,
};
return new NpcActor(resident) { Id = 1 };
}
}
@@ -0,0 +1,54 @@
using Theriapolis.Core.Time;
using Xunit;
namespace Theriapolis.Tests.Entities;
public sealed class WorldClockTests
{
[Fact]
public void Advance_AccumulatesSeconds()
{
var c = new WorldClock();
c.Advance(120);
c.Advance(30);
Assert.Equal(150, c.InGameSeconds);
}
[Fact]
public void DayHourMinute_DerivedFromSeconds()
{
var c = new WorldClock();
c.Advance(WorldClock.SecondsPerDay * 3 + WorldClock.SecondsPerHour * 14 + WorldClock.SecondsPerMinute * 27);
Assert.Equal(3, c.Day);
Assert.Equal(14, c.Hour);
Assert.Equal(27, c.Minute);
}
[Fact]
public void Season_RotatesWithDays()
{
var c = new WorldClock();
// Advance past one full season (24 days).
c.Advance(WorldClock.SecondsPerDay * WorldClock.DaysPerSeason + 1);
Assert.Equal(Season.Summer, c.Season);
}
[Fact]
public void RoundTrip_RestoresState()
{
var c = new WorldClock();
c.Advance(98765);
var s = c.CaptureState();
var c2 = new WorldClock();
c2.RestoreState(s);
Assert.Equal(98765, c2.InGameSeconds);
}
[Fact]
public void Advance_RejectsNegative()
{
var c = new WorldClock();
Assert.Throws<ArgumentOutOfRangeException>(() => c.Advance(-5));
}
}
@@ -0,0 +1,55 @@
using Theriapolis.Core;
using Theriapolis.Core.Entities;
using Theriapolis.Core.World;
using Xunit;
namespace Theriapolis.Tests.Entities;
public sealed class WorldTravelPlannerTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
private readonly WorldCache _cache;
public WorldTravelPlannerTests(WorldCache c) => _cache = c;
[Fact]
public void Plan_BetweenAdjacentSettlements_ReturnsConnectedPath()
{
var w = _cache.Get(TestSeed).World;
var inhabited = w.Settlements.Where(s => !s.IsPoi && s.Tier <= 3).Take(2).ToArray();
Assert.Equal(2, inhabited.Length);
var planner = new WorldTravelPlanner(w);
var path = planner.PlanTilePath(inhabited[0].TileX, inhabited[0].TileY,
inhabited[1].TileX, inhabited[1].TileY);
Assert.NotNull(path);
Assert.True(path!.Count >= 2);
// Endpoints match the request.
Assert.Equal((inhabited[0].TileX, inhabited[0].TileY), path[0]);
Assert.Equal((inhabited[1].TileX, inhabited[1].TileY), path[^1]);
// No teleporting — every adjacent pair is within Chebyshev 1.
for (int i = 1; i < path.Count; i++)
{
int dx = Math.Abs(path[i].X - path[i - 1].X);
int dy = Math.Abs(path[i].Y - path[i - 1].Y);
Assert.True(dx <= 1 && dy <= 1);
Assert.False(dx == 0 && dy == 0);
}
}
[Fact]
public void Plan_FromOcean_ReturnsNull()
{
var w = _cache.Get(TestSeed).World;
// Find an ocean tile and a land tile.
(int ox, int oy) = (-1, -1);
for (int y = 0; y < C.WORLD_HEIGHT_TILES && oy < 0; y++)
for (int x = 0; x < C.WORLD_WIDTH_TILES && oy < 0; x++)
if (w.TileAt(x, y).Biome == BiomeId.Ocean) { ox = x; oy = y; }
Assert.True(ox >= 0, "world should have at least one ocean tile");
var inhabited = w.Settlements.First(s => !s.IsPoi);
var planner = new WorldTravelPlanner(w);
var path = planner.PlanTilePath(ox, oy, inhabited.TileX, inhabited.TileY);
Assert.Null(path);
}
}
+130
View File
@@ -0,0 +1,130 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Items;
public sealed class InventoryTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void Add_AppendsItemAndIncreasesWeight()
{
var inv = new Inventory();
var sword = inv.Add(_content.Items["rend_sword"]);
Assert.Single(inv.Items);
Assert.Equal(_content.Items["rend_sword"].WeightLb, inv.TotalWeightLb);
Assert.Equal(1, sword.Qty);
}
[Fact]
public void TryEquip_PutsItemInRequestedSlot()
{
var inv = new Inventory();
var sword = inv.Add(_content.Items["rend_sword"]);
bool ok = inv.TryEquip(sword, EquipSlot.MainHand, out var err);
Assert.True(ok, err);
Assert.Equal(EquipSlot.MainHand, sword.EquippedAt);
Assert.Same(sword, inv.GetEquipped(EquipSlot.MainHand));
}
[Fact]
public void TryEquip_RefusesIfItemNotInInventory()
{
var inv = new Inventory();
var sword = new ItemInstance(_content.Items["rend_sword"]); // not added to inv
bool ok = inv.TryEquip(sword, EquipSlot.MainHand, out var err);
Assert.False(ok);
Assert.Contains("not in this inventory", err);
}
[Fact]
public void TryEquip_TwoHandedWeaponBlocksWhenOffHandOccupied()
{
var inv = new Inventory();
var shield = inv.Add(_content.Items["buckler"]);
var lance = inv.Add(_content.Items["gore_lance"]); // two_handed
Assert.True(inv.TryEquip(shield, EquipSlot.OffHand, out _));
bool ok = inv.TryEquip(lance, EquipSlot.MainHand, out var err);
Assert.False(ok);
Assert.Contains("two-handed", err.ToLowerInvariant());
}
[Fact]
public void TryEquip_OffHandBlockedWhenMainHandHoldsTwoHanded()
{
var inv = new Inventory();
var lance = inv.Add(_content.Items["gore_lance"]);
var shield = inv.Add(_content.Items["buckler"]);
Assert.True(inv.TryEquip(lance, EquipSlot.MainHand, out _));
bool ok = inv.TryEquip(shield, EquipSlot.OffHand, out var err);
Assert.False(ok);
Assert.Contains("two-handed", err.ToLowerInvariant());
}
[Fact]
public void TryEquip_NaturalWeaponEnhancerRequiresMatchingSlot()
{
var inv = new Inventory();
var fangCaps = inv.Add(_content.Items["fang_caps_steel"]);
// Wrong slot:
bool ok = inv.TryEquip(fangCaps, EquipSlot.NaturalWeaponClaw, out var err);
Assert.False(ok);
Assert.Contains("fits", err.ToLowerInvariant());
// Correct slot:
Assert.True(inv.TryEquip(fangCaps, EquipSlot.NaturalWeaponFang, out _));
}
[Fact]
public void TryUnequip_ClearsSlot()
{
var inv = new Inventory();
var sword = inv.Add(_content.Items["rend_sword"]);
inv.TryEquip(sword, EquipSlot.MainHand, out _);
Assert.True(inv.TryUnequip(EquipSlot.MainHand, out _));
Assert.Null(sword.EquippedAt);
Assert.Null(inv.GetEquipped(EquipSlot.MainHand));
}
[Fact]
public void Remove_AlsoUnregistersFromEquippedSlot()
{
var inv = new Inventory();
var sword = inv.Add(_content.Items["rend_sword"]);
inv.TryEquip(sword, EquipSlot.MainHand, out _);
Assert.True(inv.Remove(sword));
Assert.Empty(inv.Items);
Assert.Null(inv.GetEquipped(EquipSlot.MainHand));
}
// ── SizeMatch ────────────────────────────────────────────────────────
[Fact]
public void SizeMatch_DirectFitReturnsMatch()
{
var rendSword = _content.Items["rend_sword"]; // medium + large
Assert.Equal(SizeMatch.MatchResult.Match, SizeMatch.Check(rendSword, SizeCategory.Medium));
}
[Fact]
public void SizeMatch_AdaptivePropertyOverridesMissingSize()
{
var pack = _content.Items["adaptive_pack"];
// adaptive_pack lists s/m/l explicitly, so match — but we test the Adaptive
// path by querying with a Tiny wearer that's not in the list.
var result = SizeMatch.Check(pack, SizeCategory.Tiny);
// adaptive_pack lists "small" so Tiny falls through to "tiny" not in list,
// but its "adaptive" property kicks in for the Adaptive branch.
Assert.NotEqual(SizeMatch.MatchResult.WrongSize, result);
}
[Fact]
public void SizeMatch_NonAdaptiveWrongSizeIsFlagged()
{
var rendSword = _content.Items["rend_sword"]; // medium + large only
// Small wearer: not in sizes, no adaptive property → WrongSize.
Assert.Equal(SizeMatch.MatchResult.WrongSize, SizeMatch.Check(rendSword, SizeCategory.Small));
}
}
+81
View File
@@ -0,0 +1,81 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Loot;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Loot;
public sealed class LootRollerTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void LootTables_LoadCleanly()
{
Assert.True(_content.LootTables.Count >= 9, $"expected ≥9 loot tables, got {_content.LootTables.Count}");
Assert.Contains("loot_brigand_low", _content.LootTables.Keys);
Assert.Contains("loot_brigand_high", _content.LootTables.Keys);
Assert.Contains("loot_wild_low", _content.LootTables.Keys);
}
[Fact]
public void Roll_UnknownTable_ReturnsEmpty()
{
var rng = new SeededRng(0xCAFEUL);
var drops = LootRoller.Roll("not_a_table", _content.LootTables, _content.Items, rng);
Assert.Empty(drops);
}
[Fact]
public void Roll_EmptyTableId_ReturnsEmpty()
{
var rng = new SeededRng(0xCAFEUL);
var drops = LootRoller.Roll("", _content.LootTables, _content.Items, rng);
Assert.Empty(drops);
}
[Fact]
public void Roll_SameSeed_ProducesSameDrops()
{
var a = LootRoller.Roll("loot_brigand_high", _content.LootTables, _content.Items, new SeededRng(42UL));
var b = LootRoller.Roll("loot_brigand_high", _content.LootTables, _content.Items, new SeededRng(42UL));
Assert.Equal(a.Count, b.Count);
for (int i = 0; i < a.Count; i++)
{
Assert.Equal(a[i].Def.Id, b[i].Def.Id);
Assert.Equal(a[i].Qty, b[i].Qty);
}
}
[Fact]
public void Roll_QtyAlwaysWithinBounds()
{
// Roll many times; verify no result has qty outside the table-defined range.
var table = _content.LootTables["loot_brigand_high"];
var bounds = table.Drops.ToDictionary(d => d.ItemId, d => (d.QtyMin, d.QtyMax));
for (int seed = 0; seed < 50; seed++)
{
var drops = LootRoller.Roll("loot_brigand_high", _content.LootTables, _content.Items, new SeededRng((ulong)seed));
foreach (var drop in drops)
{
var (mn, mx) = bounds[drop.Def.Id];
Assert.InRange(drop.Qty, mn, mx);
}
}
}
[Fact]
public void Roll_OneHundredSamples_AverageDropCountIsReasonable()
{
// The "high" brigand table has 5 drops with cumulative chance summing
// around 1.95. Across 100 samples expect ≥50 total drops.
int totalDrops = 0;
for (int seed = 0; seed < 100; seed++)
{
var drops = LootRoller.Roll("loot_brigand_high", _content.LootTables, _content.Items, new SeededRng((ulong)seed));
totalDrops += drops.Count;
}
Assert.True(totalDrops >= 50, $"expected ≥50 drops in 100 samples, got {totalDrops}");
}
}
@@ -0,0 +1,93 @@
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// End-to-end M5 smoke test:
/// 1. Generate a chunk via the streamer.
/// 2. Mutate a tile (chop a tree → set Deco=None).
/// 3. Flush → save → load.
/// 4. Re-stream the chunk and verify the mutation is still applied.
///
/// This is the single most important save-correctness test — exercises the
/// streamer/delta-store/codec/restore loop in one shot.
/// </summary>
public sealed class DeltaPersistenceTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
private readonly WorldCache _cache;
public DeltaPersistenceTests(WorldCache c) => _cache = c;
[Fact]
public void ChopTree_PersistsAcrossSaveLoad()
{
var w = _cache.Get(TestSeed).World;
// Find a chunk + tile that the baseline generator put a tree on.
var (cc, lx, ly) = FindTreeTile(w);
var deltasA = new InMemoryChunkDeltaStore();
var streamerA = new ChunkStreamer(TestSeed, w, deltasA);
var chunk = streamerA.Get(cc);
Assert.Equal(TacticalDeco.Tree, chunk.Tiles[lx, ly].Deco);
// Chop it.
chunk.Tiles[lx, ly].Deco = TacticalDeco.None;
chunk.HasDelta = true;
// Flush + save.
streamerA.FlushAll();
var header = new SaveHeader { WorldSeedHex = $"0x{TestSeed:X}" };
var body = new SaveBody { Clock = new() { InGameSeconds = 0 } };
body.Player = new() { Name = "Tester" };
foreach (var kv in deltasA.All) body.ModifiedChunks[kv.Key] = kv.Value;
var bytes = SaveCodec.Serialize(header, body);
// ── Load on a fresh streamer ──
var (_, rb) = SaveCodec.Deserialize(bytes);
var deltasB = new InMemoryChunkDeltaStore();
foreach (var kv in rb.ModifiedChunks) deltasB.Put(kv.Key, kv.Value);
var streamerB = new ChunkStreamer(TestSeed, w, deltasB);
var reloaded = streamerB.Get(cc);
Assert.Equal(TacticalDeco.None, reloaded.Tiles[lx, ly].Deco);
}
[Fact]
public void UnmodifiedChunk_HasNoDelta()
{
var w = _cache.Get(TestSeed).World;
var deltas = new InMemoryChunkDeltaStore();
var streamer = new ChunkStreamer(TestSeed, w, deltas);
// Touch a chunk without modifying it.
var cc = new ChunkCoord(20, 20);
streamer.Get(cc);
streamer.FlushAll();
Assert.Null(deltas.Get(cc));
}
private static (ChunkCoord cc, int lx, int ly) FindTreeTile(WorldState w)
{
// Walk a band of chunks centred near a known land settlement so we
// don't waste time scanning ocean.
var anchor = w.Settlements.First(s => !s.IsPoi && s.Tier <= 3);
var centre = ChunkCoord.ForWorldTile(anchor.TileX, anchor.TileY);
for (int dy = -3; dy <= 3; dy++)
for (int dx = -3; dx <= 3; dx++)
{
var cc = new ChunkCoord(centre.X + dx, centre.Y + dy);
var chunk = TacticalChunkGen.Generate(0xCAFEBABEUL, cc, w);
for (int ly = 0; ly < C.TACTICAL_CHUNK_SIZE; ly++)
for (int lx = 0; lx < C.TACTICAL_CHUNK_SIZE; lx++)
if (chunk.Tiles[lx, ly].Deco == TacticalDeco.Tree)
return (cc, lx, ly);
}
throw new Xunit.Sdk.XunitException("no tree near settlement to chop in test");
}
}
@@ -0,0 +1,141 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// Phase 6.5 M0 — level-up history + subclass + learned-features round-trip.
/// Save a character at level 4 (with subclass + ASI history); load; assert
/// every per-level delta survives.
/// </summary>
public sealed class LevelUpRoundTripTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
private Character MakeWolfFangsworn()
{
var b = new CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 12, 13, 10, 13, 8),
Name = "Tester",
};
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();
}
private void LevelTo(Character c, int target)
{
while (c.Level < target)
{
int next = c.Level + 1;
ulong seed = 0xCAFE_F00D_CAFE_F00DUL ^ (ulong)next;
var r = LevelUpFlow.Compute(c, next, seed, takeAverage: true);
var ch = new LevelUpChoices { TakeAverageHp = true };
if (r.GrantsSubclassChoice && c.ClassDef.SubclassIds.Length > 0)
ch.SubclassId = c.ClassDef.SubclassIds[0];
if (r.GrantsAsiChoice)
ch.AsiAdjustments = new() { { AbilityId.STR, 2 } };
c.ApplyLevelUp(r, ch);
}
}
[Fact]
public void Character_AtLevel4_RoundTripsThroughCharacterCodec()
{
var c = MakeWolfFangsworn();
c.Xp = 6_500; // beyond level 4 threshold
LevelTo(c, target: 4);
Assert.Equal(4, c.Level);
Assert.NotEmpty(c.SubclassId); // L3 picker fired
Assert.NotEmpty(c.LearnedFeatureIds);
Assert.Equal(3, c.LevelUpHistory.Count); // L2, L3, L4
var snap = CharacterCodec.Capture(c);
var restored = CharacterCodec.Restore(snap, _content);
Assert.Equal(4, restored.Level);
Assert.Equal(c.SubclassId, restored.SubclassId);
Assert.Equal(c.MaxHp, restored.MaxHp);
Assert.Equal(c.LearnedFeatureIds.Count, restored.LearnedFeatureIds.Count);
Assert.Equal(c.LearnedFeatureIds, restored.LearnedFeatureIds);
Assert.Equal(c.LevelUpHistory.Count, restored.LevelUpHistory.Count);
for (int i = 0; i < c.LevelUpHistory.Count; i++)
{
var a = c.LevelUpHistory[i];
var b = restored.LevelUpHistory[i];
Assert.Equal(a.Level, b.Level);
Assert.Equal(a.HpGained, b.HpGained);
Assert.Equal(a.HpWasAveraged, b.HpWasAveraged);
Assert.Equal(a.HpHitDieResult, b.HpHitDieResult);
Assert.Equal(a.SubclassChosen, b.SubclassChosen);
Assert.Equal(a.FeaturesUnlocked, b.FeaturesUnlocked);
Assert.Equal(a.AsiAdjustmentsKeys, b.AsiAdjustmentsKeys);
Assert.Equal(a.AsiAdjustmentsValues, b.AsiAdjustmentsValues);
}
// ASI raised STR — level-4 history entry should record +2 STR.
var lv4 = c.LevelUpHistory[^1];
Assert.Equal(4, lv4.Level);
Assert.Single(lv4.AsiAdjustmentsValues);
Assert.Equal(2, lv4.AsiAdjustmentsValues[0]);
// And the ability is preserved across the round-trip.
Assert.Equal(c.Abilities.STR, restored.Abilities.STR);
}
[Fact]
public void Character_AtLevel4_RoundTripsThroughBinarySaveCodec()
{
var c = MakeWolfFangsworn();
c.Xp = 6_500;
LevelTo(c, target: 4);
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION, WorldSeedHex = "0xFEED" };
var body = new SaveBody { PlayerCharacter = CharacterCodec.Capture(c) };
body.Player.Id = 1;
body.Player.Name = "Tester";
var bytes = SaveCodec.Serialize(header, body);
var (h2, body2) = SaveCodec.Deserialize(bytes);
Assert.Equal(header.Version, h2.Version);
Assert.NotNull(body2.PlayerCharacter);
Assert.Equal(4, body2.PlayerCharacter!.Level);
Assert.NotEmpty(body2.PlayerCharacter.SubclassId);
Assert.Equal(3, body2.PlayerCharacter.LevelUpHistory.Length);
Assert.Equal(c.LearnedFeatureIds.Count, body2.PlayerCharacter.LearnedFeatureIds.Length);
}
[Fact]
public void V6Save_WithoutLevelUpFields_LoadsAsLevel1Character()
{
// Simulate a v6 save by writing a character without the v7 trailing
// fields. Easiest path: hand-construct a minimal PlayerCharacterState
// (the codec's EOS-check pattern handles missing trailing data on read).
var c = MakeWolfFangsworn();
var snap = CharacterCodec.Capture(c);
// Force the v7 fields to defaults to simulate a v6 save.
snap.SubclassId = "";
snap.LearnedFeatureIds = Array.Empty<string>();
snap.LevelUpHistory = Array.Empty<LevelUpRecordState>();
var restored = CharacterCodec.Restore(snap, _content);
Assert.Equal(1, restored.Level);
Assert.Empty(restored.SubclassId);
Assert.Empty(restored.LevelUpHistory);
}
}
@@ -0,0 +1,163 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// Phase 5 plan §5: same (worldSeed, encounterId) → identical dice stream;
/// save mid-encounter, load, continue, byte-identical outcome.
///
/// We exercise the codec layer + Encounter.ResumeRolls — the live game's
/// CombatHUD wires these together but the determinism contract belongs to
/// the Core layer and tests independently of MonoGame.
/// </summary>
public sealed class MidCombatSaveRoundTripTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void EncounterState_RoundTripsThroughSaveCodec()
{
var enc = MakeEncounter(0xCAFEUL);
// Burn 5 d20s + 1 attack (mutates participants).
for (int i = 0; i < 5; i++) enc.RollD20();
var attacker = enc.Participants[0];
var target = enc.Participants[1];
Resolver.AttemptAttack(enc, attacker, target, attacker.AttackOptions[0]);
var snapshot = SnapshotEncounter(enc);
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION };
var body = new SaveBody { ActiveEncounter = snapshot };
body.PlayerCharacter = new PlayerCharacterState(); // make body well-formed for codec
var bytes = SaveCodec.Serialize(header, body);
var (_, body2) = SaveCodec.Deserialize(bytes);
Assert.NotNull(body2.ActiveEncounter);
Assert.Equal(snapshot.EncounterId, body2.ActiveEncounter!.EncounterId);
Assert.Equal(snapshot.RollCount, body2.ActiveEncounter.RollCount);
Assert.Equal(snapshot.RoundNumber, body2.ActiveEncounter.RoundNumber);
Assert.Equal(snapshot.Combatants.Length, body2.ActiveEncounter.Combatants.Length);
for (int i = 0; i < snapshot.Combatants.Length; i++)
{
Assert.Equal(snapshot.Combatants[i].Id, body2.ActiveEncounter.Combatants[i].Id);
Assert.Equal(snapshot.Combatants[i].CurrentHp, body2.ActiveEncounter.Combatants[i].CurrentHp);
Assert.Equal(snapshot.Combatants[i].PositionX, body2.ActiveEncounter.Combatants[i].PositionX);
}
}
[Fact]
public void Encounter_ResumedAtRollCount_ProducesIdenticalDownstreamLog()
{
// Setup: build two identical encounters from the same seed.
var encA = MakeEncounter(0xDEADUL);
var encB = MakeEncounter(0xDEADUL);
Assert.Equal(encA.EncounterSeed, encB.EncounterSeed);
// Run encA for several attacks, then snapshot its rollcount.
var atkA = encA.Participants[0];
var defA = encA.Participants[1];
for (int i = 0; i < 3; i++)
Resolver.AttemptAttack(encA, atkA, defA, atkA.AttackOptions[0]);
int snapshotRollCount = encA.RollCount;
int snapshotHp = defA.CurrentHp;
// Resume encB at the same point — defB starts at full HP, copy the
// mutated HP from defA so they're at the same state.
encB.ResumeRolls(snapshotRollCount);
encB.Participants[1].CurrentHp = snapshotHp;
// Continue both encounters in lockstep and compare each roll outcome.
var atkB = encB.Participants[0];
var defB = encB.Participants[1];
for (int i = 0; i < 5; i++)
{
int hpBeforeA = defA.CurrentHp;
int hpBeforeB = defB.CurrentHp;
var resA = Resolver.AttemptAttack(encA, atkA, defA, atkA.AttackOptions[0]);
var resB = Resolver.AttemptAttack(encB, atkB, defB, atkB.AttackOptions[0]);
Assert.Equal(resA.D20Roll, resB.D20Roll);
Assert.Equal(resA.Hit, resB.Hit);
Assert.Equal(resA.DamageRolled, resB.DamageRolled);
Assert.Equal(defA.CurrentHp, defB.CurrentHp);
}
}
[Fact]
public void NpcRoster_RoundTripsThroughSaveCodec()
{
var roster = new NpcRosterState();
roster.ChunkDeltas.Add(new NpcChunkDelta
{
ChunkX = 5, ChunkY = -3,
KilledSpawnIndices = new[] { 1, 2, 7 },
});
roster.ChunkDeltas.Add(new NpcChunkDelta
{
ChunkX = 0, ChunkY = 0,
KilledSpawnIndices = new[] { 0 },
});
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION };
var body = new SaveBody { NpcRoster = roster };
body.PlayerCharacter = new PlayerCharacterState();
var bytes = SaveCodec.Serialize(header, body);
var (_, body2) = SaveCodec.Deserialize(bytes);
Assert.Equal(2, body2.NpcRoster.ChunkDeltas.Count);
Assert.Equal(5, body2.NpcRoster.ChunkDeltas[0].ChunkX);
Assert.Equal(-3, body2.NpcRoster.ChunkDeltas[0].ChunkY);
Assert.Equal(new[] { 1, 2, 7 }, body2.NpcRoster.ChunkDeltas[0].KilledSpawnIndices);
Assert.Equal(new[] { 0 }, body2.NpcRoster.ChunkDeltas[1].KilledSpawnIndices);
}
private Encounter MakeEncounter(ulong worldSeed)
{
var brigand = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad")
with { DefaultAllegiance = "player" };
var wolf = _content.Npcs.Templates.First(t => t.Id == "wolf");
var hero = Combatant.FromNpcTemplate(brigand, id: 1, new Vec2(0, 0));
var foe = Combatant.FromNpcTemplate(wolf, id: 2, new Vec2(1, 0));
return new Encounter(worldSeed, encounterId: 7, new[] { hero, foe });
}
/// <summary>
/// Build an EncounterState from a live encounter — mirrors what
/// <see cref="Game.Screens.CombatHUDScreen.SnapshotForSave"/> does in
/// the game side, but inlined here so this test stays Core-only.
/// </summary>
private static EncounterState SnapshotEncounter(Encounter enc)
{
var snaps = new CombatantSnapshot[enc.Participants.Count];
for (int i = 0; i < enc.Participants.Count; i++)
{
var c = enc.Participants[i];
snaps[i] = new CombatantSnapshot
{
Id = c.Id,
Name = c.Name,
IsPlayer = c.SourceCharacter is not null,
NpcTemplateId = c.SourceTemplate?.Id ?? "",
CurrentHp = c.CurrentHp,
PositionX = c.Position.X,
PositionY = c.Position.Y,
Conditions = c.Conditions.Select(x => (byte)x).ToArray(),
};
}
var initOrder = new int[enc.InitiativeOrder.Count];
for (int i = 0; i < initOrder.Length; i++) initOrder[i] = enc.InitiativeOrder[i];
return new EncounterState
{
EncounterId = enc.EncounterId,
RollCount = enc.RollCount,
CurrentTurnIndex = enc.CurrentTurnIndex,
RoundNumber = enc.RoundNumber,
InitiativeOrder = initOrder,
Combatants = snaps,
};
}
}
@@ -0,0 +1,104 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Persistence;
public sealed class Phase5SaveRoundTripTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void Character_RoundTripsThroughCharacterCodec()
{
var c = MakeBasicCharacter();
// Add and equip an item so the codec hits the inventory + equip-slot path.
var sword = c.Inventory.Add(_content.Items["rend_sword"]);
c.Inventory.TryEquip(sword, EquipSlot.MainHand, out _);
c.Conditions.Add(Condition.Frightened);
c.ExhaustionLevel = 2;
c.CurrentHp = c.MaxHp - 3;
c.Xp = 142;
var snap = CharacterCodec.Capture(c);
var restored = CharacterCodec.Restore(snap, _content);
Assert.Equal(c.Clade.Id, restored.Clade.Id);
Assert.Equal(c.Species.Id, restored.Species.Id);
Assert.Equal(c.ClassDef.Id, restored.ClassDef.Id);
Assert.Equal(c.Background.Id, restored.Background.Id);
Assert.Equal(c.Abilities.STR, restored.Abilities.STR);
Assert.Equal(c.Abilities.WIS, restored.Abilities.WIS);
Assert.Equal(c.Level, restored.Level);
Assert.Equal(c.Xp, restored.Xp);
Assert.Equal(c.MaxHp, restored.MaxHp);
Assert.Equal(c.CurrentHp, restored.CurrentHp);
Assert.Equal(c.ExhaustionLevel, restored.ExhaustionLevel);
Assert.Contains(Condition.Frightened, restored.Conditions);
Assert.Single(restored.Inventory.Items);
Assert.Equal("rend_sword", restored.Inventory.Items[0].Def.Id);
Assert.Equal(EquipSlot.MainHand, restored.Inventory.Items[0].EquippedAt);
Assert.Same(restored.Inventory.Items[0], restored.Inventory.GetEquipped(EquipSlot.MainHand));
// Skill set should match (set equality)
Assert.Equal(c.SkillProficiencies, restored.SkillProficiencies);
}
[Fact]
public void SaveBody_PlayerCharacter_RoundTripsThroughSaveCodec()
{
var c = MakeBasicCharacter();
var snap = CharacterCodec.Capture(c);
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION, WorldSeedHex = "0xFEED" };
var body = new SaveBody { PlayerCharacter = snap };
// Player is not interesting for this test; clock + chunks empty.
body.Player.Id = 1;
body.Player.Name = "Tester";
var bytes = SaveCodec.Serialize(header, body);
var (h2, body2) = SaveCodec.Deserialize(bytes);
Assert.Equal(header.Version, h2.Version);
Assert.NotNull(body2.PlayerCharacter);
Assert.Equal(snap.CladeId, body2.PlayerCharacter!.CladeId);
Assert.Equal(snap.SpeciesId, body2.PlayerCharacter.SpeciesId);
Assert.Equal(snap.ClassId, body2.PlayerCharacter.ClassId);
Assert.Equal(snap.STR, body2.PlayerCharacter.STR);
Assert.Equal(snap.WIS, body2.PlayerCharacter.WIS);
Assert.Equal(snap.MaxHp, body2.PlayerCharacter.MaxHp);
}
[Fact]
public void SaveBody_NoCharacter_StillRoundTrips()
{
// Phase-4-style body with no character. Round-trips cleanly because
// TAG_CHARACTER is only written when PlayerCharacter is non-null.
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION, WorldSeedHex = "0xDEAD" };
var body = new SaveBody();
var bytes = SaveCodec.Serialize(header, body);
var (_, body2) = SaveCodec.Deserialize(bytes);
Assert.Null(body2.PlayerCharacter);
}
private Character MakeBasicCharacter()
{
var b = new CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
Name = "Roundtrip",
};
// Class.SkillsChoose = 2 for fangsworn
b.ChosenClassSkills.Add(SkillId.Athletics);
b.ChosenClassSkills.Add(SkillId.Intimidation);
return b.Build();
}
}
@@ -0,0 +1,152 @@
using Theriapolis.Core;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Time;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// SaveCodec round-trip: every field we write should reappear after Serialize
/// → Deserialize. The Phase-4 reserved fields (Flags, Factions, etc.) are
/// covered with at least one entry each so future schema bumps notice if a
/// section gets accidentally dropped.
/// </summary>
public sealed class SaveCodecRoundTripTests
{
[Fact]
public void Roundtrip_PreservesPlayerAndClock()
{
var header = new SaveHeader
{
WorldSeedHex = "0xDEADBEEF",
PlayerName = "Grev",
PlayerTier = 2,
InGameSeconds = 12345,
SavedAtUtc = "2026-04-21T09:00:00Z",
};
header.StageHashes["ElevationGen"] = "0xABCDEF01";
header.StageHashes["BiomeAssign"] = "0x11223344";
var body = new SaveBody
{
Player = new PlayerActorState
{
Id = 7,
Name = "Grev",
PositionX = 1234.5f, PositionY = 678.9f,
FacingAngleRad = MathF.PI * 0.5f,
SpeedWorldPxPerSec = 80f,
HighestTierReached = 2,
DiscoveredPoiIds = new[] { 3, 14, 159 },
},
Clock = new WorldClockState { InGameSeconds = 12345 },
};
body.Flags["acts:1:complete"] = 1;
var bytes = SaveCodec.Serialize(header, body);
var (rh, rb) = SaveCodec.Deserialize(bytes);
Assert.Equal(header.WorldSeedHex, rh.WorldSeedHex);
Assert.Equal(header.PlayerName, rh.PlayerName);
Assert.Equal(2, rh.StageHashes.Count);
Assert.Equal("0xABCDEF01", rh.StageHashes["ElevationGen"]);
Assert.Equal(7, rb.Player.Id);
Assert.Equal("Grev", rb.Player.Name);
Assert.Equal(1234.5f, rb.Player.PositionX);
Assert.Equal(MathF.PI * 0.5f, rb.Player.FacingAngleRad);
Assert.Equal(2, rb.Player.HighestTierReached);
Assert.Equal(new[] { 3, 14, 159 }, rb.Player.DiscoveredPoiIds);
Assert.Equal(12345L, rb.Clock.InGameSeconds);
Assert.Equal(1, rb.Flags["acts:1:complete"]);
}
[Fact]
public void Roundtrip_PreservesChunkDeltas()
{
var header = new SaveHeader { WorldSeedHex = "0x1" };
var body = new SaveBody
{
Player = new PlayerActorState { Name = "X" },
Clock = new WorldClockState { InGameSeconds = 0 },
};
var d = new ChunkDelta { SpawnsConsumed = true };
d.TileMods.Add(new TileMod(5, 7, TacticalSurface.Cobble, TacticalDeco.None, (byte)TacticalFlags.Road));
d.TileMods.Add(new TileMod(8, 8, TacticalSurface.Mud, TacticalDeco.Boulder, 0));
body.ModifiedChunks[new ChunkCoord(3, 4)] = d;
var bytes = SaveCodec.Serialize(header, body);
var (_, rb) = SaveCodec.Deserialize(bytes);
Assert.True(rb.ModifiedChunks.ContainsKey(new ChunkCoord(3, 4)));
var rd = rb.ModifiedChunks[new ChunkCoord(3, 4)];
Assert.True(rd.SpawnsConsumed);
Assert.Equal(2, rd.TileMods.Count);
Assert.Equal(TacticalSurface.Cobble, rd.TileMods[0].Surface);
Assert.Equal(TacticalDeco.Boulder, rd.TileMods[1].Deco);
}
[Fact]
public void Roundtrip_PreservesWorldTileDeltas()
{
var header = new SaveHeader { WorldSeedHex = "0x2" };
var body = new SaveBody
{
Player = new PlayerActorState { Name = "Y" },
Clock = new WorldClockState { InGameSeconds = 0 },
ModifiedWorldTiles = { new WorldTileDelta(50, 80, 7, 0xAB) },
};
var bytes = SaveCodec.Serialize(header, body);
var (_, rb) = SaveCodec.Deserialize(bytes);
Assert.Single(rb.ModifiedWorldTiles);
Assert.Equal(50, rb.ModifiedWorldTiles[0].X);
Assert.Equal(80, rb.ModifiedWorldTiles[0].Y);
Assert.Equal(7, rb.ModifiedWorldTiles[0].NewBiome);
Assert.Equal(0xAB, rb.ModifiedWorldTiles[0].NewFeatures);
}
[Fact]
public void DeserializeHeaderOnly_DoesNotTouchBody()
{
var header = new SaveHeader { WorldSeedHex = "0xCAFE", PlayerName = "Solo" };
var body = new SaveBody
{
Player = new PlayerActorState { Id = 99, Name = "Solo" },
Clock = new WorldClockState { InGameSeconds = 1 },
};
var bytes = SaveCodec.Serialize(header, body);
var only = SaveCodec.DeserializeHeaderOnly(bytes);
Assert.Equal("0xCAFE", only.WorldSeedHex);
Assert.Equal("Solo", only.PlayerName);
}
[Fact]
public void Roundtrip_HandlesEmptyBody()
{
var header = new SaveHeader { WorldSeedHex = "0x0" };
var body = new SaveBody();
var bytes = SaveCodec.Serialize(header, body);
var (rh, rb) = SaveCodec.Deserialize(bytes);
Assert.Equal("0x0", rh.WorldSeedHex);
Assert.Equal(0L, rb.Clock.InGameSeconds);
Assert.Empty(rb.ModifiedChunks);
Assert.Empty(rb.ModifiedWorldTiles);
}
[Fact]
public void SchemaVersion_IsCurrent()
{
// Bumped to 8 in Phase 7 M0 (Phase 6.5 was 7; Phase 6 was 6; Phase 5
// was 5; Phase 4 was 4). The header auto-stamps the current schema
// version on construction so a save written today carries the latest
// version. Each bump must add a chained ISaveMigration in
// <see cref="Theriapolis.Core.Persistence.SaveMigrations.Migrations"/>.
Assert.Equal(8, C.SAVE_SCHEMA_VERSION);
var h = new SaveHeader();
Assert.Equal(C.SAVE_SCHEMA_VERSION, h.Version);
}
}
@@ -0,0 +1,46 @@
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// Phase 5 M2 refuses Phase-4 saves rather than auto-instantiating a default
/// Character. Verified via <see cref="SaveCodec.IsCompatible"/> +
/// <see cref="SaveCodec.IncompatibilityReason"/>.
/// </summary>
public sealed class V4ToV5MigrationTests
{
[Fact]
public void V4Header_IsRejectedAsIncompatible()
{
var header = new SaveHeader { Version = 4 };
Assert.False(SaveCodec.IsCompatible(header));
Assert.NotEmpty(SaveCodec.IncompatibilityReason(header));
}
[Fact]
public void V5Header_IsCompatible()
{
var header = new SaveHeader { Version = 5 };
Assert.True(SaveCodec.IsCompatible(header));
Assert.Empty(SaveCodec.IncompatibilityReason(header));
}
[Fact]
public void NewSaveHeader_AutoSetsCurrentSchemaVersion()
{
var header = new SaveHeader();
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
Assert.True(header.Version >= C.SAVE_SCHEMA_MIN_VERSION);
}
[Fact]
public void IncompatibilityReason_MentionsVersionInformation()
{
var header = new SaveHeader { Version = 3 };
var reason = SaveCodec.IncompatibilityReason(header);
Assert.Contains("v3", reason);
Assert.Contains("v" + C.SAVE_SCHEMA_MIN_VERSION, reason);
}
}
@@ -0,0 +1,74 @@
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Persistence.SaveMigrations;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// Phase 6 M2 — additive V5→V6 migration. Unlike V4→V5 (rejection), v5
/// saves are accepted and migrated up by zero-filling the new typed
/// reputation containers.
/// </summary>
public sealed class V5ToV6MigrationTests
{
[Fact]
public void V5Header_IsAcceptedByMigration()
{
var header = new SaveHeader { Version = 5 };
Assert.True(SaveCodec.IsCompatible(header),
"v5 must remain readable post-Phase-6 (MIN_VERSION = 5).");
var body = new SaveBody();
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
}
[Fact]
public void V6_NewBody_HasEmptyReputationState()
{
var body = new SaveBody();
Assert.NotNull(body.ReputationState);
Assert.Empty(body.ReputationState.FactionStandings);
Assert.Empty(body.ReputationState.Personal);
Assert.Empty(body.ReputationState.Ledger);
}
[Fact]
public void V5SaveBody_AfterMigration_HasEmptyReputation()
{
// Construct what a Phase-5-saved body might look like — v5 fields
// (Player, Clock, ModifiedChunks, ModifiedWorldTiles, Flags,
// PlayerCharacter, NpcRoster, ActiveEncounter), no v6 fields.
var header = new SaveHeader { Version = 5 };
var body = new SaveBody
{
Player = new() { Name = "Old Hand", PositionX = 50, PositionY = 50 },
};
body.Flags["debug-flag"] = 1;
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
// Migration chains all the way up to the current schema version.
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
// V5 fields must survive untouched.
Assert.Equal("Old Hand", body.Player.Name);
Assert.Equal(1, body.Flags["debug-flag"]);
// V6 fields must be empty (the migration does not synthesise data).
Assert.NotNull(body.ReputationState);
Assert.Empty(body.ReputationState.FactionStandings);
Assert.Empty(body.ReputationState.Personal);
}
[Fact]
public void NewSaveHeader_DefaultsToCurrentSchemaVersion()
{
// Schema version increments per phase (Phase 6 = v6, Phase 6.5 = v7);
// SaveHeader picks up the constant.
var header = new SaveHeader();
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
}
}
@@ -0,0 +1,71 @@
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Persistence.SaveMigrations;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// Phase 6.5 M0 — additive V6→V7 migration. Every v6 field carries over
/// unchanged; the new <see cref="PlayerCharacterState.SubclassId"/>,
/// <see cref="PlayerCharacterState.LearnedFeatureIds"/>, and
/// <see cref="PlayerCharacterState.LevelUpHistory"/> default-initialise
/// to empty values.
/// </summary>
public sealed class V6ToV7MigrationTests
{
[Fact]
public void V6Header_IsAcceptedByMigration()
{
var header = new SaveHeader { Version = 6 };
Assert.True(SaveCodec.IsCompatible(header),
"v6 must remain readable post-Phase-6.5 (MIN_VERSION = 5).");
var body = new SaveBody();
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
}
[Fact]
public void V6SaveBody_AfterMigration_HasFreshLevelUpFields()
{
var header = new SaveHeader { Version = 6 };
var body = new SaveBody
{
Player = new() { Name = "Wanderer", PositionX = 10, PositionY = 10 },
PlayerCharacter = new()
{
CladeId = "canidae", SpeciesId = "wolf",
ClassId = "fangsworn", BackgroundId = "pack_raised",
STR = 15, DEX = 12, CON = 13, INT = 10, WIS = 13, CHA = 8,
Level = 1, Xp = 0, MaxHp = 11, CurrentHp = 11,
},
};
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
// Existing fields untouched.
Assert.Equal("Wanderer", body.Player.Name);
Assert.Equal(1, body.PlayerCharacter!.Level);
// New v7 fields default-initialised to empty.
Assert.Equal("", body.PlayerCharacter.SubclassId);
Assert.Empty(body.PlayerCharacter.LearnedFeatureIds);
Assert.Empty(body.PlayerCharacter.LevelUpHistory);
}
[Fact]
public void NewSaveHeader_DefaultsToCurrentSchemaVersion()
{
// Phase 7 M0 bumped SAVE_SCHEMA_VERSION to 8; the old test
// hardcoded 7. This version-of-record test is an early-warning
// that future bumps remember to add a chained migration entry.
var header = new SaveHeader();
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
Assert.True(header.Version >= 7,
"Schema version must not regress below the v7 floor introduced in Phase 6.5.");
}
}
@@ -0,0 +1,71 @@
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Persistence.SaveMigrations;
using Xunit;
namespace Theriapolis.Tests.Persistence;
/// <summary>
/// Phase 7 M0 — additive V7→V8 migration. Phase-6.5 saves continue to
/// load post-Phase-7; the new <see cref="SaveBody"/> sections (anchors,
/// building deltas, dungeon state) default-initialise to empty, which
/// correctly represents "no anchors persisted, no buildings modified,
/// no dungeons visited" — the truth for any pre-Phase-7 save.
/// </summary>
public sealed class V7ToV8MigrationTests
{
[Fact]
public void V7Header_IsAcceptedByMigration()
{
var header = new SaveHeader { Version = 7 };
Assert.True(SaveCodec.IsCompatible(header),
"v7 must remain readable post-Phase-7 (MIN_VERSION = 5; v7 is one bump back).");
var body = new SaveBody();
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
}
[Fact]
public void V6Header_ChainsThroughV7ToV8()
{
// v6 → v7 → v8 chain: a Phase-6 save should still load with two
// additive migrations applied in order.
var header = new SaveHeader { Version = 6 };
Assert.True(SaveCodec.IsCompatible(header));
var body = new SaveBody();
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
}
[Fact]
public void V7SaveBody_AfterMigration_PreservesPhase65Fields()
{
var header = new SaveHeader { Version = 7 };
var body = new SaveBody
{
Player = new() { Name = "Wanderer", PositionX = 10, PositionY = 10 },
PlayerCharacter = new()
{
CladeId = "canidae", SpeciesId = "wolf",
ClassId = "fangsworn", BackgroundId = "pack_raised",
STR = 15, DEX = 12, CON = 13, INT = 10, WIS = 13, CHA = 8,
Level = 3, Xp = 950, MaxHp = 28, CurrentHp = 28,
SubclassId = "pack_forged",
LearnedFeatureIds = new[] { "packmates_howl" },
},
};
bool ok = Migrations.MigrateUp(header, body);
Assert.True(ok);
Assert.Equal(C.SAVE_SCHEMA_VERSION, header.Version);
// Phase 6.5 fields untouched.
Assert.Equal(3, body.PlayerCharacter!.Level);
Assert.Equal("pack_forged", body.PlayerCharacter.SubclassId);
Assert.Contains("packmates_howl", body.PlayerCharacter.LearnedFeatureIds);
}
}
@@ -0,0 +1,241 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Quests;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Time;
using Theriapolis.Core.World.Settlements;
using Xunit;
namespace Theriapolis.Tests.Quests;
/// <summary>
/// Phase 6 M4 — quest engine mechanics: start, step transition, on_enter
/// effects, outcome selection, completion / failure terminals,
/// chain-fire when consecutive steps are immediately satisfiable.
/// </summary>
public sealed class QuestEngineTests : IClassFixture<WorldCache>
{
private readonly WorldCache _cache;
public QuestEngineTests(WorldCache c) => _cache = c;
private static QuestDef SimpleLinearQuest()
=> new()
{
Id = "test_linear",
Title = "Linear Test",
EntryStep = "intro",
Steps = new[]
{
new QuestStepDef
{
Id = "intro",
Title = "Begin",
OnEnter = new[] { new QuestEffectDef { Kind = "set_flag", Flag = "started", Value = 1 } },
Outcomes = new[] { new QuestOutcomeDef { Next = "wait" } },
},
new QuestStepDef
{
Id = "wait",
Title = "Wait for trigger",
TriggerConditions = new[] { new QuestConditionDef { Kind = "flag_set", Flag = "trigger" } },
Outcomes = new[] { new QuestOutcomeDef { Next = "done" } },
},
new QuestStepDef
{
Id = "done",
Title = "Done",
CompletesQuest = true,
},
},
};
/// <summary>Build a real ContentResolver but inject a quest tree post-construction via the file system.</summary>
private static (ContentResolver content, string testQuestId) MakeContentWithExtraQuest(QuestDef extra)
{
// Write the quest to a temp dir alongside Content/Data and load
// from there. Cheap, isolated, deterministic.
string baseDir = TestHelpers.DataDirectory;
string tempDir = Path.Combine(System.IO.Path.GetTempPath(),
"theriapolis-test-quests-" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
// Mirror every Content/Data file (loader expects siblings).
foreach (var f in Directory.EnumerateFiles(baseDir))
File.Copy(f, Path.Combine(tempDir, Path.GetFileName(f)));
foreach (var d in Directory.EnumerateDirectories(baseDir))
{
string destDir = Path.Combine(tempDir, Path.GetFileName(d));
Directory.CreateDirectory(destDir);
foreach (var f in Directory.EnumerateFiles(d))
File.Copy(f, Path.Combine(destDir, Path.GetFileName(f)));
}
// Write the synthetic quest into quests/ and load.
string questDir = Path.Combine(tempDir, "quests");
Directory.CreateDirectory(questDir);
string json = System.Text.Json.JsonSerializer.Serialize(extra,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(Path.Combine(questDir, extra.Id + ".json"), json);
return (new ContentResolver(new ContentLoader(tempDir)), extra.Id);
}
[Fact]
public void StartQuest_RunsOnEnter_AndAdvancesAutomatically()
{
var (content, qid) = MakeContentWithExtraQuest(SimpleLinearQuest());
var actors = new ActorManager();
actors.SpawnPlayer(new Theriapolis.Core.Util.Vec2(50, 50));
var rep = new PlayerReputation();
var flags = new Dictionary<string, int>();
var ctx = new QuestContext(content, actors, rep, flags,
new AnchorRegistry(), new WorldClock(), _cache.Get(0xCAFEBABEUL).World);
var engine = new QuestEngine();
Assert.True(engine.Start(qid, ctx));
// After Start: intro's on_enter set the flag; auto-transition to "wait"
// happens because the unconditional outcome fires immediately.
Assert.Equal(1, flags["started"]);
Assert.True(engine.IsActive(qid));
Assert.Equal("wait", engine.Get(qid)!.CurrentStep);
}
[Fact]
public void Quest_AdvancesOnTrigger_AndCompletes()
{
var (content, qid) = MakeContentWithExtraQuest(SimpleLinearQuest());
var actors = new ActorManager();
actors.SpawnPlayer(new Theriapolis.Core.Util.Vec2(50, 50));
var rep = new PlayerReputation();
var flags = new Dictionary<string, int>();
var ctx = new QuestContext(content, actors, rep, flags,
new AnchorRegistry(), new WorldClock(), _cache.Get(0xCAFEBABEUL).World);
var engine = new QuestEngine();
engine.Start(qid, ctx);
Assert.True(engine.IsActive(qid));
// Tick without trigger → still active, still at "wait".
engine.Tick(ctx);
Assert.True(engine.IsActive(qid));
// Set trigger, tick → progresses to "done" (completes_quest).
flags["trigger"] = 1;
engine.Tick(ctx);
Assert.False(engine.IsActive(qid));
Assert.True(engine.IsCompleted(qid));
}
[Fact]
public void StartQuest_TwiceIsNoOp()
{
var (content, qid) = MakeContentWithExtraQuest(SimpleLinearQuest());
var actors = new ActorManager();
actors.SpawnPlayer(new Theriapolis.Core.Util.Vec2(0, 0));
var ctx = new QuestContext(content, actors, new PlayerReputation(),
new Dictionary<string, int>(), new AnchorRegistry(), new WorldClock(),
_cache.Get(0xCAFEBABEUL).World);
var engine = new QuestEngine();
Assert.True(engine.Start(qid, ctx));
Assert.False(engine.Start(qid, ctx));
}
[Fact]
public void AutoStartWhen_FiresOnTick()
{
var tree = new QuestDef
{
Id = "auto_test", Title = "Auto Test", EntryStep = "go",
AutoStartWhen = new[] { new QuestConditionDef { Kind = "flag_set", Flag = "ready" } },
Steps = new[]
{
new QuestStepDef { Id = "go", Title = "Go", CompletesQuest = true },
},
};
var (content, qid) = MakeContentWithExtraQuest(tree);
var actors = new ActorManager();
actors.SpawnPlayer(new Theriapolis.Core.Util.Vec2(0, 0));
var flags = new Dictionary<string, int>();
var ctx = new QuestContext(content, actors, new PlayerReputation(), flags,
new AnchorRegistry(), new WorldClock(), _cache.Get(0xCAFEBABEUL).World);
var engine = new QuestEngine();
engine.Tick(ctx);
Assert.False(engine.IsActive(qid) || engine.IsCompleted(qid));
flags["ready"] = 1;
engine.Tick(ctx);
Assert.True(engine.IsCompleted(qid));
}
[Fact]
public void EnterAnchor_FiresWhenPlayerNearSettlement()
{
// Pick a real settlement at world tile coords and place the player on it.
var world = _cache.Get(0xCAFEBABEUL).World;
var s = world.Settlements.First(x => !x.IsPoi && x.Tier <= 3);
var tree = new QuestDef
{
Id = "anchor_test", Title = "Anchor Test", EntryStep = "go",
Steps = new[]
{
new QuestStepDef
{
Id = "go",
TriggerConditions = new[]
{
new QuestConditionDef { Kind = "enter_anchor", Anchor = "anchor:test_anchor" },
},
CompletesQuest = true,
},
},
};
var (content, qid) = MakeContentWithExtraQuest(tree);
var actors = new ActorManager();
// Place player at the settlement's tile (converted to world-pixel space).
int px = s.TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS / 2;
int py = s.TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS / 2;
actors.SpawnPlayer(new Theriapolis.Core.Util.Vec2(px, py));
var anchors = new AnchorRegistry();
// Register a synthetic anchor pointing at the chosen settlement id.
// (Use the real anchor enum value if the settlement has one; else
// rely on RegisterAnchor's lowercased key matching.)
if (s.Anchor is { } a)
anchors.RegisterAnchor(a, s.Id);
else
{
// Fall back: directly insert the anchor mapping via a helper that
// doesn't exist publicly — we'll use the closest real anchor in
// the world that DOES have a settlement.
return; // skip the test; only meaningful when a settlement has an Anchor.
}
// Override the anchor key used by the quest to match the registered one.
// (The test is generic — replace test_anchor with the real anchor name.)
string anchorKey = "anchor:" + a.ToString().ToLowerInvariant();
var tree2 = tree with
{
Steps = new[]
{
tree.Steps[0] with
{
TriggerConditions = new[] { new QuestConditionDef { Kind = "enter_anchor", Anchor = anchorKey } },
},
},
};
var (content2, qid2) = MakeContentWithExtraQuest(tree2);
var ctx = new QuestContext(content2, actors, new PlayerReputation(),
new Dictionary<string, int>(), anchors, new WorldClock(), world);
var engine = new QuestEngine();
engine.Start(qid2, ctx);
engine.Tick(ctx);
Assert.True(engine.IsCompleted(qid2),
$"player at settlement {s.TileX},{s.TileY} should satisfy enter_anchor");
}
}
@@ -0,0 +1,82 @@
using Theriapolis.Core;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Quests;
using Xunit;
namespace Theriapolis.Tests.Quests;
/// <summary>
/// Phase 6 M4 — capture/restore round-trip for quest engine state.
/// </summary>
public sealed class QuestSnapshotTests
{
[Fact]
public void CaptureAndRestore_RoundTripActiveAndCompleted()
{
var engine = new QuestEngine();
engine.AdoptActive(new QuestState
{
QuestId = "q1", CurrentStep = "step_a", Status = QuestStatus.Active,
StartedAt = 100, StepStartedAt = 150,
});
engine.Get("q1")!.Journal.Add("started q1");
engine.AdoptCompleted(new QuestState
{
QuestId = "q2", CurrentStep = "<end>", Status = QuestStatus.Completed,
StartedAt = 0, StepStartedAt = 200,
});
engine.Journal.Add("global event 1");
var snap = QuestCodec.Capture(engine);
Assert.Single(snap.Active);
Assert.Single(snap.Completed);
Assert.Single(snap.Journal);
var rebuilt = new QuestEngine();
QuestCodec.Restore(rebuilt, snap);
Assert.True(rebuilt.IsActive("q1"));
Assert.True(rebuilt.IsCompleted("q2"));
Assert.Single(rebuilt.Journal);
Assert.Equal("step_a", rebuilt.Get("q1")!.CurrentStep);
Assert.Contains("started q1", rebuilt.Get("q1")!.Journal);
}
[Fact]
public void SaveCodec_RoundTripsQuestEngineState()
{
var engine = new QuestEngine();
engine.AdoptActive(new QuestState
{
QuestId = "main_act_i_001_arrival",
CurrentStep = "find_magistrate",
Status = QuestStatus.Active,
StartedAt = 500, StepStartedAt = 700,
});
engine.Journal.Add("Arrived in Millhaven");
var body = new SaveBody();
body.Player.Name = "Tester";
body.QuestEngineState = QuestCodec.Capture(engine);
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION, WorldSeedHex = "0xCAFE" };
var bytes = SaveCodec.Serialize(header, body);
var (h2, b2) = SaveCodec.Deserialize(bytes);
Assert.Equal(C.SAVE_SCHEMA_VERSION, h2.Version);
Assert.Single(b2.QuestEngineState.Active);
Assert.Equal("find_magistrate", b2.QuestEngineState.Active[0].CurrentStep);
Assert.Single(b2.QuestEngineState.Journal);
}
[Fact]
public void EmptyEngineState_OmitsTagFromSave()
{
// No active/completed/journal → no TAG_QUESTS section written.
var body = new SaveBody { Player = { Name = "Empty" } };
var bytes = SaveCodec.Serialize(new SaveHeader(), body);
var (_, b2) = SaveCodec.Deserialize(bytes);
Assert.Empty(b2.QuestEngineState.Active);
Assert.Empty(b2.QuestEngineState.Completed);
Assert.Empty(b2.QuestEngineState.Journal);
}
}
@@ -0,0 +1,270 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Reputation;
using Xunit;
namespace Theriapolis.Tests.Reputation;
/// <summary>
/// Phase 6.5 M7 — betrayal cascade: tier mapping, faction propagation
/// through the opposition matrix, permanent memory tag, sticky aggro
/// flag on guard-style NPCs in the betrayed faction.
/// </summary>
public sealed class BetrayalCascadeTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Tier resolution ───────────────────────────────────────────────────
[Theory]
[InlineData(0, 0)] // not a betrayal
[InlineData(-5, 0)] // sub-minor → no faction cascade
[InlineData(-10, -5)] // minor
[InlineData(-24, -5)] // still minor
[InlineData(-25, -15)] // moderate
[InlineData(-49, -15)] // still moderate
[InlineData(-50, -30)] // major
[InlineData(-74, -30)] // still major
[InlineData(-75, -50)] // critical
[InlineData(-100, -50)] // floor
public void ResolveFactionDelta_TiersByMagnitude(int magnitude, int expected)
{
Assert.Equal(expected, BetrayalCascade.ResolveFactionDelta(magnitude));
}
// ── Apply: cascade outcomes ───────────────────────────────────────────
[Fact]
public void Apply_NonBetrayalEvent_ReturnsEmpty()
{
var rep = new PlayerReputation();
var ev = MakeBetrayalEvent("test.role", magnitude: 0); // magnitude 0 = not a betrayal
var result = BetrayalCascade.Apply(ev, rep, betrayedNpc: null,
npcs: System.Linq.Enumerable.Empty<NpcActor>(), factions: _content.Factions);
Assert.True(result.IsEmpty);
Assert.Empty(rep.Ledger.Entries);
}
[Fact]
public void Apply_PositiveMagnitude_NoCascade()
{
var rep = new PlayerReputation();
var ev = MakeBetrayalEvent("test.role", magnitude: 10); // positive — not a betrayal
var result = BetrayalCascade.Apply(ev, rep, betrayedNpc: null,
npcs: System.Linq.Enumerable.Empty<NpcActor>(), factions: _content.Factions);
Assert.True(result.IsEmpty);
}
[Fact]
public void Apply_WritesBetrayedMeMemoryFlag()
{
var rep = new PlayerReputation();
var ev = MakeBetrayalEvent("millhaven.asha", magnitude: -25);
BetrayalCascade.Apply(ev, rep, betrayedNpc: null,
npcs: System.Linq.Enumerable.Empty<NpcActor>(), factions: _content.Factions);
Assert.Contains("betrayed_me", rep.PersonalFor("millhaven.asha").Memory);
}
[Fact]
public void Apply_AppliesFactionDeltaFromBetrayedNpc()
{
var rep = new PlayerReputation();
var asha = MakeNpc(faction: "hybrid_underground", behavior: "resident");
var ev = MakeBetrayalEvent("millhaven.asha", magnitude: -25);
var result = BetrayalCascade.Apply(ev, rep, asha,
npcs: System.Linq.Enumerable.Empty<NpcActor>(), factions: _content.Factions);
Assert.Equal("hybrid_underground", result.factionId);
// Hybrid_underground: -15 (moderate tier).
Assert.Equal(-15, rep.Factions.Get("hybrid_underground"));
// Opposition cascade: hybrid_underground hates inheritors (-0.5),
// so -15 with hybrid_underground → +7 with inheritors (negative ×
// negative). Sign check.
int inheritors = rep.Factions.Get("inheritors");
Assert.True(inheritors > 0, $"expected positive inheritor delta from cascade, got {inheritors}");
}
[Fact]
public void Apply_LedgerRecordsCascadeAsFactionTaggedEvent()
{
var rep = new PlayerReputation();
var asha = MakeNpc(faction: "hybrid_underground", behavior: "resident");
var ev = MakeBetrayalEvent("millhaven.asha", magnitude: -50);
BetrayalCascade.Apply(ev, rep, asha,
npcs: System.Linq.Enumerable.Empty<NpcActor>(), factions: _content.Factions);
Assert.Contains(rep.Ledger.Entries,
e => e.Kind == RepEventKind.Betrayal
&& e.FactionId == "hybrid_underground"
&& e.RoleTag == "millhaven.asha");
}
[Fact]
public void Apply_NoFactionCascadeWhenNpcAndEventBothLackFaction()
{
var rep = new PlayerReputation();
var npc = MakeNpc(faction: "", behavior: "brigand");
var ev = MakeBetrayalEvent("test.role", magnitude: -25);
var result = BetrayalCascade.Apply(ev, rep, npc,
npcs: new[] { npc }, factions: _content.Factions);
// Personal flag still set, but no faction propagation.
Assert.Contains("betrayed_me", rep.PersonalFor("test.role").Memory);
Assert.Equal("", result.factionId);
Assert.Empty(result.factionDeltas);
}
// ── Apply: permanent aggro flip ───────────────────────────────────────
[Fact]
public void Apply_FlipsPermanentAggroOnSameFactionGuards()
{
var rep = new PlayerReputation();
var betrayed = MakeNpc(faction: "covenant_enforcers", behavior: "patrol");
var otherGuard = MakeNpc(faction: "covenant_enforcers", behavior: "patrol");
var unrelated = MakeNpc(faction: "merchant_guilds", behavior: "patrol");
var ev = MakeBetrayalEvent("guard.captain", magnitude: -50);
var result = BetrayalCascade.Apply(ev, rep, betrayed,
npcs: new[] { betrayed, otherGuard, unrelated }, factions: _content.Factions);
Assert.True(otherGuard.PermanentAggroAfterBetrayal);
Assert.False(unrelated.PermanentAggroAfterBetrayal); // wrong faction
Assert.True(result.permanentAggroFlipped >= 1);
}
[Fact]
public void Apply_DoesNotFlipCivilianResidents()
{
var rep = new PlayerReputation();
var betrayed = MakeNpc(faction: "merchant_guilds", behavior: "resident");
var civilianTrader = MakeNpc(faction: "merchant_guilds", behavior: "resident");
var ev = MakeBetrayalEvent("guild.master", magnitude: -50);
BetrayalCascade.Apply(ev, rep, betrayed,
npcs: new[] { betrayed, civilianTrader }, factions: _content.Factions);
// Civilians stay non-aggro even on betrayal — only guard-style behaviors flip.
Assert.False(civilianTrader.PermanentAggroAfterBetrayal);
}
[Theory]
[InlineData("brigand")]
[InlineData("patrol")]
[InlineData("poi_guard")]
[InlineData("wild_animal")]
public void Apply_FlipsAggroForCombatBehaviors(string behavior)
{
var rep = new PlayerReputation();
var betrayed = MakeNpc(faction: "inheritors", behavior: behavior);
var same = MakeNpc(faction: "inheritors", behavior: behavior);
var ev = MakeBetrayalEvent("inheritor.captain", magnitude: -50);
BetrayalCascade.Apply(ev, rep, betrayed,
npcs: new[] { betrayed, same }, factions: _content.Factions);
Assert.True(same.PermanentAggroAfterBetrayal);
}
[Fact]
public void Apply_DoesNotReFlipAlreadyAggroedNpc()
{
var rep = new PlayerReputation();
var betrayed = MakeNpc(faction: "inheritors", behavior: "patrol");
var preFlipped = MakeNpc(faction: "inheritors", behavior: "patrol");
preFlipped.PermanentAggroAfterBetrayal = true;
var ev = MakeBetrayalEvent("inheritor.scout", magnitude: -25);
var result = BetrayalCascade.Apply(ev, rep, betrayed,
npcs: new[] { betrayed, preFlipped }, factions: _content.Factions);
// betrayed gets flipped (it's the same-faction guard), but
// preFlipped is already-aggro and shouldn't double-count.
Assert.True(betrayed.PermanentAggroAfterBetrayal);
Assert.True(preFlipped.PermanentAggroAfterBetrayal);
// Only `betrayed` flipped fresh in this call.
Assert.Equal(1, result.permanentAggroFlipped);
}
// ── FactionAggression integration ─────────────────────────────────────
[Fact]
public void FactionAggression_FlipsAllegiance_OnPermanentAggroFlag()
{
// Simulate: a friendly resident with the betrayal aggro flag set
// (no need for a hostile faction standing). FactionAggression
// should still flip them to Hostile.
var npc = MakeNpc(faction: "hybrid_underground", behavior: "resident");
npc.Allegiance = Theriapolis.Core.Rules.Character.Allegiance.Friendly;
npc.PermanentAggroAfterBetrayal = true;
// FactionAggression.UpdateAllegiances takes an ActorManager + content
// + world. Build minimal fixtures.
var actors = new ActorManager();
actors.SpawnNpc(npc);
// No PC needed for the early-return null check; pass a stub character.
var pc = MakeStubPc();
// Empty WorldState — UpdateAllegiances handles missing settlements gracefully.
var world = new Theriapolis.Core.World.WorldState();
var rep = new PlayerReputation();
int flipped = FactionAggression.UpdateAllegiances(actors, pc, rep, _content, world, 0xCAFEUL);
Assert.True(flipped >= 1);
Assert.Equal(Theriapolis.Core.Rules.Character.Allegiance.Hostile, npc.Allegiance);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static RepEvent MakeBetrayalEvent(string roleTag, int magnitude) => new()
{
Kind = RepEventKind.Betrayal,
RoleTag = roleTag,
Magnitude = magnitude,
Note = "test betrayal",
TimestampSeconds = 1000,
};
// Monotone counter so every NPC gets a positive Id — needed because
// ActorManager.SpawnNpc clones (and resets initialization-time flags)
// when Id ≤ 0.
private static int _nextNpcId = 1000;
private static NpcActor MakeNpc(string faction, string behavior)
{
// Use NpcTemplateDef so we can set an arbitrary Behavior id.
var template = new NpcTemplateDef
{
Id = "test_" + behavior,
Name = "Test NPC",
Hp = 20,
Behavior = behavior,
Faction = faction,
DefaultAllegiance = "neutral",
};
return new NpcActor(template) { Id = ++_nextNpcId };
}
private Theriapolis.Core.Rules.Character.Character MakeStubPc()
{
var b = new Theriapolis.Core.Rules.Character.CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new Theriapolis.Core.Rules.Stats.AbilityScores(15, 14, 13, 12, 10, 8),
};
int n = b.ClassDef.SkillsChoose;
foreach (var raw in b.ClassDef.SkillOptions)
{
if (b.ChosenClassSkills.Count >= n) break;
try { b.ChosenClassSkills.Add(Theriapolis.Core.Rules.Stats.SkillIdExtensions.FromJson(raw)); } catch { }
}
return b.Build(_content.Items);
}
}
@@ -0,0 +1,171 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Reputation;
/// <summary>
/// Phase 6 M2 — disposition formula correctness.
///
/// The blend is:
/// Total = CladeBias + SizeDifferential + FactionModifier + Personal
/// each layer independently testable. We synthesise small fixtures so the
/// tests don't depend on actual content.json values.
/// </summary>
public sealed class EffectiveDispositionTests
{
private static ContentResolver LoadContent()
=> new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
private static Character WolfPc(ContentResolver content)
{
var clade = content.Clades["canidae"];
var species = content.Species["wolf"];
var classD = content.Classes["fangsworn"];
var bg = content.Backgrounds["pack_raised"];
var b = new CharacterBuilder()
.WithClade(clade).WithSpecies(species)
.WithClass(classD).WithBackground(bg)
.WithAbilities(new AbilityScores(13, 12, 14, 10, 10, 11));
// Pick the right number of class skills.
int needed = classD.SkillsChoose;
var added = new HashSet<SkillId>();
for (int i = 0; i < classD.SkillOptions.Length && added.Count < needed; i++)
{
try
{
var sk = SkillIdExtensions.FromJson(classD.SkillOptions[i]);
if (added.Add(sk)) b.ChooseSkill(sk);
}
catch (System.ArgumentException) { /* unknown skill name in content — skip */ }
}
return b.Build();
}
[Fact]
public void DispositionLabel_BoundariesMatchThresholds()
{
Assert.Equal(DispositionLabel.Champion, DispositionLabels.For( 80));
Assert.Equal(DispositionLabel.Allied, DispositionLabels.For( 60));
Assert.Equal(DispositionLabel.Friendly, DispositionLabels.For( 30));
Assert.Equal(DispositionLabel.Favorable, DispositionLabels.For( 10));
Assert.Equal(DispositionLabel.Neutral, DispositionLabels.For( 0));
Assert.Equal(DispositionLabel.Unfriendly, DispositionLabels.For(-10));
Assert.Equal(DispositionLabel.Antagonistic,DispositionLabels.For(-30));
Assert.Equal(DispositionLabel.Hostile, DispositionLabels.For(-60));
Assert.Equal(DispositionLabel.Nemesis, DispositionLabels.For(-90));
}
[Fact]
public void Breakdown_Sums_AllLayers()
{
var content = LoadContent();
var pc = WolfPc(content);
var rep = new PlayerReputation();
// A wolf-folk PC vs a CANID_TRADITIONALIST resident: clade bias
// for canidae is +15, no size differential (Wolf-Folk = MediumLarge,
// generic_innkeeper is rabbit = Small → diff = +2 ⇒ -8 mod). Personal
// and faction = 0. So effective = 15 + (-8) = 7 → Favorable.
var template = content.Residents["generic_innkeeper"]; // Leporidae rabbit, URBAN_PROGRESSIVE
var npc = new NpcActor(template) { Id = 1, RoleTag = "innkeeper" };
var br = EffectiveDisposition.Breakdown(npc, pc, rep, content);
Assert.Equal(br.CladeBias + br.SizeDifferential + br.FactionModifier + br.Personal, br.Total);
Assert.Equal(DispositionLabels.For(br.Total), br.Label);
}
[Fact]
public void Breakdown_HostileBiasProfile_ProducesNegativeTotal()
{
var content = LoadContent();
var pc = WolfPc(content);
var rep = new PlayerReputation();
// Find a resident with a profile that's actively hostile to canidae
// (THORN_COUNCIL_HARDLINER → canidae -25).
var hostileTemplate = new ResidentTemplateDef
{
Id = "test_hardliner",
RoleTag = "test.hardliner",
Named = true,
Name = "Test Hardliner",
Clade = "cervidae",
Species = "elk",
BiasProfile = "THORN_COUNCIL_HARDLINER",
};
var npc = new NpcActor(hostileTemplate) { Id = 2, RoleTag = "test.hardliner" };
var br = EffectiveDisposition.Breakdown(npc, pc, rep, content);
Assert.True(br.Total < 0,
$"Wolf-folk vs Thorn Council Hardliner should be negative; got {br.Total}");
}
[Fact]
public void Personal_Disposition_OverridesNeutralBaseline()
{
var content = LoadContent();
var pc = WolfPc(content);
var rep = new PlayerReputation();
var template = content.Residents["generic_innkeeper"];
var npc = new NpcActor(template) { Id = 3, RoleTag = "village.innkeeper" };
// Apply a +30 personal event. Effective should rise by ~30.
var before = EffectiveDisposition.For(npc, pc, rep, content);
rep.PersonalFor(npc.RoleTag).Apply(new RepEvent
{
Kind = RepEventKind.Aid, RoleTag = npc.RoleTag, Magnitude = 30,
});
var after = EffectiveDisposition.For(npc, pc, rep, content);
Assert.Equal(30, after - before);
}
[Fact]
public void Faction_Standing_ContributesToHalfWeight()
{
var content = LoadContent();
var pc = WolfPc(content);
var rep = new PlayerReputation();
// Use a constable template (faction = covenant_enforcers).
var template = content.Residents["generic_constable"];
var npc = new NpcActor(template) { Id = 4, RoleTag = "village.constable" };
var before = EffectiveDisposition.For(npc, pc, rep, content);
rep.Factions.Set("covenant_enforcers", 40);
var after = EffectiveDisposition.For(npc, pc, rep, content);
// Direct faction affiliation contributes 0.5×; the bias profile
// (Covenant Faithful) layers an additional 0.25× × affinity/100
// for matching factions. For COVENANT_FAITHFUL with
// covenant_enforcers affinity = +25, the layered weight is
// 40 × 25/100 × 0.25 = 2.5 → rounds to 3 on top of the 20 from
// direct affiliation. So the delta lands in the 20..23 range.
int delta = after - before;
Assert.InRange(delta, 20, 23);
}
[Fact]
public void Score_IsClampedToRepRange()
{
var content = LoadContent();
var pc = WolfPc(content);
var rep = new PlayerReputation();
var template = content.Residents["generic_innkeeper"];
var npc = new NpcActor(template) { Id = 5, RoleTag = "any.innkeeper" };
// Stack +200 personal points; clamp should keep it at +100.
rep.PersonalFor(npc.RoleTag).Score = 250; // bypass Apply to force a value past clamp
var br = EffectiveDisposition.Breakdown(npc, pc, rep, content);
Assert.True(br.Total <= C.REP_MAX);
rep.PersonalFor(npc.RoleTag).Score = -250;
br = EffectiveDisposition.Breakdown(npc, pc, rep, content);
Assert.True(br.Total >= C.REP_MIN);
}
}
@@ -0,0 +1,130 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Reputation;
/// <summary>
/// Phase 6 M5 — patrol/guard NPC allegiance flips when local disposition
/// drops to HOSTILE.
/// </summary>
public sealed class FactionAggressionTests : IClassFixture<WorldCache>
{
private readonly WorldCache _cache;
public FactionAggressionTests(WorldCache c) => _cache = c;
private static Character WolfPc(ContentResolver content)
{
var b = new CharacterBuilder()
.WithClade(content.Clades["canidae"])
.WithSpecies(content.Species["wolf"])
.WithClass(content.Classes["fangsworn"])
.WithBackground(content.Backgrounds["pack_raised"])
.WithAbilities(new AbilityScores(13, 12, 14, 10, 10, 11));
var classD = content.Classes["fangsworn"];
var added = new HashSet<SkillId>();
for (int i = 0; i < classD.SkillOptions.Length && added.Count < classD.SkillsChoose; i++)
{
try
{
var sk = SkillIdExtensions.FromJson(classD.SkillOptions[i]);
if (added.Add(sk)) b.ChooseSkill(sk);
}
catch (System.ArgumentException) { }
}
return b.Build();
}
[Fact]
public void EnforcerPatrol_FlipsToHostile_WhenStandingTanks()
{
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
var pc = WolfPc(content);
var rep = new PlayerReputation();
var actors = new ActorManager();
actors.SpawnPlayer(new Theriapolis.Core.Util.Vec2(50, 50));
var world = _cache.Get(0xCAFEBABEUL).World;
var s = world.Settlements.First();
// Spawn a militia patroller manually using the npc template so it
// carries the covenant_enforcers faction id.
var template = content.Npcs.Templates.First(t => t.Id == "militia_patrol");
var npc = actors.SpawnNpc(template, new Theriapolis.Core.Util.Vec2(s.TileX * C.WORLD_TILE_PIXELS, s.TileY * C.WORLD_TILE_PIXELS));
// Give them a home settlement so propagation has somewhere to land.
// (The init-only HomeSettlementId requires reconstruction; instead
// we re-spawn through the (NpcActor pre) path with the field set.)
actors.RemoveActor(npc.Id);
var pre = new NpcActor(template)
{
Id = -1,
Position = new Theriapolis.Core.Util.Vec2(s.TileX * C.WORLD_TILE_PIXELS, s.TileY * C.WORLD_TILE_PIXELS),
HomeSettlementId = s.Id,
};
var live = actors.SpawnNpc(pre);
Assert.Equal(Allegiance.Neutral, live.Allegiance);
Assert.Equal("covenant_enforcers", live.FactionId);
// Tank the player's Enforcer standing with a single big crime.
rep.Submit(new RepEvent
{
Kind = RepEventKind.Crime,
FactionId = "covenant_enforcers",
Magnitude = -80,
OriginTileX = s.TileX, OriginTileY = s.TileY,
}, content.Factions);
int flipped = FactionAggression.UpdateAllegiances(actors, pc, rep, content, world, 0xCAFEBABEUL);
Assert.True(flipped >= 1, $"expected ≥1 flip; got {flipped}");
Assert.Equal(Allegiance.Hostile, live.Allegiance);
}
[Fact]
public void Resident_WithoutFaction_DoesNotFlip()
{
// A friendly non-faction resident (innkeeper without faction) should
// never flip even with player infamy.
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
var pc = WolfPc(content);
var rep = new PlayerReputation();
var actors = new ActorManager();
actors.SpawnPlayer(new Theriapolis.Core.Util.Vec2(50, 50));
var world = _cache.Get(0xCAFEBABEUL).World;
var template = content.Residents["generic_innkeeper"]; // no faction
var pre = new NpcActor(template)
{
Id = -1,
Position = new Theriapolis.Core.Util.Vec2(0, 0),
};
var live = actors.SpawnNpc(pre);
rep.Factions.Set("covenant_enforcers", -100);
FactionAggression.UpdateAllegiances(actors, pc, rep, content, world, 0xCAFEBABEUL);
Assert.NotEqual(Allegiance.Hostile, live.Allegiance);
}
[Fact]
public void HostileNpc_StaysHostile_AndDoesNotChangeAgain()
{
// Sanity: a hostile NPC isn't "promoted" further (no "extra hostile").
var content = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory));
var pc = WolfPc(content);
var rep = new PlayerReputation();
var actors = new ActorManager();
actors.SpawnPlayer(new Theriapolis.Core.Util.Vec2(50, 50));
var world = _cache.Get(0xCAFEBABEUL).World;
// brigand_footpad has default_allegiance = hostile and no faction.
var template = content.Npcs.Templates.First(t => t.Id == "brigand_footpad");
var live = actors.SpawnNpc(template, new Theriapolis.Core.Util.Vec2(0, 0));
Assert.Equal(Allegiance.Hostile, live.Allegiance);
int flipped = FactionAggression.UpdateAllegiances(actors, pc, rep, content, world, 0xCAFEBABEUL);
Assert.Equal(0, flipped);
Assert.Equal(Allegiance.Hostile, live.Allegiance);
}
}
@@ -0,0 +1,110 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Reputation;
using Xunit;
namespace Theriapolis.Tests.Reputation;
/// <summary>
/// Phase 6 M2 — opposition matrix application.
///
/// The plan §I-2 spells out the exact multipliers; this suite verifies
/// the cascade fires in both directions (gains and losses) and stays
/// inside the clamp range.
/// </summary>
public sealed class FactionOppositionTests
{
private static IReadOnlyDictionary<string, FactionDef> LoadFactions()
=> new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Factions;
[Fact]
public void GainWithInheritors_CascadesIntoOppositionLosses()
{
var factions = LoadFactions();
var standing = new FactionStanding();
var applied = standing.Apply("inheritors", 10, factions);
// Per the doc: +10 with Inheritors should yield -5 Enforcers,
// -2 Thorn Council, -3 Hybrid Underground, -3 Unsheathed.
Assert.Equal( 10, standing.Get("inheritors"));
Assert.Equal( -5, standing.Get("covenant_enforcers"));
Assert.Equal( -2, standing.Get("thorn_council"));
Assert.Equal( -3, standing.Get("hybrid_underground"));
Assert.Equal( -3, standing.Get("unsheathed"));
Assert.Equal( 0, standing.Get("merchant_guilds")); // multiplier 0
// The applied list reports every actually-changed faction.
Assert.Contains(applied, t => t.FactionId == "inheritors" && t.Delta == 10);
Assert.Contains(applied, t => t.FactionId == "covenant_enforcers" && t.Delta == -5);
}
[Fact]
public void LossWithInheritors_CascadesIntoOppositionGains()
{
var factions = LoadFactions();
var standing = new FactionStanding();
standing.Apply("inheritors", -20, factions);
// -20 × -0.5 = +10 with Enforcers.
Assert.Equal(-20, standing.Get("inheritors"));
Assert.Equal( 10, standing.Get("covenant_enforcers"));
Assert.Equal( 4, standing.Get("thorn_council"));
Assert.Equal( 6, standing.Get("hybrid_underground"));
Assert.Equal( 6, standing.Get("unsheathed"));
}
[Fact]
public void Standing_IsClampedToRepRange()
{
var factions = LoadFactions();
var standing = new FactionStanding();
standing.Apply("inheritors", 200, factions);
Assert.Equal(C.REP_MAX, standing.Get("inheritors"));
// Cascaded -100 is below the floor — verify clamping.
Assert.Equal(C.REP_MIN, standing.Get("covenant_enforcers"));
}
[Fact]
public void ZeroDelta_DoesNothing()
{
var factions = LoadFactions();
var standing = new FactionStanding();
standing.Set("inheritors", 25);
standing.Apply("inheritors", 0, factions);
Assert.Equal(25, standing.Get("inheritors"));
Assert.Equal( 0, standing.Get("covenant_enforcers"));
}
[Fact]
public void UnknownFaction_NoCascade()
{
var factions = LoadFactions();
var standing = new FactionStanding();
// No throw; standing accumulates against the unknown id.
standing.Apply("not_a_real_faction", 10, factions);
Assert.Equal(10, standing.Get("not_a_real_faction"));
Assert.Equal( 0, standing.Get("covenant_enforcers"));
}
[Fact]
public void SubmitEvent_AppliesFactionAndPersonal()
{
var factions = LoadFactions();
var rep = new PlayerReputation();
rep.Submit(new RepEvent
{
Kind = RepEventKind.Quest,
FactionId = "inheritors",
RoleTag = "test.someone",
Magnitude = 10,
Note = "test event",
}, factions);
Assert.Equal(10, rep.Factions.Get("inheritors"));
Assert.Equal(-5, rep.Factions.Get("covenant_enforcers"));
Assert.Equal(10, rep.PersonalFor("test.someone").Score);
Assert.Single(rep.Ledger.Entries);
}
}
@@ -0,0 +1,195 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.World;
using Xunit;
namespace Theriapolis.Tests.Reputation;
/// <summary>
/// Phase 6 M5 — propagation correctness: distance-band decay, opposition
/// cascade, frontier coin-flip determinism, NEMESIS/CHAMPION bypass.
/// </summary>
public sealed class RepPropagationTests
{
private static IReadOnlyDictionary<string, FactionDef> Factions()
=> new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Factions;
private static Settlement Sett(int id, int x, int y) => new()
{
Id = id,
Name = $"S{id}",
Tier = 3,
TileX = x,
TileY = y,
};
[Fact]
public void BandFor_MapsTilesToBands()
{
Assert.Equal(RepPropagation.DistanceBand.Origin, RepPropagation.BandFor(0));
Assert.Equal(RepPropagation.DistanceBand.Adjacent, RepPropagation.BandFor(1));
Assert.Equal(RepPropagation.DistanceBand.Adjacent, RepPropagation.BandFor(C.REP_ADJACENT_DIST_TILES));
Assert.Equal(RepPropagation.DistanceBand.Regional, RepPropagation.BandFor(C.REP_ADJACENT_DIST_TILES + 1));
Assert.Equal(RepPropagation.DistanceBand.Regional, RepPropagation.BandFor(C.REP_REGIONAL_DIST_TILES));
Assert.Equal(RepPropagation.DistanceBand.Continental, RepPropagation.BandFor(C.REP_REGIONAL_DIST_TILES + 1));
Assert.Equal(RepPropagation.DistanceBand.Continental, RepPropagation.BandFor(C.REP_CONTINENTAL_DIST_TILES));
Assert.Equal(RepPropagation.DistanceBand.Frontier, RepPropagation.BandFor(C.REP_CONTINENTAL_DIST_TILES + 1));
}
[Fact]
public void DecayPctFor_HasMonotonicallyDecreasingValues()
{
Assert.True(RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Origin) >
RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Adjacent));
Assert.True(RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Adjacent) >
RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Regional));
Assert.True(RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Regional) >
RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Continental));
Assert.True(RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Continental) >
RepPropagation.DecayPctFor(RepPropagation.DistanceBand.Frontier));
}
[Fact]
public void LocalStanding_AtOrigin_FullMagnitude()
{
var ledger = new RepLedger();
ledger.Append(new RepEvent
{
FactionId = "inheritors",
Magnitude = 20,
OriginTileX = 100, OriginTileY = 100,
});
var s = Sett(1, 100, 100);
Assert.Equal(20, RepPropagation.LocalStandingFor("inheritors", s, 0xCAFEUL, ledger, Factions()));
}
[Fact]
public void LocalStanding_DecaysWithDistance()
{
var f = Factions();
var ledger = new RepLedger();
ledger.Append(new RepEvent
{
FactionId = "inheritors",
Magnitude = 50, // (just under the bypass threshold)
OriginTileX = 100, OriginTileY = 100,
});
// Adjacent: 80% of 50 = 40
// Wait — 50 is exactly at threshold; let's use 49 to test decay.
ledger = new RepLedger();
ledger.Append(new RepEvent
{
FactionId = "inheritors",
Magnitude = 49,
OriginTileX = 100, OriginTileY = 100,
});
int origin = RepPropagation.LocalStandingFor("inheritors", Sett(1, 100, 100), 0xCAFEUL, ledger, f);
int adjacent = RepPropagation.LocalStandingFor("inheritors", Sett(2, 110, 100), 0xCAFEUL, ledger, f); // 10 tiles
int regional = RepPropagation.LocalStandingFor("inheritors", Sett(3, 150, 100), 0xCAFEUL, ledger, f); // 50 tiles
int continental= RepPropagation.LocalStandingFor("inheritors", Sett(4, 250, 100), 0xCAFEUL, ledger, f); // 150 tiles
Assert.Equal(49, origin);
Assert.Equal((int)System.Math.Round(49 * 0.80f), adjacent);
Assert.Equal((int)System.Math.Round(49 * 0.60f), regional);
Assert.Equal((int)System.Math.Round(49 * 0.40f), continental);
}
[Fact]
public void Cascade_AppliesOppositionMatrix()
{
// Player gains +20 with Inheritors at (100, 100). The Enforcers
// hate this (mult -0.5) → -10 should cascade to Enforcer standing
// even at origin.
var f = Factions();
var ledger = new RepLedger();
ledger.Append(new RepEvent
{
FactionId = "inheritors",
Magnitude = 20,
OriginTileX = 100, OriginTileY = 100,
});
int enforcerLocal = RepPropagation.LocalStandingFor("covenant_enforcers",
Sett(1, 100, 100), 0xCAFEUL, ledger, f);
Assert.Equal(-10, enforcerLocal);
}
[Fact]
public void ExtremeMagnitude_BypassesDecay()
{
var f = Factions();
var ledger = new RepLedger();
ledger.Append(new RepEvent
{
FactionId = "inheritors",
Magnitude = 60, // ≥ REP_EXTREME_BYPASS_MAGNITUDE
OriginTileX = 0, OriginTileY = 0,
});
// Settlement on the frontier (>200 tiles away).
int far = RepPropagation.LocalStandingFor("inheritors",
Sett(99, 250, 250), 0xCAFEUL, ledger, f);
Assert.Equal(60, far);
}
[Fact]
public void FrontierDelivered_IsDeterministic()
{
// Same seed, same event id, same settlement id → same answer.
bool a = RepPropagation.FrontierDelivered(0xCAFEUL, 1, 5);
bool b = RepPropagation.FrontierDelivered(0xCAFEUL, 1, 5);
Assert.Equal(a, b);
// Vary one input → may differ; just confirm we don't always hit one branch.
int trues = 0, falses = 0;
for (int i = 1; i <= 100; i++)
{
if (RepPropagation.FrontierDelivered(0xCAFEUL, i, 5)) trues++;
else falses++;
}
Assert.True(trues > 20 && falses > 20,
$"Expected roughly 50/50 distribution; got trues={trues}, falses={falses}");
}
[Fact]
public void FrontierEvent_OnlyAppliesWhenDelivered()
{
var f = Factions();
var ledger = new RepLedger();
ledger.Append(new RepEvent
{
SequenceId = 0, // assigned by Append → 1
FactionId = "inheritors",
Magnitude = 20,
OriginTileX = 0, OriginTileY = 0,
});
// Frontier settlement.
var s = Sett(1, 250, 250);
int got = RepPropagation.LocalStandingFor("inheritors", s, 0xCAFEUL, ledger, f);
// Either 0 (not delivered) or +4 (20 × 20%).
Assert.True(got == 0 || got == (int)System.Math.Round(20 * 0.20f),
$"frontier delivery should be 0 or {System.Math.Round(20 * 0.20f)}, got {got}");
}
[Fact]
public void ExplainLocalStanding_ReturnsRecentEvents()
{
var f = Factions();
var ledger = new RepLedger();
ledger.Append(new RepEvent
{
FactionId = "inheritors", Magnitude = 10, Note = "first",
OriginTileX = 100, OriginTileY = 100,
});
ledger.Append(new RepEvent
{
FactionId = "inheritors", Magnitude = -5, Note = "second",
OriginTileX = 100, OriginTileY = 100,
});
var s = Sett(1, 100, 100);
var explained = RepPropagation.ExplainLocalStanding("inheritors", s, 0xCAFEUL, ledger, f, max: 8).ToList();
Assert.Equal(2, explained.Count);
// Most-recent-first.
Assert.Equal("second", explained[0].Event.Note);
Assert.Equal("first", explained[1].Event.Note);
}
}
@@ -0,0 +1,105 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Persistence;
using Theriapolis.Core.Rules.Reputation;
using Xunit;
namespace Theriapolis.Tests.Reputation;
/// <summary>
/// Phase 6 M2 — round-trip <see cref="PlayerReputation"/> through the
/// codec. Every field must survive Capture → Restore identically. Plus
/// the SaveCodec serialization path itself: write a SaveBody with rep
/// data, parse it back, compare.
/// </summary>
public sealed class ReputationRoundTripTests
{
[Fact]
public void CaptureRestore_FactionStandings_RoundTrips()
{
var factions = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Factions;
var rep = new PlayerReputation();
rep.Factions.Apply("inheritors", 30, factions);
rep.Factions.Apply("merchant_guilds", 50, factions);
var snap = ReputationCodec.Capture(rep);
var restored = ReputationCodec.Restore(snap);
Assert.Equal(rep.Factions.Get("inheritors"), restored.Factions.Get("inheritors"));
Assert.Equal(rep.Factions.Get("covenant_enforcers"), restored.Factions.Get("covenant_enforcers"));
Assert.Equal(rep.Factions.Get("merchant_guilds"), restored.Factions.Get("merchant_guilds"));
}
[Fact]
public void CaptureRestore_PersonalDispositions_RoundTrip()
{
var rep = new PlayerReputation();
var pd = rep.PersonalFor("millhaven.innkeeper");
pd.Score = 25;
pd.Trust = TrustLevel.Familiar;
pd.Betrayed = false;
pd.Memory.Add("saved-her-kit");
pd.Memory.Add("paid-for-the-window");
pd.Log.Add(new RepEvent { Kind = RepEventKind.Aid, RoleTag = "millhaven.innkeeper",
Magnitude = 10, Note = "first" });
var snap = ReputationCodec.Capture(rep);
var restored = ReputationCodec.Restore(snap);
Assert.True(restored.Personal.ContainsKey("millhaven.innkeeper"));
var rPd = restored.Personal["millhaven.innkeeper"];
Assert.Equal(25, rPd.Score);
Assert.Equal(TrustLevel.Familiar, rPd.Trust);
Assert.Contains("saved-her-kit", rPd.Memory);
Assert.Contains("paid-for-the-window", rPd.Memory);
Assert.Single(rPd.Log);
Assert.Equal("first", rPd.Log[0].Note);
}
[Fact]
public void CaptureRestore_Ledger_RoundTrips()
{
var factions = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Factions;
var rep = new PlayerReputation();
rep.Submit(new RepEvent { Kind = RepEventKind.Quest, FactionId = "inheritors", Magnitude = 10, Note = "a" }, factions);
rep.Submit(new RepEvent { Kind = RepEventKind.Betrayal, FactionId = "thorn_council", Magnitude = -25, Note = "b" }, factions);
var snap = ReputationCodec.Capture(rep);
var restored = ReputationCodec.Restore(snap);
Assert.Equal(rep.Ledger.Count, restored.Ledger.Count);
Assert.Equal(rep.Ledger.Entries[0].Note, restored.Ledger.Entries[0].Note);
Assert.Equal(rep.Ledger.Entries[1].Kind, restored.Ledger.Entries[1].Kind);
}
[Fact]
public void SaveCodec_RoundTripsReputationState()
{
var factions = new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Factions;
var body = new SaveBody();
body.Player.Name = "Tester";
body.Player.PositionX = 100;
body.Player.PositionY = 200;
// Populate reputation state.
var rep = new PlayerReputation();
rep.Submit(new RepEvent { Kind = RepEventKind.Quest, FactionId = "inheritors", Magnitude = 10 }, factions);
var pd = rep.PersonalFor("millhaven.constable_fenn");
pd.Score = 7;
pd.Memory.Add("met-at-the-magistrate");
body.ReputationState = ReputationCodec.Capture(rep);
var header = new SaveHeader { Version = C.SAVE_SCHEMA_VERSION, WorldSeedHex = "0xCAFE" };
var bytes = SaveCodec.Serialize(header, body);
var (h2, b2) = SaveCodec.Deserialize(bytes);
Assert.Equal(C.SAVE_SCHEMA_VERSION, h2.Version);
Assert.Equal( 10, b2.ReputationState.FactionStandings["inheritors"]);
Assert.Equal( -5, b2.ReputationState.FactionStandings["covenant_enforcers"]);
Assert.Single(b2.ReputationState.Personal);
Assert.Equal("millhaven.constable_fenn", b2.ReputationState.Personal[0].RoleTag);
Assert.Contains("met-at-the-magistrate", b2.ReputationState.Personal[0].MemoryTags);
Assert.Single(b2.ReputationState.Ledger);
}
}
@@ -0,0 +1,95 @@
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Rules;
public sealed class AbilityScoreTests
{
[Theory]
[InlineData(1, -5)]
[InlineData(8, -1)]
[InlineData(9, -1)]
[InlineData(10, 0)]
[InlineData(11, 0)]
[InlineData(12, 1)]
[InlineData(15, 2)]
[InlineData(18, 4)]
[InlineData(20, 5)]
[InlineData(30, 10)]
public void Mod_MatchesD20Table(int score, int expected)
{
Assert.Equal(expected, AbilityScores.Mod(score));
}
[Fact]
public void Mod_FloorsTowardNegativeInfinity()
{
// Score 9 → -1, score 7 → -2 (per d20 floor convention; not C# truncate)
Assert.Equal(-1, AbilityScores.Mod(9));
Assert.Equal(-2, AbilityScores.Mod(7));
Assert.Equal(-3, AbilityScores.Mod(5));
}
[Fact]
public void Constructor_ClampsToValidRange()
{
var a = new AbilityScores(0, 31, 50, -10, 100, 18);
Assert.Equal(1, a.STR); // 0 clamped up to 1
Assert.Equal(30, a.DEX); // 31 clamped down to 30
Assert.Equal(30, a.CON);
Assert.Equal(1, a.INT); // -10 clamped up to 1
Assert.Equal(30, a.WIS);
Assert.Equal(18, a.CHA);
}
[Fact]
public void StandardArray_IsCanonical()
{
Assert.Equal(new[] { 15, 14, 13, 12, 10, 8 }, AbilityScores.StandardArray);
}
[Fact]
public void Get_ReturnsValueByAbilityId()
{
var a = new AbilityScores(11, 13, 15, 10, 12, 14);
Assert.Equal(11, a.Get(AbilityId.STR));
Assert.Equal(13, a.Get(AbilityId.DEX));
Assert.Equal(15, a.Get(AbilityId.CON));
Assert.Equal(10, a.Get(AbilityId.INT));
Assert.Equal(12, a.Get(AbilityId.WIS));
Assert.Equal(14, a.Get(AbilityId.CHA));
}
[Fact]
public void With_ReturnsNewBlock_LeavesOriginalUnchanged()
{
var a = new AbilityScores(10, 10, 10, 10, 10, 10);
var b = a.With(AbilityId.STR, 18);
Assert.Equal(10, a.STR); // unchanged
Assert.Equal(18, b.STR);
Assert.Equal(10, b.DEX); // others copied
}
[Fact]
public void Plus_AppliesAllMods()
{
var a = new AbilityScores(10, 10, 10, 10, 10, 10);
var mods = new Dictionary<AbilityId, int> { { AbilityId.STR, 1 }, { AbilityId.WIS, 2 } };
var b = a.Plus(mods);
Assert.Equal(11, b.STR);
Assert.Equal(12, b.WIS);
Assert.Equal(10, b.DEX);
}
[Fact]
public void ModFor_UsesStandardFormula()
{
var a = new AbilityScores(15, 14, 13, 12, 10, 8);
Assert.Equal( 2, a.ModFor(AbilityId.STR)); // (15-10)/2 = 2
Assert.Equal( 2, a.ModFor(AbilityId.DEX)); // (14-10)/2 = 2
Assert.Equal( 1, a.ModFor(AbilityId.CON)); // (13-10)/2 = 1
Assert.Equal( 1, a.ModFor(AbilityId.INT));
Assert.Equal( 0, a.ModFor(AbilityId.WIS));
Assert.Equal(-1, a.ModFor(AbilityId.CHA));
}
}
@@ -0,0 +1,163 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Rules;
/// <summary>
/// CharacterBuilder smoke + integration: every (clade × species) pair,
/// when assigned a representative class, produces a valid level-1 character
/// with sane HP and ability totals.
/// </summary>
public sealed class CharacterBuilderTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void Build_DefaultsProduceValidCharacter()
{
var c = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised").Build();
Assert.Equal("canidae", c.Clade.Id);
Assert.Equal("wolf", c.Species.Id);
Assert.Equal("fangsworn", c.ClassDef.Id);
Assert.Equal(1, c.Level);
Assert.Equal(0, c.Xp);
Assert.True(c.MaxHp > 0);
Assert.Equal(c.MaxHp, c.CurrentHp);
Assert.True(c.SkillProficiencies.Count >= c.ClassDef.SkillsChoose);
}
[Fact]
public void Build_AppliesCladeAndSpeciesAbilityMods()
{
// Wolf-Folk: STR+1 (species), CON+1 WIS+1 (canid clade)
// Standard Array assigned in class priority — we use a known base for
// the test instead of relying on auto-assignment.
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
var c = b.Build();
Assert.Equal(11, c.Abilities.STR); // 10 + species
Assert.Equal(10, c.Abilities.DEX);
Assert.Equal(11, c.Abilities.CON); // 10 + clade
Assert.Equal(10, c.Abilities.INT);
Assert.Equal(11, c.Abilities.WIS); // 10 + clade
Assert.Equal(10, c.Abilities.CHA);
}
[Fact]
public void Build_HpUsesHitDiePlusConModifier()
{
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
b.BaseAbilities = new AbilityScores(10, 10, 14, 10, 10, 10); // CON 14 → +2 → +1 after canid (15→+2)
var c = b.Build();
// Wolf-Folk has no CON mod from species, canid +1 → final CON = 15 → +2.
// Fangsworn d10 + 2 = 12.
Assert.Equal(12, c.MaxHp);
}
[Fact]
public void Validate_RejectsSpeciesNotInChosenClade()
{
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
b.Species = _content.Species["lion"]; // felid species under canid clade
bool ok = b.Validate(out var err);
Assert.False(ok);
Assert.Contains("clade", err.ToLowerInvariant());
}
[Fact]
public void Validate_RequiresExactSkillCount()
{
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
b.ChosenClassSkills.Clear();
Assert.False(b.Validate(out _));
}
[Fact]
public void Validate_RejectsSkillNotOfferedByClass()
{
var b = MinimalBuilder("canidae", "wolf", "fangsworn", "pack_raised");
b.ChosenClassSkills.Clear();
b.ChosenClassSkills.Add(SkillId.Athletics);
b.ChosenClassSkills.Add(SkillId.Arcana); // Fangsworn does not offer Arcana
Assert.False(b.Validate(out var err));
Assert.Contains("arcana", err.ToLowerInvariant());
}
[Theory]
[InlineData("canidae", "wolf", "fangsworn", "pack_raised")]
[InlineData("felidae", "lion", "shadow_pelt", "borderland_stray")]
[InlineData("ursidae", "brown_bear", "feral", "coliseum_survivor")]
[InlineData("cervidae", "elk", "covenant_keeper", "covenant_enforcer")]
[InlineData("bovidae", "bull", "bulwark", "herd_city_born")]
[InlineData("leporidae", "rabbit", "muzzle_speaker", "warren_runner")]
[InlineData("mustelidae","badger", "claw_wright", "borderland_stray")]
[InlineData("felidae", "housecat", "scent_broker", "scent_suppressed")]
public void Build_AllRepresentativeCombosValid(
string cladeId, string speciesId, string classId, string bgId)
{
var c = MinimalBuilder(cladeId, speciesId, classId, bgId).Build();
Assert.True(c.MaxHp > 0);
Assert.True(c.IsAlive);
}
[Fact]
public void RollAbilityScores_IsDeterministicGivenSameInputs()
{
var a = CharacterBuilder.RollAbilityScores(0xCAFE, 1234);
var b = CharacterBuilder.RollAbilityScores(0xCAFE, 1234);
Assert.Equal(a.STR, b.STR);
Assert.Equal(a.DEX, b.DEX);
Assert.Equal(a.CON, b.CON);
Assert.Equal(a.INT, b.INT);
Assert.Equal(a.WIS, b.WIS);
Assert.Equal(a.CHA, b.CHA);
}
[Fact]
public void RollAbilityScores_DifferentMsProducesDifferentResults()
{
var a = CharacterBuilder.RollAbilityScores(0xCAFE, 1234);
var b = CharacterBuilder.RollAbilityScores(0xCAFE, 5678);
// At least one of six should differ across plausibly-different rolls.
Assert.True(
a.STR != b.STR || a.DEX != b.DEX || a.CON != b.CON ||
a.INT != b.INT || a.WIS != b.WIS || a.CHA != b.CHA);
}
[Fact]
public void Roll4d6DropLowest_ResultIs3to18()
{
var rng = new SeededRng(0xDEADBEEFUL);
for (int i = 0; i < 1000; i++)
{
int v = CharacterBuilder.Roll4d6DropLowest(rng);
Assert.InRange(v, 3, 18);
}
}
// ── Helpers ──────────────────────────────────────────────────────────
private CharacterBuilder MinimalBuilder(string cladeId, string speciesId, string classId, string bgId)
{
var b = new CharacterBuilder
{
Clade = _content.Clades[cladeId],
Species = _content.Species[speciesId],
ClassDef = _content.Classes[classId],
Background = _content.Backgrounds[bgId],
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
Name = "Test",
};
// Auto-pick first N skill options
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;
}
}
@@ -0,0 +1,133 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Rules;
public sealed class DerivedStatsTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void ArmorClass_Unarmored_Is10PlusDexMod()
{
var c = MakeCharacter(dex: 14); // DEX 15 after wolf+canid mods (DEX +0 species, +0 clade) → final DEX 14, +2 mod
// Wolf-Folk: STR+1, no DEX mod from species; canid: CON+1, WIS+1.
// Base DEX 14 → final 14 → mod +2.
// Unarmored: 10 + 2 = 12.
Assert.Equal(12, DerivedStats.ArmorClass(c));
}
[Fact]
public void ArmorClass_LightArmor_AddsBaseAndDex()
{
var c = MakeCharacter(dex: 14);
var hide = c.Inventory.Add(_content.Items["hide_vest"]);
c.Inventory.TryEquip(hide, EquipSlot.Body, out _);
// Hide vest base 11, max DEX -1 (unlimited), DEX mod +2 → AC 13.
Assert.Equal(13, DerivedStats.ArmorClass(c));
}
[Fact]
public void ArmorClass_MediumArmor_CapsDex()
{
var c = MakeCharacter(dex: 18); // base 18 → final 18 → mod +4
var chain = c.Inventory.Add(_content.Items["chain_shirt"]);
c.Inventory.TryEquip(chain, EquipSlot.Body, out _);
// Chain shirt base 13, max DEX 2 → effective DEX bonus 2 → AC 15.
Assert.Equal(15, DerivedStats.ArmorClass(c));
}
[Fact]
public void ArmorClass_HeavyArmor_IgnoresDex()
{
var c = MakeCharacter(dex: 18);
var mail = c.Inventory.Add(_content.Items["chain_mail"]);
c.Inventory.TryEquip(mail, EquipSlot.Body, out _);
// Chain mail base 16, max DEX 0 → AC 16.
Assert.Equal(16, DerivedStats.ArmorClass(c));
}
[Fact]
public void ArmorClass_ShieldAdds()
{
var c = MakeCharacter(dex: 14);
var chain = c.Inventory.Add(_content.Items["chain_shirt"]);
c.Inventory.TryEquip(chain, EquipSlot.Body, out _);
var shield = c.Inventory.Add(_content.Items["standard_shield"]);
c.Inventory.TryEquip(shield, EquipSlot.OffHand, out _);
// 13 + min(2, 2) + 2 (shield) = 17.
Assert.Equal(17, DerivedStats.ArmorClass(c));
}
[Fact]
public void Speed_BaseMatchesSpecies()
{
var c = MakeCharacter();
Assert.Equal(c.Species.BaseSpeedFt, DerivedStats.SpeedFt(c));
}
[Fact]
public void CarryCapacity_StrTimes15TimesSizeMult()
{
var c = MakeCharacter(str: 14); // STR 15 after wolf+1
// Wolf-Folk is medium_large → mult = 1.0
Assert.Equal(15f * 15f * 1.0f, DerivedStats.CarryCapacityLb(c));
}
[Fact]
public void Encumbrance_LightWhenWellUnderCap()
{
var c = MakeCharacter(str: 14);
c.Inventory.Add(_content.Items["fang_knife"]); // 0.5 lb
Assert.Equal(DerivedStats.EncumbranceBand.Light, DerivedStats.Encumbrance(c));
Assert.Equal(1.0f, DerivedStats.TacticalSpeedMult(c));
}
[Fact]
public void Encumbrance_HeavyWhenOverSoftThreshold()
{
var c = MakeCharacter(str: 8); // STR 9 after wolf+1, cap = 9 * 15 = 135 lb
for (int i = 0; i < 60; i++) c.Inventory.Add(_content.Items["chain_mail"]); // 60 * 40 lb = 2400 lb
var enc = DerivedStats.Encumbrance(c);
Assert.Equal(DerivedStats.EncumbranceBand.Over, enc);
Assert.Equal(0.5f, DerivedStats.TacticalSpeedMult(c));
}
[Fact]
public void Speed_DropsWhenEncumbered()
{
var c = MakeCharacter(str: 8);
int baseSpeed = DerivedStats.SpeedFt(c);
// Pile on chain mail to push past the hard threshold (1.5x cap).
for (int i = 0; i < 60; i++) c.Inventory.Add(_content.Items["chain_mail"]);
int encSpeed = DerivedStats.SpeedFt(c);
Assert.True(encSpeed < baseSpeed, $"Encumbered speed ({encSpeed}) should be less than base ({baseSpeed})");
}
[Fact]
public void Initiative_EqualsDexMod()
{
var c = MakeCharacter(dex: 18);
// DEX 18 → mod +4 (no DEX mod from wolf-folk or canid clade)
Assert.Equal(4, DerivedStats.Initiative(c));
}
private Character MakeCharacter(int str = 10, int dex = 10, int con = 10, int @int = 10, int wis = 10, int cha = 10)
{
var b = new CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(str, dex, con, @int, wis, cha),
Name = "Test",
};
b.ChosenClassSkills.Add(SkillId.Athletics);
b.ChosenClassSkills.Add(SkillId.Intimidation);
return b.Build(); // no starting kit — tests build inventory explicitly
}
}
@@ -0,0 +1,304 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Rules;
/// <summary>
/// Phase 6.5 M4 — hybrid character creation. Validates the Sire/Dam
/// picker logic, blended ability mods, dominant-parent presentation,
/// universal hybrid detriments (Scent Dysphoria save DC, Social Stigma
/// penalty, Medical Incompatibility healing scale), and cross-clade
/// enforcement.
/// </summary>
public sealed class HybridCharacterTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Validation ────────────────────────────────────────────────────────
[Fact]
public void TryBuildHybrid_RejectsSameClade()
{
var b = MakeHybridBuilder("canidae", "wolf", "canidae", "fox");
bool ok = b.TryBuildHybrid(_content.Items, out _, out string err);
Assert.False(ok);
Assert.Contains("different clades", err.ToLowerInvariant());
}
[Fact]
public void TryBuildHybrid_RejectsSpeciesNotInClade()
{
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "wolf");
// dam species "wolf" doesn't belong to leporidae
bool ok = b.TryBuildHybrid(_content.Items, out _, out string err);
Assert.False(ok);
Assert.Contains("clade", err.ToLowerInvariant());
}
[Fact]
public void TryBuildHybrid_RejectsMissingSire()
{
var b = NewBuilderWithClassAndSkills();
b.HybridDamClade = _content.Clades["leporidae"];
b.HybridDamSpecies = _content.Species["rabbit"];
bool ok = b.TryBuildHybrid(_content.Items, out _, out string err);
Assert.False(ok);
Assert.Contains("sire", err.ToLowerInvariant());
}
[Fact]
public void TryBuildHybrid_RejectsMissingClass()
{
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
b.ClassDef = null; // strip
bool ok = b.TryBuildHybrid(_content.Items, out _, out string err);
Assert.False(ok);
Assert.Contains("class", err.ToLowerInvariant());
}
// ── Build happy path ──────────────────────────────────────────────────
[Fact]
public void TryBuildHybrid_ProducesHybridCharacterWithGenealogy()
{
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
bool ok = b.TryBuildHybrid(_content.Items, out var c, out string err);
Assert.True(ok, err);
Assert.NotNull(c);
Assert.True(c!.IsHybrid);
Assert.NotNull(c.Hybrid);
Assert.Equal("canidae", c.Hybrid!.SireClade);
Assert.Equal("wolf", c.Hybrid.SireSpecies);
Assert.Equal("leporidae", c.Hybrid.DamClade);
Assert.Equal("rabbit", c.Hybrid.DamSpecies);
Assert.Equal(ParentLineage.Sire, c.Hybrid.DominantParent); // default
Assert.False(c.Hybrid.PassingActive);
}
[Fact]
public void TryBuildHybrid_DominantParentDrivesPresentingClade()
{
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
b.HybridDominantParent = ParentLineage.Dam;
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
Assert.True(ok);
// The character's primary Clade/Species should track the dominant
// parent so existing systems keying off Character.Clade get the
// presenting clade.
Assert.Equal("leporidae", c!.Clade.Id);
Assert.Equal("rabbit", c.Species.Id);
Assert.Equal("leporidae", c.Hybrid!.PresentingCladeId);
}
[Fact]
public void TryBuildHybrid_BlendsAbilityMods()
{
// Wolf-Folk Sire:
// canidae clade: +1 CON, +1 WIS
// wolf species: +1 STR
// × Rabbit-Folk Dam:
// leporidae clade: -1 STR, +2 DEX
// rabbit species: +1 WIS
// Base 10 across the board.
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
Assert.True(ok);
// Net STR: 10 + 1 (wolf) - 1 (leporid) = 10.
// Net DEX: 10 + 2 (leporid) = 12.
// Net CON: 10 + 1 (canid) = 11.
// Net WIS: 10 + 1 (canid) + 1 (rabbit) = 12.
Assert.Equal(10, c!.Abilities.STR);
Assert.Equal(12, c.Abilities.DEX);
Assert.Equal(11, c.Abilities.CON);
Assert.Equal(12, c.Abilities.WIS);
}
// ── Cross-clade pairings smoke ────────────────────────────────────────
[Theory]
[InlineData("canidae", "wolf", "felidae", "lion")]
[InlineData("canidae", "coyote", "leporidae", "hare")]
[InlineData("ursidae", "brown_bear", "bovidae", "bull")]
[InlineData("felidae", "leopard", "cervidae", "deer")]
[InlineData("mustelidae","badger", "leporidae", "rabbit")]
[InlineData("bovidae", "ram", "cervidae", "elk")]
[InlineData("leporidae", "rabbit", "felidae", "housecat")]
public void TryBuildHybrid_AllCrossCladeCombinationsValid(
string sireClade, string sireSpecies, string damClade, string damSpecies)
{
var b = MakeHybridBuilder(sireClade, sireSpecies, damClade, damSpecies);
bool ok = b.TryBuildHybrid(_content.Items, out var c, out string err);
Assert.True(ok, err);
Assert.True(c!.MaxHp > 0);
Assert.True(c.IsAlive);
Assert.True(c.IsHybrid);
}
// ── HybridDetriments ──────────────────────────────────────────────────
[Fact]
public void HybridDetriments_HaveDocumentedConstants()
{
Assert.Equal(10, HybridDetriments.ScentDysphoriaSaveDc);
Assert.Equal(-2, HybridDetriments.SocialStigmaFirstCheckPenalty);
Assert.Equal(0.75f, HybridDetriments.MedicalIncompatibilityMultiplier);
Assert.True(HybridDetriments.IllegibleBodyLanguagePenalty);
}
[Fact]
public void ScaleHealForHybrid_AppliesMultiplierToHybrids()
{
var hybrid = MakeHybrid();
Assert.Equal(6, HybridDetriments.ScaleHealForHybrid(hybrid, 8)); // floor(8*0.75)=6
Assert.Equal(3, HybridDetriments.ScaleHealForHybrid(hybrid, 4)); // floor(4*0.75)=3
// Min 1 floor: a hybrid healed for 1 raw still gets 1.
Assert.Equal(1, HybridDetriments.ScaleHealForHybrid(hybrid, 1));
}
[Fact]
public void ScaleHealForHybrid_NoOpForPurebreds()
{
var purebred = MakePurebred();
Assert.Equal(8, HybridDetriments.ScaleHealForHybrid(purebred, 8));
Assert.Equal(1, HybridDetriments.ScaleHealForHybrid(purebred, 1));
}
[Fact]
public void ScaleHealForHybrid_PassesThroughZeroAndNegative()
{
var hybrid = MakeHybrid();
Assert.Equal(0, HybridDetriments.ScaleHealForHybrid(hybrid, 0));
Assert.Equal(-3, HybridDetriments.ScaleHealForHybrid(hybrid, -3));
}
// ── Save round-trip ───────────────────────────────────────────────────
[Fact]
public void Hybrid_RoundTripsThroughCharacterCodec()
{
var c = MakeHybrid();
c.Hybrid!.PassingActive = true;
c.Hybrid.NpcsWhoKnow.Add(42);
c.Hybrid.NpcsWhoKnow.Add(99);
var snap = Theriapolis.Core.Persistence.CharacterCodec.Capture(c);
Assert.NotNull(snap.Hybrid);
Assert.Equal("canidae", snap.Hybrid!.SireClade);
Assert.Equal("leporidae", snap.Hybrid.DamClade);
Assert.True(snap.Hybrid.PassingActive);
Assert.Equal(2, snap.Hybrid.NpcsWhoKnow.Length);
var restored = Theriapolis.Core.Persistence.CharacterCodec.Restore(snap, _content);
Assert.NotNull(restored.Hybrid);
Assert.Equal("canidae", restored.Hybrid!.SireClade);
Assert.Equal("wolf", restored.Hybrid.SireSpecies);
Assert.Equal("leporidae", restored.Hybrid.DamClade);
Assert.Equal("rabbit", restored.Hybrid.DamSpecies);
Assert.True(restored.Hybrid.PassingActive);
Assert.Contains(42, restored.Hybrid.NpcsWhoKnow);
Assert.Contains(99, restored.Hybrid.NpcsWhoKnow);
}
[Fact]
public void Purebred_RoundTripDoesNotEmitHybridSection()
{
var c = MakePurebred();
var snap = Theriapolis.Core.Persistence.CharacterCodec.Capture(c);
Assert.Null(snap.Hybrid);
var restored = Theriapolis.Core.Persistence.CharacterCodec.Restore(snap, _content);
Assert.Null(restored.Hybrid);
Assert.False(restored.IsHybrid);
}
[Fact]
public void Hybrid_RoundTripsThroughBinarySaveCodec()
{
var c = MakeHybrid();
c.Hybrid!.PassingActive = true;
var header = new Theriapolis.Core.Persistence.SaveHeader
{
Version = Theriapolis.Core.C.SAVE_SCHEMA_VERSION,
WorldSeedHex = "0xFEED",
};
var body = new Theriapolis.Core.Persistence.SaveBody
{
PlayerCharacter = Theriapolis.Core.Persistence.CharacterCodec.Capture(c),
};
body.Player.Id = 1;
body.Player.Name = "Hybrid";
var bytes = Theriapolis.Core.Persistence.SaveCodec.Serialize(header, body);
var (h2, body2) = Theriapolis.Core.Persistence.SaveCodec.Deserialize(bytes);
Assert.Equal(header.Version, h2.Version);
Assert.NotNull(body2.PlayerCharacter);
Assert.NotNull(body2.PlayerCharacter!.Hybrid);
Assert.Equal("canidae", body2.PlayerCharacter.Hybrid!.SireClade);
Assert.Equal("leporidae", body2.PlayerCharacter.Hybrid.DamClade);
Assert.True(body2.PlayerCharacter.Hybrid.PassingActive);
}
// ── Helpers ───────────────────────────────────────────────────────────
private CharacterBuilder NewBuilderWithClassAndSkills()
{
var b = new CharacterBuilder
{
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
Name = "Test",
IsHybridOrigin = true,
};
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;
}
private CharacterBuilder MakeHybridBuilder(
string sireClade, string sireSpecies,
string damClade, string damSpecies)
{
var b = NewBuilderWithClassAndSkills();
b.HybridSireClade = _content.Clades[sireClade];
b.HybridSireSpecies = _content.Species[sireSpecies];
b.HybridDamClade = _content.Clades[damClade];
b.HybridDamSpecies = _content.Species[damSpecies];
return b;
}
private Character MakeHybrid()
{
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
Assert.True(b.TryBuildHybrid(_content.Items, out var c, out _));
return c!;
}
private Character MakePurebred()
{
var b = new CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
Name = "Test",
};
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);
}
}
+240
View File
@@ -0,0 +1,240 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Rules;
/// <summary>
/// Phase 6.5 M0 — LevelUpFlow + Character.ApplyLevelUp coverage.
///
/// LevelUpFlow.Compute is pure; same (character, level, seed) → same payload.
/// ApplyLevelUp mutates in place; per-level deltas land on Level, MaxHp,
/// CurrentHp, LearnedFeatureIds, LevelUpHistory, and (when applicable)
/// SubclassId / Abilities.
/// </summary>
public sealed class LevelUpFlowTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
private Character MakeWolfFangsworn(int con = 10)
{
var b = new CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 12, con, 10, 13, 8),
Name = "Test",
};
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();
}
[Fact]
public void CanLevelUp_FalseAtZeroXp()
{
var c = MakeWolfFangsworn();
Assert.Equal(1, c.Level);
Assert.Equal(0, c.Xp);
Assert.False(LevelUpFlow.CanLevelUp(c));
}
[Fact]
public void CanLevelUp_TrueAt300Xp()
{
var c = MakeWolfFangsworn();
c.Xp = 300;
Assert.True(LevelUpFlow.CanLevelUp(c));
}
[Fact]
public void CanLevelUp_FalseAtLevelCap()
{
var c = MakeWolfFangsworn();
c.Level = C.CHARACTER_LEVEL_MAX;
c.Xp = 999_999;
Assert.False(LevelUpFlow.CanLevelUp(c));
}
[Fact]
public void Compute_TakeAverage_ReturnsExpectedHpGain()
{
var c = MakeWolfFangsworn(con: 14); // Wolf+canid: con+1 → 15 → +2 mod
var result = LevelUpFlow.Compute(c, targetLevel: 2, seed: 0xCAFE, takeAverage: true);
// Fangsworn d10: average rounded up = 6. CON mod = +2. → 8
Assert.Equal(2, result.NewLevel);
Assert.Equal(8, result.HpGained);
Assert.True(result.HpWasAveraged);
Assert.False(result.GrantsAsiChoice);
Assert.False(result.GrantsSubclassChoice);
}
[Fact]
public void Compute_RolledHp_IsDeterministicForSameSeed()
{
var c = MakeWolfFangsworn();
var a = LevelUpFlow.Compute(c, targetLevel: 2, seed: 0x1234, takeAverage: false);
var b = LevelUpFlow.Compute(c, targetLevel: 2, seed: 0x1234, takeAverage: false);
Assert.Equal(a.HpGained, b.HpGained);
Assert.Equal(a.HpHitDieResult, b.HpHitDieResult);
}
[Fact]
public void Compute_RolledHp_DifferentSeedsCanProduceDifferentResults()
{
var c = MakeWolfFangsworn();
var seen = new HashSet<int>();
for (ulong s = 1; s <= 50; s++)
{
var r = LevelUpFlow.Compute(c, targetLevel: 2, seed: s, takeAverage: false);
seen.Add(r.HpHitDieResult);
}
// With 50 different seeds and a d10, we should see at least 3 distinct rolls.
Assert.True(seen.Count >= 3, $"Expected variance across 50 seeds; saw {seen.Count} distinct rolls.");
}
[Fact]
public void Compute_Level3_GrantsSubclassChoice()
{
var c = MakeWolfFangsworn();
c.Level = 2;
var result = LevelUpFlow.Compute(c, targetLevel: 3, seed: 0xCAFE);
Assert.True(result.GrantsSubclassChoice);
// Fangsworn has at least one subclass id in classes.json.
Assert.NotEmpty(c.ClassDef.SubclassIds);
}
[Fact]
public void Compute_Level3_DoesNotGrantSubclassChoice_IfAlreadyPicked()
{
var c = MakeWolfFangsworn();
c.Level = 2;
c.SubclassId = "pack_forged"; // already picked somehow
var result = LevelUpFlow.Compute(c, targetLevel: 3, seed: 0xCAFE);
Assert.False(result.GrantsSubclassChoice);
}
[Fact]
public void Compute_Level4_GrantsAsiChoice()
{
var c = MakeWolfFangsworn();
c.Level = 3;
var result = LevelUpFlow.Compute(c, targetLevel: 4, seed: 0xCAFE);
Assert.True(result.GrantsAsiChoice);
}
[Fact]
public void Compute_ProficiencyBonus_FollowsTable()
{
var c = MakeWolfFangsworn();
Assert.Equal(2, LevelUpFlow.Compute(c, targetLevel: 2, seed: 0).NewProficiencyBonus);
Assert.Equal(3, LevelUpFlow.Compute(c, targetLevel: 5, seed: 0).NewProficiencyBonus);
Assert.Equal(4, LevelUpFlow.Compute(c, targetLevel: 9, seed: 0).NewProficiencyBonus);
Assert.Equal(5, LevelUpFlow.Compute(c, targetLevel: 13, seed: 0).NewProficiencyBonus);
Assert.Equal(6, LevelUpFlow.Compute(c, targetLevel: 17, seed: 0).NewProficiencyBonus);
}
[Fact]
public void Compute_FeaturesUnlocked_FollowsLevelTable()
{
var c = MakeWolfFangsworn();
var lv2 = LevelUpFlow.Compute(c, targetLevel: 2, seed: 0);
// Fangsworn level 2 grants Action Surge per the level table.
Assert.NotEmpty(lv2.ClassFeaturesUnlocked);
}
[Fact]
public void ApplyLevelUp_MutatesLevelHpAndHistory()
{
var c = MakeWolfFangsworn(con: 10); // canid +1 → CON 11 → mod 0
int hpBefore = c.MaxHp;
var result = LevelUpFlow.Compute(c, 2, 0xCAFE);
c.ApplyLevelUp(result, new LevelUpChoices { TakeAverageHp = true });
Assert.Equal(2, c.Level);
Assert.Equal(hpBefore + result.HpGained, c.MaxHp);
Assert.Equal(c.MaxHp, c.CurrentHp); // level-up restores HP
Assert.Single(c.LevelUpHistory);
Assert.Equal(2, c.LevelUpHistory[0].Level);
}
[Fact]
public void ApplyLevelUp_RecordsClassFeatures()
{
var c = MakeWolfFangsworn();
var result = LevelUpFlow.Compute(c, 2, 0xCAFE);
int beforeCount = c.LearnedFeatureIds.Count;
c.ApplyLevelUp(result, new LevelUpChoices());
Assert.Equal(beforeCount + result.ClassFeaturesUnlocked.Length, c.LearnedFeatureIds.Count);
}
[Fact]
public void ApplyLevelUp_WithSubclassChoice_WritesSubclassId()
{
var c = MakeWolfFangsworn();
c.Level = 2;
c.Xp = XpTable.Threshold[3];
var result = LevelUpFlow.Compute(c, 3, 0xCAFE);
var subId = c.ClassDef.SubclassIds[0];
c.ApplyLevelUp(result, new LevelUpChoices { SubclassId = subId });
Assert.Equal(subId, c.SubclassId);
Assert.Equal(subId, c.LevelUpHistory[^1].SubclassChosen);
}
[Fact]
public void ApplyLevelUp_WithAsi_RaisesAbilityScores()
{
var c = MakeWolfFangsworn();
c.Level = 3;
c.Xp = XpTable.Threshold[4];
int strBefore = c.Abilities.STR;
var result = LevelUpFlow.Compute(c, 4, 0xCAFE);
c.ApplyLevelUp(result, new LevelUpChoices
{
AsiAdjustments = new() { { AbilityId.STR, 2 } },
});
Assert.Equal(strBefore + 2, c.Abilities.STR);
}
[Fact]
public void ApplyLevelUp_AsiClampsAtAbilityCap()
{
var c = MakeWolfFangsworn();
c.Level = 3;
c.SetAbilities(c.Abilities.With(AbilityId.STR, C.ABILITY_SCORE_CAP_PRE_L20));
var result = LevelUpFlow.Compute(c, 4, 0xCAFE);
c.ApplyLevelUp(result, new LevelUpChoices
{
AsiAdjustments = new() { { AbilityId.STR, 2 } },
});
Assert.Equal(C.ABILITY_SCORE_CAP_PRE_L20, c.Abilities.STR);
}
[Fact]
public void ApplyLevelUp_ChainedLevels_AccumulateHistoryInOrder()
{
var c = MakeWolfFangsworn();
for (int target = 2; target <= 5; target++)
{
var r = LevelUpFlow.Compute(c, target, 0xCAFE_CAFE_CAFEUL ^ (ulong)target);
var choices = new LevelUpChoices();
if (r.GrantsSubclassChoice)
choices.SubclassId = c.ClassDef.SubclassIds[0];
if (r.GrantsAsiChoice)
choices.AsiAdjustments = new() { { AbilityId.CON, 2 } };
c.ApplyLevelUp(r, choices);
}
Assert.Equal(5, c.Level);
Assert.Equal(4, c.LevelUpHistory.Count);
for (int i = 0; i < 4; i++)
Assert.Equal(i + 2, c.LevelUpHistory[i].Level);
}
}
@@ -0,0 +1,377 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Rules;
/// <summary>
/// Phase 6.5 M5 — passing detection. Hybrid PCs with PassingActive get a
/// scent-detection roll on encountering scent-capable NPCs. The result is
/// permanent per-NPC (cached in Hybrid.NpcsWhoKnow + the NPC's
/// PersonalDisposition.Memory). Once detected, EffectiveDisposition layers
/// in the NPC's BiasProfile.HybridBias.
/// </summary>
public sealed class PassingDetectionTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
// ── Roll outcomes ─────────────────────────────────────────────────────
[Fact]
public void Roll_NotApplicable_ForPurebredPc()
{
var pc = MakePurebred();
var npc = MakeCanidNpc();
var result = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0xCAFE);
Assert.Equal(DetectionResult.NotApplicable, result);
}
[Fact]
public void Roll_PreviouslyDetected_NoFreshRoll()
{
var pc = MakeHybrid(passing: true);
var npc = MakeCanidNpc();
var memory = new HashSet<string> { "knows_hybrid" };
var result = PassingCheck.Roll(pc, npc, memory, seed: 0xCAFE);
Assert.Equal(DetectionResult.PreviouslyDetected, result);
}
[Fact]
public void Roll_NotPassing_AutoDetected()
{
var pc = MakeHybrid(passing: false);
var npc = MakeCanidNpc();
var result = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0xCAFE);
Assert.Equal(DetectionResult.NotPassing, result);
}
[Fact]
public void Roll_DeepCoverMask_AlwaysSuppresses()
{
var pc = MakeHybrid(passing: true);
pc.Hybrid!.ActiveMaskTier = ScentMaskTier.DeepCover;
var npc = MakeCanidNpc();
// Even Canid Superior Scent fails against deep cover.
var result = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0xCAFE);
Assert.Equal(DetectionResult.MaskSuppressed, result);
}
[Fact]
public void Roll_MilitaryMask_SuppressesNonCanid()
{
var pc = MakeHybrid(passing: true);
pc.Hybrid!.ActiveMaskTier = ScentMaskTier.Military;
// M5 simplification: only Canid NPCs detect scent. Test the path
// by giving the NPC a non-Canid clade — military mask suppresses
// automatically for non-superior-scent NPCs.
var npc = MakeNonCanidNpc();
var result = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0xCAFE);
// Non-Canid NPCs lack scent capability anyway, so result is NoCapability.
// Military mask short-circuits earlier with MaskSuppressed.
Assert.Equal(DetectionResult.MaskSuppressed, result);
}
[Fact]
public void Roll_NoCapability_ForNonScentNpc()
{
var pc = MakeHybrid(passing: true);
var npc = MakeNonCanidNpc();
var result = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0xCAFE);
Assert.Equal(DetectionResult.NoCapability, result);
}
[Fact]
public void Roll_IsDeterministic_ForSameSeed()
{
var pc = MakeHybrid(passing: true);
var npc = MakeCanidNpc();
// Use a fresh memory set each time so PreviouslyDetected doesn't
// short-circuit.
var a = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0x1234);
var b = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 0x1234);
Assert.Equal(a, b);
}
[Fact]
public void Roll_DifferentSeeds_CanProduceDifferentOutcomes()
{
var pc = MakeHybrid(passing: true);
var npc = MakeCanidNpc();
// Sweep 50 seeds — at least one should differ from the first
// (probabilistic detection).
var first = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: 1UL);
bool sawDifferent = false;
for (ulong s = 2; s <= 50; s++)
{
var r = PassingCheck.Roll(pc, npc, new HashSet<string>(), seed: s);
if (r != first) { sawDifferent = true; break; }
}
Assert.True(sawDifferent, "expected some seed variance in detection outcomes");
}
[Fact]
public void Roll_BasicMaskFavoursPc()
{
// With a basic mask, the PC's Deception roll gets +5. Sweep many
// seeds and verify that masked rolls produce *more* Pass outcomes
// than unmasked rolls on average.
var pcMasked = MakeHybrid(passing: true);
pcMasked.Hybrid!.ActiveMaskTier = ScentMaskTier.Basic;
var pcUnmasked = MakeHybrid(passing: true);
var npc = MakeCanidNpc();
int maskedPasses = 0;
int unmaskedPasses = 0;
for (ulong s = 1; s <= 200; s++)
{
if (PassingCheck.Roll(pcMasked, npc, new HashSet<string>(), seed: s) == DetectionResult.Pass)
maskedPasses++;
if (PassingCheck.Roll(pcUnmasked, npc, new HashSet<string>(), seed: s) == DetectionResult.Pass)
unmaskedPasses++;
}
Assert.True(maskedPasses > unmaskedPasses,
$"basic mask should help: masked passes={maskedPasses}, unmasked passes={unmaskedPasses}");
}
// ── RollAndApply side effects ─────────────────────────────────────────
[Fact]
public void RollAndApply_NotPassing_WritesMemoryAndLedger()
{
var pc = MakeHybrid(passing: false);
var npc = MakeCanidNpcWithRole("test.canid");
var rep = new PlayerReputation();
var result = PassingCheck.RollAndApply(pc, npc, rep,
worldClockSeconds: 100L, seed: 0xCAFE);
Assert.Equal(DetectionResult.NotPassing, result);
Assert.Contains(npc.Id, pc.Hybrid!.NpcsWhoKnow);
Assert.Contains("knows_hybrid", rep.PersonalFor("test.canid").Memory);
Assert.Contains(rep.Ledger.Entries,
ev => ev.Kind == RepEventKind.HybridDetected && ev.RoleTag == "test.canid");
}
[Fact]
public void RollAndApply_PreviouslyDetected_DoesNotReWrite()
{
var pc = MakeHybrid(passing: true);
var npc = MakeCanidNpcWithRole("test.canid");
var rep = new PlayerReputation();
// Pre-seed memory to look like a prior detection.
rep.PersonalFor("test.canid").Memory.Add("knows_hybrid");
var result = PassingCheck.RollAndApply(pc, npc, rep,
worldClockSeconds: 100L, seed: 0xCAFE);
Assert.Equal(DetectionResult.PreviouslyDetected, result);
// No new HybridDetected event added (only the pre-existing memory tag).
Assert.DoesNotContain(rep.Ledger.Entries,
ev => ev.Kind == RepEventKind.HybridDetected);
}
[Fact]
public void RollAndApply_NotApplicable_NoSideEffects_ForPurebred()
{
var pc = MakePurebred();
var npc = MakeCanidNpcWithRole("test.canid");
var rep = new PlayerReputation();
var result = PassingCheck.RollAndApply(pc, npc, rep,
worldClockSeconds: 100L, seed: 0xCAFE);
Assert.Equal(DetectionResult.NotApplicable, result);
Assert.Empty(rep.Ledger.Entries);
Assert.False(rep.Personal.ContainsKey("test.canid"));
}
// ── EffectiveDisposition + HybridBias consumption ────────────────────
[Fact]
public void EffectiveDisposition_DoesNotApplyHybridBias_BeforeDetection()
{
var pc = MakeHybrid(passing: true);
var npc = MakeNpcWithBiasProfile("CERVID_CAUTIOUS");
var rep = new PlayerReputation();
// No detection yet — hybrid bias should not be in the disposition.
int beforeDisposition = EffectiveDisposition.For(npc, pc, rep, _content);
// Sanity: just confirm we get a number; what matters is the next
// assertion shows it differs after detection.
Assert.True(beforeDisposition > -100 && beforeDisposition < 100);
}
[Fact]
public void EffectiveDisposition_AppliesHybridBias_AfterDetection()
{
var pc = MakeHybrid(passing: true);
var npc = MakeNpcWithBiasProfile("CERVID_CAUTIOUS");
var rep = new PlayerReputation();
int before = EffectiveDisposition.For(npc, pc, rep, _content);
// Mark NPC as having detected.
pc.Hybrid!.NpcsWhoKnow.Add(npc.Id);
int after = EffectiveDisposition.For(npc, pc, rep, _content);
// CERVID_CAUTIOUS has a *negative* hybrid_bias per bias_profiles.json,
// so the disposition should drop after detection.
Assert.True(after < before,
$"expected disposition to drop after hybrid detection: before={before}, after={after}");
}
[Fact]
public void EffectiveDisposition_ProgressiveProfile_PositiveHybridBias()
{
var pc = MakeHybrid(passing: true);
var npc = MakeNpcWithBiasProfile("HYBRID_SURVIVOR");
var rep = new PlayerReputation();
int before = EffectiveDisposition.For(npc, pc, rep, _content);
pc.Hybrid!.NpcsWhoKnow.Add(npc.Id);
int after = EffectiveDisposition.For(npc, pc, rep, _content);
// HYBRID_SURVIVOR has positive hybrid_bias — disposition rises.
Assert.True(after > before,
$"expected disposition to rise for hybrid-friendly profile: before={before}, after={after}");
}
// ── Save round-trip mask tier ────────────────────────────────────────
[Fact]
public void Hybrid_MaskTier_RoundTripsThroughSave()
{
var pc = MakeHybrid(passing: true);
pc.Hybrid!.ActiveMaskTier = ScentMaskTier.DeepCover;
var snap = Theriapolis.Core.Persistence.CharacterCodec.Capture(pc);
Assert.NotNull(snap.Hybrid);
Assert.Equal((byte)ScentMaskTier.DeepCover, snap.Hybrid!.ActiveMaskTier);
var restored = Theriapolis.Core.Persistence.CharacterCodec.Restore(snap, _content);
Assert.Equal(ScentMaskTier.DeepCover, restored.Hybrid!.ActiveMaskTier);
}
[Fact]
public void Hybrid_MaskTier_RoundTripsThroughBinarySaveCodec()
{
var pc = MakeHybrid(passing: true);
pc.Hybrid!.ActiveMaskTier = ScentMaskTier.Military;
var header = new Theriapolis.Core.Persistence.SaveHeader
{
Version = Theriapolis.Core.C.SAVE_SCHEMA_VERSION,
WorldSeedHex = "0xFEED",
};
var body = new Theriapolis.Core.Persistence.SaveBody
{
PlayerCharacter = Theriapolis.Core.Persistence.CharacterCodec.Capture(pc),
};
body.Player.Id = 1;
body.Player.Name = "Hybrid";
var bytes = Theriapolis.Core.Persistence.SaveCodec.Serialize(header, body);
var (_, body2) = Theriapolis.Core.Persistence.SaveCodec.Deserialize(bytes);
Assert.NotNull(body2.PlayerCharacter?.Hybrid);
Assert.Equal((byte)ScentMaskTier.Military, body2.PlayerCharacter!.Hybrid!.ActiveMaskTier);
}
// ── Helpers ───────────────────────────────────────────────────────────
private Theriapolis.Core.Rules.Character.Character MakePurebred()
{
var b = new CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
};
AutoPickSkills(b);
return b.Build(_content.Items);
}
private Theriapolis.Core.Rules.Character.Character MakeHybrid(bool passing)
{
var b = new CharacterBuilder
{
ClassDef = _content.Classes["fangsworn"],
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 12),
IsHybridOrigin = true,
HybridSireClade = _content.Clades["canidae"],
HybridSireSpecies = _content.Species["wolf"],
HybridDamClade = _content.Clades["leporidae"],
HybridDamSpecies = _content.Species["rabbit"],
HybridDominantParent = ParentLineage.Sire,
};
AutoPickSkills(b);
bool ok = b.TryBuildHybrid(_content.Items, out var c, out string err);
Assert.True(ok, err);
c!.Hybrid!.PassingActive = passing;
return c;
}
private void AutoPickSkills(CharacterBuilder b)
{
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 { }
}
}
private NpcActor MakeCanidNpc()
{
var resident = new ResidentTemplateDef
{
Id = "test_canid",
Name = "Test Canid",
Clade = "canidae",
Species = "wolf",
};
return new NpcActor(resident) { Id = 42 };
}
private NpcActor MakeCanidNpcWithRole(string roleTag)
{
var resident = new ResidentTemplateDef
{
Id = "test_canid",
Name = "Test Canid",
Clade = "canidae",
Species = "wolf",
RoleTag = roleTag,
};
return new NpcActor(resident) { Id = 42 };
}
private NpcActor MakeNonCanidNpc()
{
var resident = new ResidentTemplateDef
{
Id = "test_cervid",
Name = "Test Cervid",
Clade = "cervidae",
Species = "deer",
};
return new NpcActor(resident) { Id = 99 };
}
private NpcActor MakeNpcWithBiasProfile(string biasProfileId)
{
var resident = new ResidentTemplateDef
{
Id = "test_npc",
Name = "Test NPC",
Clade = "cervidae",
Species = "deer",
BiasProfile = biasProfileId,
};
return new NpcActor(resident) { Id = 1 };
}
}
@@ -0,0 +1,36 @@
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Rules;
public sealed class ProficiencyBonusTests
{
[Theory]
[InlineData(1, 2)]
[InlineData(2, 2)]
[InlineData(3, 2)]
[InlineData(4, 2)]
[InlineData(5, 3)]
[InlineData(6, 3)]
[InlineData(8, 3)]
[InlineData(9, 4)]
[InlineData(12, 4)]
[InlineData(13, 5)]
[InlineData(16, 5)]
[InlineData(17, 6)]
[InlineData(20, 6)]
public void ForLevel_MatchesD20Table(int level, int expected)
{
Assert.Equal(expected, ProficiencyBonus.ForLevel(level));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(21)]
[InlineData(100)]
public void ForLevel_OutOfRange_Throws(int level)
{
Assert.Throws<ArgumentOutOfRangeException>(() => ProficiencyBonus.ForLevel(level));
}
}
+50
View File
@@ -0,0 +1,50 @@
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Rules;
public sealed class SizeTests
{
[Theory]
[InlineData(SizeCategory.Small, 1)]
[InlineData(SizeCategory.Medium, 1)]
[InlineData(SizeCategory.MediumLarge, 1)]
[InlineData(SizeCategory.Large, 2)]
public void FootprintTiles_MatchesPlanTable(SizeCategory s, int expected)
{
Assert.Equal(expected, s.FootprintTiles());
}
[Theory]
[InlineData(SizeCategory.Small, 1)]
[InlineData(SizeCategory.Medium, 1)]
[InlineData(SizeCategory.MediumLarge, 1)]
[InlineData(SizeCategory.Large, 2)]
public void DefaultReachTiles_MatchesPlanTable(SizeCategory s, int expected)
{
Assert.Equal(expected, s.DefaultReachTiles());
}
[Theory]
[InlineData("small", SizeCategory.Small)]
[InlineData("medium", SizeCategory.Medium)]
[InlineData("medium_large", SizeCategory.MediumLarge)]
[InlineData("large", SizeCategory.Large)]
public void FromJson_ParsesSnakeCase(string raw, SizeCategory expected)
{
Assert.Equal(expected, SizeExtensions.FromJson(raw));
}
[Fact]
public void FromJson_UnknownThrows()
{
Assert.Throws<ArgumentException>(() => SizeExtensions.FromJson("gargantuan"));
}
[Fact]
public void CarryCapacityMult_LargeIsDoubled()
{
Assert.Equal(2.0f, SizeCategory.Large.CarryCapacityMult());
Assert.Equal(0.5f, SizeCategory.Small.CarryCapacityMult());
}
}
+122
View File
@@ -0,0 +1,122 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Rules;
/// <summary>
/// Verifies that every class's <c>starting_kit</c> in classes.json
/// references real items, that auto-equipped items land in their declared
/// slot, and that fresh characters arrive armed and armoured.
/// </summary>
public sealed class StartingKitTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void EveryClass_HasNonEmptyStartingKit()
{
foreach (var c in _content.Classes.Values)
Assert.NotEmpty(c.StartingKit);
}
[Fact]
public void EveryStartingKitItem_ReferencesARealItem()
{
foreach (var c in _content.Classes.Values)
foreach (var entry in c.StartingKit)
Assert.True(
_content.Items.ContainsKey(entry.ItemId),
$"Class '{c.Id}' starting_kit references unknown item '{entry.ItemId}'");
}
[Fact]
public void EveryAutoEquipEntry_HasValidEquipSlot()
{
foreach (var c in _content.Classes.Values)
foreach (var entry in c.StartingKit)
{
if (!entry.AutoEquip) continue;
Assert.False(
string.IsNullOrEmpty(entry.EquipSlot),
$"Class '{c.Id}' auto-equip entry '{entry.ItemId}' has empty equip_slot");
Assert.NotNull(EquipSlotExtensions.FromJson(entry.EquipSlot));
}
}
[Theory]
[InlineData("fangsworn", "rend_sword", "chain_shirt", "buckler")]
[InlineData("bulwark", "hoof_club", "chain_mail", "standard_shield")]
[InlineData("covenant_keeper", "rend_sword", "chain_shirt", "standard_shield")]
[InlineData("claw_wright", "hoof_club", "studded_leather", "buckler")]
public void StartingKit_AppliedAndEquipped_FullKit(
string classId, string mainHand, string body, string offHand)
{
var c = BuildWithKit(classId);
AssertEquipped(c, EquipSlot.MainHand, mainHand);
AssertEquipped(c, EquipSlot.Body, body);
AssertEquipped(c, EquipSlot.OffHand, offHand);
}
[Theory]
[InlineData("feral", "paw_axe", "hide_vest")]
[InlineData("shadow_pelt", "thorn_blade", "studded_leather")]
[InlineData("scent_broker", "fang_knife", "leather_harness")]
[InlineData("muzzle_speaker", "fang_knife", "studded_leather")]
public void StartingKit_AppliedAndEquipped_NoShield(string classId, string mainHand, string body)
{
var c = BuildWithKit(classId);
AssertEquipped(c, EquipSlot.MainHand, mainHand);
AssertEquipped(c, EquipSlot.Body, body);
Assert.Null(c.Inventory.GetEquipped(EquipSlot.OffHand));
}
[Fact]
public void StartingKit_Skipped_WhenItemsTableNotPassed()
{
var c = MakeBuilder("fangsworn").Build(); // no items dict → no kit applied
Assert.Empty(c.Inventory.Items);
}
[Fact]
public void StartingKit_ProducesPositiveAcOverUnarmoredBaseline()
{
var c = BuildWithKit("fangsworn");
int armored = DerivedStats.ArmorClass(c);
Assert.True(armored >= 14, $"Fangsworn starting kit should produce AC ≥ 14 (chain shirt + buckler), got {armored}");
}
private Character BuildWithKit(string classId)
=> MakeBuilder(classId).Build(_content.Items);
private CharacterBuilder MakeBuilder(string classId)
{
var classDef = _content.Classes[classId];
var b = new CharacterBuilder
{
Clade = _content.Clades["canidae"],
Species = _content.Species["wolf"],
ClassDef = classDef,
Background = _content.Backgrounds["pack_raised"],
BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8),
Name = "KitTest",
};
// Pick the right number of skills for this class.
int n = classDef.SkillsChoose;
foreach (var raw in classDef.SkillOptions)
{
if (b.ChosenClassSkills.Count >= n) break;
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
}
return b;
}
private static void AssertEquipped(Character c, EquipSlot slot, string expectedItemId)
{
var inst = c.Inventory.GetEquipped(slot);
Assert.NotNull(inst);
Assert.Equal(expectedItemId, inst!.Def.Id);
}
}
@@ -0,0 +1,116 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Xunit;
namespace Theriapolis.Tests.Rules;
/// <summary>
/// Phase 6.5 M2 — SubclassResolver covers the pure look-up surface
/// (subclass id → unlocked feature ids per level, feature def lookup).
/// All subclass mechanics are JSON-driven; tests run against the live
/// content set so authoring drift is caught here.
/// </summary>
public sealed class SubclassResolverTests
{
private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void TryFindSubclass_ReturnsDefForKnownId()
{
var def = SubclassResolver.TryFindSubclass(_content.Subclasses, "pack_forged");
Assert.NotNull(def);
Assert.Equal("fangsworn", def!.ClassId);
}
[Fact]
public void TryFindSubclass_NullForEmptyId()
{
Assert.Null(SubclassResolver.TryFindSubclass(_content.Subclasses, ""));
Assert.Null(SubclassResolver.TryFindSubclass(_content.Subclasses, null));
}
[Fact]
public void TryFindSubclass_NullForUnknownId()
{
Assert.Null(SubclassResolver.TryFindSubclass(_content.Subclasses, "definitely_not_a_subclass"));
}
[Fact]
public void UnlockedFeaturesAt_Level3_ReturnsFirstFeature()
{
var features = SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, "pack_forged", 3);
Assert.NotEmpty(features);
Assert.Contains("packmates_howl", features);
}
[Fact]
public void UnlockedFeaturesAt_LevelWithoutEntry_ReturnsEmpty()
{
// Pack-Forged has entries at L3, L7, L10, L15, L18 — L4 is empty.
var features = SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, "pack_forged", 4);
Assert.Empty(features);
}
[Fact]
public void UnlockedFeaturesAt_NullSubclassId_ReturnsEmpty()
{
Assert.Empty(SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, null, 3));
Assert.Empty(SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, "", 3));
}
[Fact]
public void ResolveFeatureDef_FindsSubclassFeature()
{
var subclass = _content.Subclasses["pack_forged"];
var classDef = _content.Classes["fangsworn"];
var fdef = SubclassResolver.ResolveFeatureDef(classDef, subclass, "packmates_howl");
Assert.NotNull(fdef);
Assert.Equal("Packmate's Howl", fdef!.Name);
}
[Fact]
public void ResolveFeatureDef_FallsThroughToClassDefForSharedIds()
{
var subclass = _content.Subclasses["pack_forged"];
var classDef = _content.Classes["fangsworn"];
// 'asi' is in the class feature_definitions (shared across subclasses).
var fdef = SubclassResolver.ResolveFeatureDef(classDef, subclass, "asi");
Assert.NotNull(fdef);
}
[Fact]
public void ResolveFeatureDef_NullForUnknownId()
{
var subclass = _content.Subclasses["pack_forged"];
var classDef = _content.Classes["fangsworn"];
Assert.Null(SubclassResolver.ResolveFeatureDef(classDef, subclass, "completely_made_up_feature"));
}
[Fact]
public void EveryClass_HasAtLeastOneSubclass()
{
// Smoke: every class declared in classes.json should resolve to at
// least one entry in subclasses.json.
foreach (var cls in _content.Classes.Values)
{
Assert.NotEmpty(cls.SubclassIds);
foreach (var sid in cls.SubclassIds)
{
Assert.True(_content.Subclasses.ContainsKey(sid),
$"class '{cls.Id}' references unknown subclass '{sid}'");
}
}
}
[Fact]
public void EverySubclass_HasLevel3Features()
{
// M2 ship-point: every authored subclass should have at least one
// level-3 feature so the L3 unlock fires meaningfully.
foreach (var sub in _content.Subclasses.Values)
{
var l3 = SubclassResolver.UnlockedFeaturesAt(_content.Subclasses, sub.Id, 3);
Assert.NotEmpty(l3);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using Theriapolis.Core.Rules.Stats;
using Xunit;
namespace Theriapolis.Tests.Rules;
public sealed class XpTableTests
{
[Theory]
[InlineData(0, 1)]
[InlineData(1, 1)]
[InlineData(299, 1)]
[InlineData(300, 2)]
[InlineData(899, 2)]
[InlineData(900, 3)]
[InlineData(2_700, 4)]
[InlineData(355_000, 20)]
[InlineData(1_000_000,20)]
public void LevelForXp_MatchesD20Table(int xp, int expectedLevel)
{
Assert.Equal(expectedLevel, XpTable.LevelForXp(xp));
}
[Fact]
public void LevelForXp_NegativeThrows()
{
Assert.Throws<ArgumentOutOfRangeException>(() => XpTable.LevelForXp(-1));
}
[Theory]
[InlineData(1, 300)]
[InlineData(2, 900)]
[InlineData(19, 355_000)]
public void XpRequiredForNextLevel_MatchesTable(int currentLevel, int expectedNext)
{
Assert.Equal(expectedNext, XpTable.XpRequiredForNextLevel(currentLevel));
}
[Fact]
public void XpRequiredForNextLevel_AtCap_ReturnsMaxValue()
{
Assert.Equal(int.MaxValue, XpTable.XpRequiredForNextLevel(20));
}
[Fact]
public void Threshold_IsMonotonicallyIncreasing()
{
for (int lv = 2; lv <= 20; lv++)
Assert.True(XpTable.Threshold[lv] > XpTable.Threshold[lv - 1],
$"Threshold[{lv}]={XpTable.Threshold[lv]} should be > Threshold[{lv-1}]={XpTable.Threshold[lv-1]}");
}
}
@@ -0,0 +1,75 @@
using Theriapolis.Core.World;
using Theriapolis.Core.World.Settlements;
using Xunit;
namespace Theriapolis.Tests.Settlements;
/// <summary>
/// Phase 6 M1 — AnchorRegistry semantics.
/// </summary>
public sealed class AnchorRegistryTests
{
[Fact]
public void Anchor_RegistersAndResolves()
{
var r = new AnchorRegistry();
r.RegisterAnchor(NarrativeAnchor.Millhaven, settlementId: 42);
Assert.Equal(42, r.ResolveAnchor("anchor:millhaven"));
}
[Fact]
public void Anchor_LookupIsCaseInsensitive()
{
var r = new AnchorRegistry();
r.RegisterAnchor(NarrativeAnchor.Millhaven, settlementId: 42);
Assert.Equal(42, r.ResolveAnchor("ANCHOR:MILLHAVEN"));
Assert.Equal(42, r.ResolveAnchor("anchor:Millhaven"));
}
[Fact]
public void UnregisteredAnchor_ReturnsNull()
{
var r = new AnchorRegistry();
Assert.Null(r.ResolveAnchor("anchor:doesnotexist"));
}
[Fact]
public void NamedRole_RegistersAndResolves()
{
var r = new AnchorRegistry();
r.RegisterRole("millhaven.innkeeper", npcId: 777);
Assert.Equal(777, r.ResolveRole("role:millhaven.innkeeper"));
}
[Fact]
public void GenericRoleTag_DoesNotRegister()
{
// A bare role tag without a "settlement.role" qualifier shouldn't
// be globally addressable — there are many generic innkeepers.
var r = new AnchorRegistry();
r.RegisterRole("innkeeper", npcId: 5);
Assert.Null(r.ResolveRole("role:innkeeper"));
}
[Fact]
public void UnregisterRole_RemovesEntry()
{
var r = new AnchorRegistry();
r.RegisterRole("millhaven.innkeeper", npcId: 5);
r.UnregisterRole("millhaven.innkeeper");
Assert.Null(r.ResolveRole("role:millhaven.innkeeper"));
}
[Fact]
public void Clear_DropsAllEntries()
{
var r = new AnchorRegistry();
r.RegisterAnchor(NarrativeAnchor.Millhaven, 1);
r.RegisterRole("millhaven.innkeeper", 5);
r.Clear();
Assert.Null(r.ResolveAnchor("anchor:millhaven"));
Assert.Null(r.ResolveRole("role:millhaven.innkeeper"));
Assert.Empty(r.AllAnchors);
Assert.Empty(r.AllRoles);
}
}
@@ -0,0 +1,232 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Settlements;
using Xunit;
namespace Theriapolis.Tests.Settlements;
/// <summary>
/// Phase 6 M0 — building stamp determinism + content shape tests.
///
/// The settlement-stamping path picks up content lazily on the first chunk
/// that touches each settlement; identical seeds must produce identical
/// building lists, and the stamped tile bytes must round-trip across two
/// independent generations.
/// </summary>
public sealed class BuildingStampTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
private readonly WorldCache _cache;
public BuildingStampTests(WorldCache c) => _cache = c;
private SettlementContent LoadContent()
=> new ContentResolver(new ContentLoader(TestHelpers.DataDirectory)).Settlements;
[Fact]
public void ContentLoader_LoadsBuildingsAndLayouts()
{
var content = LoadContent();
Assert.True(content.Buildings.Count >= 6,
$"expected ≥ 6 building templates, got {content.Buildings.Count}");
Assert.True(content.PresetByAnchor.Count >= 1,
"expected at least one preset settlement layout");
Assert.True(content.ProceduralByTier.Count >= 4,
"expected procedural layouts for Tier 2/3/4/5");
// Sanity: every preset must reference real building templates.
foreach (var p in content.PresetByAnchor.Values)
foreach (var b in p.Buildings)
Assert.True(content.Buildings.ContainsKey(b.Template),
$"preset '{p.Id}' references unknown template '{b.Template}'");
}
[Fact]
public void Stamp_ProducesIdenticalBuildingsAcrossRuns()
{
var w1 = _cache.Get(TestSeed, variant: 0).World;
var w2 = _cache.Get(TestSeed, variant: 1).World;
var content = LoadContent();
var s1 = w1.Settlements.First(s => !s.IsPoi && s.Tier <= 3);
var s2 = w2.Settlements.First(s => s.Id == s1.Id);
SettlementStamper.EnsureBuildingsResolved(TestSeed, s1, content);
SettlementStamper.EnsureBuildingsResolved(TestSeed, s2, content);
Assert.Equal(s1.Buildings.Count, s2.Buildings.Count);
for (int i = 0; i < s1.Buildings.Count; i++)
{
Assert.Equal(s1.Buildings[i].TemplateId, s2.Buildings[i].TemplateId);
Assert.Equal(s1.Buildings[i].MinX, s2.Buildings[i].MinX);
Assert.Equal(s1.Buildings[i].MinY, s2.Buildings[i].MinY);
Assert.Equal(s1.Buildings[i].MaxX, s2.Buildings[i].MaxX);
Assert.Equal(s1.Buildings[i].MaxY, s2.Buildings[i].MaxY);
}
}
[Fact]
public void Stamp_ProducesIdenticalChunkHashWithSameContent()
{
var w = _cache.Get(TestSeed).World;
var content = LoadContent();
var s = w.Settlements.First(s => !s.IsPoi && s.Tier <= 3);
var cc = ChunkCoord.ForWorldTile(s.TileX, s.TileY);
var a = TacticalChunkGen.Generate(TestSeed, cc, w, content);
// Re-resolve buildings from a fresh world to make sure the stamper
// is idempotent (BuildingsResolved guard works).
var w2 = _cache.Get(TestSeed, variant: 1).World;
var b = TacticalChunkGen.Generate(TestSeed, cc, w2, content);
Assert.Equal(a.Hash(), b.Hash());
}
[Fact]
public void Stamp_ContentNullFallsBackToLegacyHash()
{
// Generating a chunk without content should match a chunk generated
// with the no-content overload — i.e., the fallback path is the
// Phase-4 placeholder behaviour, byte-for-byte.
var w = _cache.Get(TestSeed).World;
var s = w.Settlements.First(s => !s.IsPoi && s.Tier <= 3);
var cc = ChunkCoord.ForWorldTile(s.TileX, s.TileY);
var a = TacticalChunkGen.Generate(TestSeed, cc, w);
var b = TacticalChunkGen.Generate(TestSeed, cc, w, settlementContent: null);
Assert.Equal(a.Hash(), b.Hash());
}
[Fact]
public void StampedSettlement_HasMoreBuildingTilesThanFallback()
{
// The whole point of M0 — content path stamps Floor tiles inside
// building footprints, fallback only stamps Cobble. Floor count
// diverges; this captures that.
var w = _cache.Get(TestSeed).World;
var content = LoadContent();
var s = w.Settlements.First(s => !s.IsPoi && s.Tier <= 3);
var cc = ChunkCoord.ForWorldTile(s.TileX, s.TileY);
var withContent = TacticalChunkGen.Generate(TestSeed, cc, w, content);
var w2 = _cache.Get(TestSeed, variant: 1).World;
var without = TacticalChunkGen.Generate(TestSeed, cc, w2, settlementContent: null);
int floorWith = CountSurface(withContent, TacticalSurface.Floor);
int floorWithout = CountSurface(without, TacticalSurface.Floor);
Assert.True(floorWith > floorWithout,
$"Content-aware stamp should produce floor tiles; got {floorWith} vs {floorWithout}.");
}
[Fact]
public void Buildings_HaveDoorsAndDoorsAreWalkable()
{
var w = _cache.Get(TestSeed).World;
var content = LoadContent();
var s = w.Settlements.First(x => !x.IsPoi && x.Tier <= 3);
SettlementStamper.EnsureBuildingsResolved(TestSeed, s, content);
Assert.NotEmpty(s.Buildings);
// Render a chunk that overlaps each building and confirm the door
// tile is walkable + carries the Doorway flag.
foreach (var b in s.Buildings)
{
Assert.NotEmpty(b.Doors);
foreach (var (dx, dy) in b.Doors)
{
var cc = new ChunkCoord(
dx / C.TACTICAL_CHUNK_SIZE - (dx < 0 ? 1 : 0),
dy / C.TACTICAL_CHUNK_SIZE - (dy < 0 ? 1 : 0));
var chunk = TacticalChunkGen.Generate(TestSeed, cc, w, content);
int lx = dx - chunk.OriginX;
int ly = dy - chunk.OriginY;
Assert.InRange(lx, 0, C.TACTICAL_CHUNK_SIZE - 1);
Assert.InRange(ly, 0, C.TACTICAL_CHUNK_SIZE - 1);
ref var tile = ref chunk.Tiles[lx, ly];
Assert.True(tile.IsWalkable, $"door at ({dx},{dy}) for building {b.TemplateId} should be walkable");
Assert.True((tile.Flags & (byte)TacticalFlags.Doorway) != 0, "door tile must carry the Doorway flag");
Assert.True((tile.Flags & (byte)TacticalFlags.Building) != 0, "door tile must carry the Building flag");
}
}
}
[Fact]
public void MillhavenAnchor_GetsItsPresetLayout()
{
// We can't guarantee the Millhaven anchor exists at every test seed
// (placement depends on world geometry). When it does, it should
// resolve to the preset layout, not the procedural Tier-1 fallback.
var w = _cache.Get(TestSeed).World;
var content = LoadContent();
var millhaven = w.Settlements.FirstOrDefault(
s => s.Anchor is NarrativeAnchor.Millhaven);
if (millhaven is null) return;
var layout = content.ResolveFor(millhaven);
Assert.NotNull(layout);
Assert.Equal("preset", layout!.Kind);
Assert.Equal("Millhaven", layout.Anchor);
}
[Fact]
public void ProceduralLayouts_StampMultipleBuildings()
{
// Pick a non-anchor Tier 2 or 3 settlement and confirm the
// procedural roller produced more than one building. Sanity that
// the weighted picker doesn't collapse to zero.
var w = _cache.Get(TestSeed).World;
var content = LoadContent();
var s = w.Settlements.FirstOrDefault(
x => x.Anchor is null && !x.IsPoi && x.Tier is 2 or 3);
if (s is null) return;
SettlementStamper.EnsureBuildingsResolved(TestSeed, s, content);
Assert.True(s.Buildings.Count >= 2,
$"procedural Tier-{s.Tier} settlement should stamp ≥ 2 buildings, got {s.Buildings.Count}");
}
[Fact]
public void ResidentSpawns_AppearInChunkSpawnList()
{
var w = _cache.Get(TestSeed).World;
var content = LoadContent();
// Find any settlement at Tier ≤ 3 whose layout has roles.
var s = w.Settlements.First(x => !x.IsPoi && x.Tier <= 3);
SettlementStamper.EnsureBuildingsResolved(TestSeed, s, content);
// Sum role count across buildings.
int expectedRoles = s.Buildings.Sum(b => b.Residents.Length);
if (expectedRoles == 0) return;
// Generate every chunk overlapping any building and count Resident
// spawn records emitted.
int actual = 0;
var seen = new HashSet<ChunkCoord>();
foreach (var b in s.Buildings)
{
int minCx = (int)Math.Floor(b.MinX / (double)C.TACTICAL_CHUNK_SIZE);
int minCy = (int)Math.Floor(b.MinY / (double)C.TACTICAL_CHUNK_SIZE);
int maxCx = (int)Math.Floor(b.MaxX / (double)C.TACTICAL_CHUNK_SIZE);
int maxCy = (int)Math.Floor(b.MaxY / (double)C.TACTICAL_CHUNK_SIZE);
for (int cy = minCy; cy <= maxCy; cy++)
for (int cx = minCx; cx <= maxCx; cx++)
{
var cc = new ChunkCoord(cx, cy);
if (!seen.Add(cc)) continue;
var chunk = TacticalChunkGen.Generate(TestSeed, cc, w, content);
foreach (var sp in chunk.Spawns)
if (sp.Kind == SpawnKind.Resident) actual++;
}
}
Assert.Equal(expectedRoles, actual);
}
private static int CountSurface(TacticalChunk chunk, TacticalSurface surface)
{
int n = 0;
for (int y = 0; y < C.TACTICAL_CHUNK_SIZE; y++)
for (int x = 0; x < C.TACTICAL_CHUNK_SIZE; x++)
if (chunk.Tiles[x, y].Surface == surface) n++;
return n;
}
}
@@ -0,0 +1,216 @@
using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Settlements;
using Xunit;
namespace Theriapolis.Tests.Settlements;
/// <summary>
/// Phase 6 M1 — resident-instantiation correctness.
///
/// Walks the full pipeline: chunk → SettlementStamper emits Resident spawn
/// records → ResidentInstantiator resolves them → NpcActor lands inside
/// the building with the right name, bias profile, and dialogue id.
/// </summary>
public sealed class ResidentSpawnTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
private readonly WorldCache _cache;
public ResidentSpawnTests(WorldCache c) => _cache = c;
private ContentResolver Content() => new(new ContentLoader(TestHelpers.DataDirectory));
[Fact]
public void NamedRoleTags_ResolveToHandAuthoredTemplates()
{
var content = Content();
// Direct lookup — these IDs are referenced by Millhaven's preset.
Assert.True(content.ResidentsByRoleTag.ContainsKey("millhaven.innkeeper"));
Assert.True(content.ResidentsByRoleTag.ContainsKey("millhaven.constable_fenn"));
Assert.True(content.ResidentsByRoleTag.ContainsKey("millhaven.grandmother_asha"));
Assert.True(content.ResidentsByRoleTag.ContainsKey("thornfield.dr_venn"));
var asha = content.ResidentsByRoleTag["millhaven.grandmother_asha"];
Assert.Equal("Grandmother Asha", asha.Name);
Assert.Equal("canidae", asha.Clade);
Assert.Equal("wolf", asha.Species);
Assert.Equal("CANID_TRADITIONALIST", asha.BiasProfile);
}
[Fact]
public void GenericRoleTags_FallBackToGenericTemplates()
{
var content = Content();
// Suffix-stripping: "anywhere.innkeeper" should resolve to the
// generic_innkeeper template since no anywhere.* preset exists.
var pick = ResidentInstantiator.ResolveTemplate(
"anywhere.innkeeper", content,
worldSeed: 1, settlementId: 1, buildingId: 0, spawnIndex: 0);
Assert.NotNull(pick);
Assert.False(pick!.Named);
Assert.Equal("innkeeper", pick.RoleTag);
}
[Fact]
public void ResidentInstantiator_PlacesNpcInsideBuilding()
{
var w = _cache.Get(TestSeed).World;
var content = Content();
// Find a settlement that resolves to a layout (Millhaven if anchor
// matches, else any Tier 2-3 procedural settlement).
var settlement = w.Settlements.FirstOrDefault(s => s.Anchor is NarrativeAnchor.Millhaven)
?? w.Settlements.First(s => !s.IsPoi && s.Tier <= 3);
SettlementStamper.EnsureBuildingsResolved(TestSeed, settlement, content.Settlements);
Assert.NotEmpty(settlement.Buildings);
// Pick the first building that has at least one resident slot.
var building = settlement.Buildings.FirstOrDefault(b => b.Residents.Length > 0);
Assert.NotNull(building);
var slot = building!.Residents[0];
// Spawn it through the full path (chunk render → ResidentInstantiator).
var actors = new ActorManager();
var registry = new AnchorRegistry();
registry.RegisterAllAnchors(w);
var cc = new ChunkCoord(
slot.SpawnX / C.TACTICAL_CHUNK_SIZE - (slot.SpawnX < 0 ? 1 : 0),
slot.SpawnY / C.TACTICAL_CHUNK_SIZE - (slot.SpawnY < 0 ? 1 : 0));
var chunk = TacticalChunkGen.Generate(TestSeed, cc, w, content.Settlements);
// Find the spawn entry for this slot inside the chunk.
int spawnIdx = -1;
for (int i = 0; i < chunk.Spawns.Count; i++)
{
var s = chunk.Spawns[i];
int wx = chunk.OriginX + s.LocalX;
int wy = chunk.OriginY + s.LocalY;
if (s.Kind == SpawnKind.Resident && wx == slot.SpawnX && wy == slot.SpawnY)
{
spawnIdx = i;
break;
}
}
Assert.NotEqual(-1, spawnIdx);
var npc = ResidentInstantiator.Spawn(
TestSeed, chunk, spawnIdx, chunk.Spawns[spawnIdx],
w, content, actors, registry);
Assert.NotNull(npc);
Assert.Equal(slot.SpawnX, (int)npc!.Position.X);
Assert.Equal(slot.SpawnY, (int)npc.Position.Y);
Assert.Equal(slot.RoleTag, npc.RoleTag);
Assert.NotEmpty(npc.DisplayName);
Assert.NotEmpty(npc.BiasProfileId);
Assert.True(npc.IsAlive);
}
[Fact]
public void NamedResident_RegistersInAnchorRegistry()
{
var w = _cache.Get(TestSeed).World;
var content = Content();
var millhaven = w.Settlements.FirstOrDefault(s => s.Anchor is NarrativeAnchor.Millhaven);
if (millhaven is null) return; // anchor placement varies — skip if absent
SettlementStamper.EnsureBuildingsResolved(TestSeed, millhaven, content.Settlements);
var actors = new ActorManager();
var registry = new AnchorRegistry();
registry.RegisterAllAnchors(w);
// Stream every chunk overlapping each Millhaven building.
foreach (var b in millhaven.Buildings)
{
int minCx = (int)Math.Floor(b.MinX / (double)C.TACTICAL_CHUNK_SIZE);
int minCy = (int)Math.Floor(b.MinY / (double)C.TACTICAL_CHUNK_SIZE);
int maxCx = (int)Math.Floor(b.MaxX / (double)C.TACTICAL_CHUNK_SIZE);
int maxCy = (int)Math.Floor(b.MaxY / (double)C.TACTICAL_CHUNK_SIZE);
for (int cy = minCy; cy <= maxCy; cy++)
for (int cx = minCx; cx <= maxCx; cx++)
{
var cc = new ChunkCoord(cx, cy);
var chunk = TacticalChunkGen.Generate(TestSeed, cc, w, content.Settlements);
for (int i = 0; i < chunk.Spawns.Count; i++)
{
var s = chunk.Spawns[i];
if (s.Kind != SpawnKind.Resident) continue;
if (actors.FindNpcBySource(cc, i) is not null) continue;
ResidentInstantiator.Spawn(TestSeed, chunk, i, s, w, content, actors, registry);
}
}
}
// Anchor entry exists.
Assert.NotNull(registry.ResolveAnchor("anchor:millhaven"));
// The named innkeeper role must be registered.
var innkeeperId = registry.ResolveRole("role:millhaven.innkeeper");
Assert.NotNull(innkeeperId);
var innkeeper = actors.Npcs.First(n => n.Id == innkeeperId.Value);
Assert.Equal("Mara Threadwell", innkeeper.DisplayName);
Assert.Equal("URBAN_PROGRESSIVE", innkeeper.BiasProfileId);
}
[Fact]
public void GenericResidents_DoNotPolluteAnchorRegistry()
{
// Procedural Tier 2/3 settlements use generic role tags ("innkeeper")
// — those should NOT register as roles (only anchor.role pairs do).
var w = _cache.Get(TestSeed).World;
var content = Content();
var settlement = w.Settlements.FirstOrDefault(
s => s.Anchor is null && !s.IsPoi && s.Tier is 2 or 3);
if (settlement is null) return;
SettlementStamper.EnsureBuildingsResolved(TestSeed, settlement, content.Settlements);
var actors = new ActorManager();
var registry = new AnchorRegistry();
foreach (var b in settlement.Buildings)
foreach (var r in b.Residents)
{
int cx = (int)Math.Floor(r.SpawnX / (double)C.TACTICAL_CHUNK_SIZE);
int cy = (int)Math.Floor(r.SpawnY / (double)C.TACTICAL_CHUNK_SIZE);
var cc = new ChunkCoord(cx, cy);
var chunk = TacticalChunkGen.Generate(TestSeed, cc, w, content.Settlements);
for (int i = 0; i < chunk.Spawns.Count; i++)
{
var s = chunk.Spawns[i];
if (s.Kind != SpawnKind.Resident) continue;
int wx = chunk.OriginX + s.LocalX;
int wy = chunk.OriginY + s.LocalY;
if (wx != r.SpawnX || wy != r.SpawnY) continue;
if (actors.FindNpcBySource(cc, i) is not null) continue;
ResidentInstantiator.Spawn(TestSeed, chunk, i, s, w, content, actors, registry);
}
}
// No role:* entries should exist for a generic-only settlement.
foreach (var (id, _) in registry.AllRoles)
Assert.DoesNotContain(".", id[..(id.IndexOf(':'))]); // sanity: prefix is "role:"
Assert.True(registry.AllRoles.Count == 0,
$"generic settlement should produce no named role registrations, got {registry.AllRoles.Count}");
}
[Fact]
public void ResidentInstantiator_IsDeterministic()
{
var content = Content();
// Generic role with multiple matching templates picks the same one
// for the same seed/chunk/slot every time.
var first = ResidentInstantiator.ResolveTemplate("village.shopkeeper", content,
worldSeed: 0xCAFEBABEUL, settlementId: 5, buildingId: 2, spawnIndex: 0);
var second = ResidentInstantiator.ResolveTemplate("village.shopkeeper", content,
worldSeed: 0xCAFEBABEUL, settlementId: 5, buildingId: 2, spawnIndex: 0);
Assert.NotNull(first);
Assert.Equal(first!.Id, second!.Id);
}
}
@@ -0,0 +1,83 @@
using Theriapolis.Core;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Tactical;
/// <summary>
/// Streamer-level invariants: caching, eviction, and delta round-trip.
/// </summary>
public sealed class ChunkStreamerTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
private readonly WorldCache _cache;
public ChunkStreamerTests(WorldCache c) => _cache = c;
[Fact]
public void Get_CachesSubsequentCalls()
{
var w = _cache.Get(TestSeed).World;
var streamer = new ChunkStreamer(TestSeed, w, new InMemoryChunkDeltaStore());
var cc = new ChunkCoord(3, 3);
var first = streamer.Get(cc);
var second = streamer.Get(cc);
Assert.Same(first, second);
}
[Fact]
public void EnsureLoaded_PopulatesCacheNearPlayer()
{
var w = _cache.Get(TestSeed).World;
var streamer = new ChunkStreamer(TestSeed, w, new InMemoryChunkDeltaStore());
// Centre on world tile (50, 50) → tactical-pixel (1600, 1600).
var pos = new Vec2(50 * C.WORLD_TILE_PIXELS, 50 * C.WORLD_TILE_PIXELS);
streamer.EnsureLoadedAround(pos, worldTileRadius: C.TACTICAL_WINDOW_WORLD_TILES);
Assert.NotEmpty(streamer.Loaded);
}
[Fact]
public void DeltaRoundtrip_PreservesTileEdits()
{
var w = _cache.Get(TestSeed).World;
var deltas = new InMemoryChunkDeltaStore();
var streamer = new ChunkStreamer(TestSeed, w, deltas);
var cc = new ChunkCoord(8, 8);
var chunk = streamer.Get(cc);
// Pick a known tile, edit it, mark the chunk dirty.
ref var t = ref chunk.Tiles[10, 10];
var origSurface = t.Surface;
var newSurface = origSurface == TacticalSurface.Cobble ? TacticalSurface.Sand : TacticalSurface.Cobble;
t.Surface = newSurface;
t.Deco = TacticalDeco.None;
chunk.HasDelta = true;
// Force the streamer above the cache cap so the chunk gets evicted
// (and its delta flushed). Easiest way: load enough other chunks.
for (int i = 0; i < C.CHUNK_CACHE_SOFT_MAX + 2; i++)
streamer.Get(new ChunkCoord(100 + i, 100));
streamer.EnsureLoadedAround(new Vec2(100 * C.WORLD_TILE_PIXELS, 100 * C.WORLD_TILE_PIXELS),
worldTileRadius: 1);
// Now reload our edited chunk and verify the delta was reapplied.
var reloaded = streamer.Get(cc);
Assert.Equal(newSurface, reloaded.Tiles[10, 10].Surface);
}
[Fact]
public void FlushAll_PersistsModifiedChunks()
{
var w = _cache.Get(TestSeed).World;
var deltas = new InMemoryChunkDeltaStore();
var streamer = new ChunkStreamer(TestSeed, w, deltas);
var cc = new ChunkCoord(2, 2);
var chunk = streamer.Get(cc);
chunk.Tiles[5, 5].Deco = TacticalDeco.Boulder;
chunk.HasDelta = true;
streamer.FlushAll();
Assert.NotNull(deltas.Get(cc));
Assert.NotEmpty(deltas.Get(cc)!.TileMods);
}
}
@@ -0,0 +1,83 @@
using Theriapolis.Core;
using Theriapolis.Core.Tactical;
using Xunit;
namespace Theriapolis.Tests.Tactical;
/// <summary>
/// Phase 4 chunk-determinism contract:
/// • Same (worldSeed, ChunkCoord) twice → byte-identical chunk hash.
/// • Stream cycle: generate → evict → regenerate → identical hash.
/// • Different chunk coords → different hashes (no chunk-coord collision).
/// </summary>
public sealed class TacticalChunkDeterminismTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
private readonly WorldCache _cache;
public TacticalChunkDeterminismTests(WorldCache c) => _cache = c;
[Theory]
[InlineData(0, 0)]
[InlineData(5, 7)]
[InlineData(20, 30)]
[InlineData(60, 60)]
public void SameChunk_GeneratesIdenticalBytes(int cx, int cy)
{
var w = _cache.Get(TestSeed).World;
var a = TacticalChunkGen.Generate(TestSeed, new ChunkCoord(cx, cy), w);
var b = TacticalChunkGen.Generate(TestSeed, new ChunkCoord(cx, cy), w);
Assert.Equal(a.Hash(), b.Hash());
}
[Fact]
public void StreamCycle_RegenerateProducesSameHash()
{
// Use two independent worldgen runs to avoid sharing any cached state
// accidentally — each Generate call is supposed to be a pure function.
var wA = _cache.Get(TestSeed, variant: 0).World;
var wB = _cache.Get(TestSeed, variant: 1).World;
var cc = new ChunkCoord(15, 20);
var first = TacticalChunkGen.Generate(TestSeed, cc, wA);
var second = TacticalChunkGen.Generate(TestSeed, cc, wB);
Assert.Equal(first.Hash(), second.Hash());
}
[Fact]
public void DifferentCoords_DifferentHashes()
{
// Pick chunks that overlap a known settlement footprint so we
// guarantee non-trivial content rather than picking edges that may
// both be all-ocean (identical hashes are then a true positive,
// not a determinism bug).
var w = _cache.Get(TestSeed).World;
var s = w.Settlements.First(s => !s.IsPoi && s.Tier <= 3);
var anchor = ChunkCoord.ForWorldTile(s.TileX, s.TileY);
var a = TacticalChunkGen.Generate(TestSeed, anchor, w);
var b = TacticalChunkGen.Generate(TestSeed, new ChunkCoord(anchor.X + 4, anchor.Y), w);
var c = TacticalChunkGen.Generate(TestSeed, new ChunkCoord(anchor.X, anchor.Y + 4), w);
Assert.NotEqual(a.Hash(), b.Hash());
Assert.NotEqual(a.Hash(), c.Hash());
Assert.NotEqual(b.Hash(), c.Hash());
}
[Fact]
public void DifferentSeeds_DifferentHashes()
{
var wA = _cache.Get(TestSeed).World;
var wB = _cache.Get(TestSeed + 1).World;
var sA = wA.Settlements.First(s => !s.IsPoi && s.Tier <= 3);
var anchor = ChunkCoord.ForWorldTile(sA.TileX, sA.TileY);
var a = TacticalChunkGen.Generate(TestSeed, anchor, wA);
var b = TacticalChunkGen.Generate(TestSeed + 1, anchor, wB);
Assert.NotEqual(a.Hash(), b.Hash());
}
[Fact]
public void Chunk_HasExpectedDimensions()
{
var w = _cache.Get(TestSeed).World;
var chunk = TacticalChunkGen.Generate(TestSeed, new ChunkCoord(0, 0), w);
Assert.Equal(C.TACTICAL_CHUNK_SIZE, chunk.Tiles.GetLength(0));
Assert.Equal(C.TACTICAL_CHUNK_SIZE, chunk.Tiles.GetLength(1));
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace Theriapolis.Tests;
/// <summary>Shared test utilities.</summary>
internal static class TestHelpers
{
/// <summary>
/// Resolves the Content/Data directory for tests.
/// xUnit runs from the test output directory; the .csproj copies Data/* there.
/// </summary>
public static string DataDirectory
{
get
{
// First: "Data" next to the test assembly (copied by .csproj)
string local = Path.Combine(AppContext.BaseDirectory, "Data");
if (Directory.Exists(local)) return local;
// Fallback: walk up from the assembly to find Content/Data
string? dir = AppContext.BaseDirectory;
for (int i = 0; i < 7; i++)
{
string candidate = Path.Combine(dir ?? "", "Content", "Data");
if (Directory.Exists(candidate)) return candidate;
dir = Path.GetDirectoryName(dir);
}
throw new DirectoryNotFoundException(
"Cannot locate Content/Data directory. " +
$"Searched from: {AppContext.BaseDirectory}");
}
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Theriapolis.Tests</RootNamespace>
<LangVersion>12</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Theriapolis.Core\Theriapolis.Core.csproj" />
</ItemGroup>
<!-- Copy content data files so tests can load macro_template.json and biomes.json -->
<ItemGroup>
<Content Include="..\Content\Data\**\*"
Link="Data\%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+53
View File
@@ -0,0 +1,53 @@
using Theriapolis.Core.World.Generation;
namespace Theriapolis.Tests;
/// <summary>
/// Class-level fixture that memoizes worldgen pipeline runs so multiple tests
/// in the same class (all hitting e.g. seed 0xCAFEBABE) share one expensive run.
///
/// Each test class gets its own WorldCache via <see cref="Xunit.IClassFixture{T}"/>,
/// which keeps cross-class parallelism intact while collapsing within-class duplicate
/// runs. A full pipeline takes ~30s; a test class that previously did 9 runs now does 1.
///
/// Cache key is (seed, stageThroughIndex, variant):
/// - stageThroughIndex = -1 means RunAll; otherwise RunThrough(ctx, idx).
/// - variant is used by determinism tests that intentionally want TWO independent
/// runs of the same seed to compare: pass variant 0 and variant 1.
/// </summary>
public sealed class WorldCache : IDisposable
{
private readonly Dictionary<(ulong Seed, int Stage, int Variant), WorldGenContext> _cache = new();
private readonly object _lock = new();
/// <summary>Full pipeline run for <paramref name="seed"/>, memoized.</summary>
public WorldGenContext Get(ulong seed, int variant = 0) =>
GetInternal(seed, stageThroughIndex: -1, variant);
/// <summary>
/// Partial pipeline run through stage index (0-based), memoized.
/// E.g. <c>GetThrough(seed, 9)</c> runs stages 110 (HydrologyGen).
/// </summary>
public WorldGenContext GetThrough(ulong seed, int stageThroughIndex, int variant = 0) =>
GetInternal(seed, stageThroughIndex, variant);
private WorldGenContext GetInternal(ulong seed, int stageThroughIndex, int variant)
{
var key = (seed, stageThroughIndex, variant);
lock (_lock)
{
if (_cache.TryGetValue(key, out var cached)) return cached;
var ctx = new WorldGenContext(seed, TestHelpers.DataDirectory);
if (stageThroughIndex < 0)
WorldGenerator.RunAll(ctx);
else
WorldGenerator.RunThrough(ctx, stageThroughIndex);
_cache[key] = ctx;
return ctx;
}
}
public void Dispose() => _cache.Clear();
}
@@ -0,0 +1,75 @@
using Theriapolis.Core;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Xunit;
namespace Theriapolis.Tests.Worldgen;
/// <summary>
/// Biome coverage sanity checks:
/// - No seed produces a map that is &gt;80% one biome.
/// - Each required non-ocean biome appears in at least 1% of land tiles.
/// </summary>
public sealed class BiomeCoverageTests : IClassFixture<WorldCache>
{
private readonly WorldCache _cache;
public BiomeCoverageTests(WorldCache cache) => _cache = cache;
private Dictionary<BiomeId, int> CountBiomes(ulong seed)
{
var ctx = _cache.Get(seed);
var counts = new Dictionary<BiomeId, int>();
int W = C.WORLD_WIDTH_TILES, H = C.WORLD_HEIGHT_TILES;
for (int ty = 0; ty < H; ty++)
for (int tx = 0; tx < W; tx++)
{
var b = ctx.World.Tiles[tx, ty].Biome;
counts.TryGetValue(b, out int c);
counts[b] = c + 1;
}
return counts;
}
[Fact]
public void NoBiomeDominatesMoreThan80Percent()
{
var counts = CountBiomes(0xCAFEBABEUL);
int total = C.WORLD_WIDTH_TILES * C.WORLD_HEIGHT_TILES;
foreach (var (biome, count) in counts)
{
double pct = (double)count / total;
Assert.True(pct < 0.80,
$"Biome {biome} covers {pct:P1} of the map (limit 80%).");
}
}
[Fact]
public void RequiredBiomes_AppearInAtLeast1PercentOfLandTiles()
{
var counts = CountBiomes(0xCAFEBABEUL);
int total = C.WORLD_WIDTH_TILES * C.WORLD_HEIGHT_TILES;
int oceanCount = counts.GetValueOrDefault(BiomeId.Ocean);
int landTotal = total - oceanCount;
// These biomes must all be present and non-trivial
BiomeId[] required =
{
BiomeId.Tundra,
BiomeId.Boreal,
BiomeId.TemperateDeciduous,
BiomeId.TemperateGrassland,
BiomeId.MountainAlpine,
BiomeId.SubtropicalForest,
};
foreach (var biome in required)
{
int count = counts.GetValueOrDefault(biome);
double pct = landTotal > 0 ? (double)count / landTotal : 0;
Assert.True(pct >= 0.01,
$"Required biome {biome} covers only {pct:P2} of land tiles (minimum 1%).");
}
}
}
@@ -0,0 +1,50 @@
using Theriapolis.Core.World.Generation;
using Theriapolis.Core.World.Generation.Stages;
using Xunit;
namespace Theriapolis.Tests.Worldgen;
/// <summary>
/// Addendum A §1: the coastline must have organic noise-based shape across a
/// range of seeds after the BorderDistortionGen stage runs.
///
/// The validator counts maximal runs of consecutive border tiles in four line
/// orientations (horizontal, vertical, both diagonals). Any run longer than
/// <see cref="BorderDistortionGenStage.MaxAllowedRunLength"/> tiles is a
/// violation — this catches both axis-aligned and diagonal ruler-straight
/// coasts, which the previous cardinal-only 3-run detector missed.
/// </summary>
public sealed class BorderOrganicsTests : IClassFixture<WorldCache>
{
private readonly WorldCache _cache;
public BorderOrganicsTests(WorldCache cache) => _cache = cache;
private int ViolationsForSeed(ulong seed)
{
var ctx = _cache.Get(seed);
return BorderDistortionGenStage.CountStraightViolations(ctx);
}
[Fact]
public void Seed_CAFEBABE_HasZeroStraightViolations()
{
Assert.Equal(0, ViolationsForSeed(0xCAFEBABEUL));
}
[Theory]
[InlineData(1UL)]
[InlineData(42UL)]
[InlineData(999UL)]
[InlineData(0xDEAD_BEEFUL)]
[InlineData(0x1234_5678UL)]
[InlineData(0xABCD_EF01UL)]
[InlineData(7777777UL)]
[InlineData(0xFF00_FF00UL)]
[InlineData(314159265UL)]
[InlineData(271828182UL)]
public void TenSeeds_HaveZeroStraightViolations(ulong seed)
{
Assert.Equal(0, ViolationsForSeed(seed));
}
}
@@ -0,0 +1,57 @@
using Theriapolis.Core;
using Theriapolis.Core.World;
using Xunit;
namespace Theriapolis.Tests.Worldgen;
/// <summary>
/// DangerZone outputs depend on a fully-generated WorldState; this fixture
/// uses <see cref="WorldCache"/> to amortize the ~30 s pipeline run across
/// the file. Verifies the formula: zones increase with distance from the
/// player-start tier-1 settlement and from roads/settlements.
/// </summary>
public sealed class DangerZoneTests : IClassFixture<WorldCache>
{
private readonly WorldCache _cache;
public DangerZoneTests(WorldCache cache) { _cache = cache; }
[Fact]
public void Compute_StaysWithinClampedRange()
{
var ctx = _cache.Get(seed: 0xCAFEBABEUL);
for (int i = 0; i < 200; i++)
{
int x = (i * 37) % C.WORLD_WIDTH_TILES;
int y = (i * 113) % C.WORLD_HEIGHT_TILES;
int z = DangerZone.Compute(x, y, ctx.World);
Assert.InRange(z, C.DANGER_ZONE_MIN, C.DANGER_ZONE_MAX);
}
}
[Fact]
public void Compute_StartTileIsZeroOrLow()
{
var ctx = _cache.Get(seed: 0xCAFEBABEUL);
var (sx, sy) = DangerZone.ResolveStartTile(ctx.World);
int z = DangerZone.Compute(sx, sy, ctx.World);
// Player-start should be a low-zone area (zone 0 or 1 at most after
// biome bonus). Bovid-cities land in grasslands; if the start lands
// in mountainous biome the bonus pushes us to 1.
Assert.True(z <= 2, $"Player-start zone unexpectedly high: {z}");
}
[Fact]
public void Compute_FarFromStartIsHigherZone()
{
var ctx = _cache.Get(seed: 0xCAFEBABEUL);
var (sx, sy) = DangerZone.ResolveStartTile(ctx.World);
int nearZone = DangerZone.Compute(sx, sy, ctx.World);
// 100 tiles is 2 zones' worth at C.DANGER_DIST_FROM_START_PER_ZONE = 50.
int farX = System.Math.Min(C.WORLD_WIDTH_TILES - 1, sx + 100);
int farY = System.Math.Min(C.WORLD_HEIGHT_TILES - 1, sy + 100);
int farZone = DangerZone.Compute(farX, farY, ctx.World);
Assert.True(farZone > nearZone,
$"Far tile zone ({farZone}) should be > near tile zone ({nearZone})");
}
}
@@ -0,0 +1,104 @@
using Theriapolis.Core;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Theriapolis.Core.World.Polylines;
using Xunit;
namespace Theriapolis.Tests.Worldgen;
/// <summary>
/// Hydrology correctness: rivers must be generated, endpoints must reach water.
/// </summary>
public sealed class HydrologyTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
// HydrologyGen is stage 10 → 0-based index 9 (fast-path for hydrology-only tests).
private const int HydrologyStageIndex = 9;
private readonly WorldCache _cache;
public HydrologyTests(WorldCache cache) => _cache = cache;
[Fact]
public void Pipeline_GeneratesAtLeastOneRiver()
{
var ctx = _cache.GetThrough(TestSeed, HydrologyStageIndex);
Assert.NotEmpty(ctx.World.Rivers);
}
[Fact]
public void AllRivers_HaveAtLeastTwoPoints()
{
var ctx = _cache.GetThrough(TestSeed, HydrologyStageIndex);
foreach (var river in ctx.World.Rivers)
Assert.True(river.Points.Count >= 2,
$"River {river.Id} has fewer than 2 points");
}
[Fact]
public void RiverPolylines_AreInWorldPixelSpace()
{
var ctx = _cache.GetThrough(TestSeed, HydrologyStageIndex);
float maxCoord = C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS;
foreach (var river in ctx.World.Rivers)
foreach (var pt in river.Points)
{
Assert.True(pt.X >= 0 && pt.X <= maxCoord,
$"River {river.Id} point X={pt.X} out of world-pixel range");
Assert.True(pt.Y >= 0 && pt.Y <= maxCoord,
$"River {river.Id} point Y={pt.Y} out of world-pixel range");
}
}
[Theory]
[InlineData(0xCAFEBABEUL)]
[InlineData(0x12345678UL)]
[InlineData(0xDEADBEEFUL)]
public void RiverEndpoints_DrainToWaterOrWorldEdge(ulong seed)
{
var ctx = _cache.GetThrough(seed, HydrologyStageIndex);
var world = ctx.World;
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
int nonDrainingCount = 0;
foreach (var river in world.Rivers)
{
if (river.Points.Count < 2) continue;
var last = river.Points[^1];
int lx = Math.Clamp((int)(last.X / C.WORLD_TILE_PIXELS), 0, W - 1);
int ly = Math.Clamp((int)(last.Y / C.WORLD_TILE_PIXELS), 0, H - 1);
var biome = world.Tiles[lx, ly].Biome;
bool atWater = biome == BiomeId.Ocean || biome == BiomeId.Wetland ||
(world.Tiles[lx, ly].Features & FeatureFlags.HasRiver) != 0;
bool atEdge = lx == 0 || ly == 0 || lx == W - 1 || ly == H - 1;
if (!atWater && !atEdge)
nonDrainingCount++;
}
// Allow up to 10% non-draining rivers (noise from complex terrain)
double failRate = world.Rivers.Count > 0
? (double)nonDrainingCount / world.Rivers.Count
: 0.0;
Assert.True(failRate <= 0.10,
$"Seed {seed:X}: {nonDrainingCount}/{world.Rivers.Count} rivers do not drain to water ({failRate:P0})");
}
[Fact]
public void EncounterDensityMap_IsPopulated()
{
var ctx = _cache.Get(TestSeed);
Assert.NotNull(ctx.World.EncounterDensity);
float sum = 0;
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
sum += ctx.World.EncounterDensity![x, y];
Assert.True(sum > 0, "EncounterDensity map is all zeros");
}
}
@@ -0,0 +1,101 @@
using Theriapolis.Core;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Xunit;
namespace Theriapolis.Tests.Worldgen;
/// <summary>
/// Linear feature exclusion (Addendum A §2): no parallel river+rail or rail+road
/// on the same non-settlement tile. Road+river is allowed — it represents a
/// bridge crossing (see ValidationPassStage.CheckLinearExclusion).
/// </summary>
public sealed class LinearFeatureTests : IClassFixture<WorldCache>
{
private readonly WorldCache _cache;
public LinearFeatureTests(WorldCache cache) => _cache = cache;
[Theory]
[InlineData(0xCAFEBABEUL)]
[InlineData(0x11223344UL)]
[InlineData(0xDEADBEEFUL)]
[InlineData(0xFEEDFACEUL)]
[InlineData(0xABCDEF00UL)]
[InlineData(0x00112233UL)]
[InlineData(0x99AABBCCUL)]
[InlineData(0x12345678UL)]
[InlineData(0x87654321UL)]
[InlineData(0x0DEADC0DUL)]
public void NoParallelLinearFeatures_OnNonSettlementTiles(ulong seed)
{
var ctx = _cache.Get(seed);
var world = ctx.World;
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
int violations = 0;
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++)
{
ref var tile = ref world.TileAt(x, y);
if ((tile.Features & FeatureFlags.IsSettlement) != 0) continue;
bool hasRiver = (tile.Features & FeatureFlags.HasRiver) != 0;
bool hasRail = (tile.Features & FeatureFlags.HasRail) != 0;
bool hasRoad = (tile.Features & FeatureFlags.HasRoad) != 0;
// River + Rail parallel
if (hasRiver && hasRail &&
tile.RiverFlowDir != Theriapolis.Core.Util.Dir.None &&
tile.RailDir != Theriapolis.Core.Util.Dir.None &&
Theriapolis.Core.Util.Dir.IsParallel(tile.RiverFlowDir, tile.RailDir))
violations++;
// Rail + Road (always a violation outside settlements)
if (hasRail && hasRoad)
violations++;
}
Assert.Equal(0, violations);
}
[Fact]
public void RoadNetwork_HasAtLeastOneRoad()
{
var ctx = _cache.Get(0xCAFEBABEUL);
Assert.NotEmpty(ctx.World.Roads);
}
[Fact]
public void RailNetwork_HasAtLeastOneRailLine()
{
if (!C.ENABLE_RAIL) return; // rail subsystem disabled; nothing to assert
var ctx = _cache.Get(0xCAFEBABEUL);
Assert.NotEmpty(ctx.World.Rails);
}
[Fact]
public void Roads_AreInWorldPixelSpace()
{
var ctx = _cache.Get(0xCAFEBABEUL);
float maxCoord = C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS;
foreach (var road in ctx.World.Roads)
foreach (var pt in road.Points)
{
Assert.InRange(pt.X, 0f, maxCoord);
Assert.InRange(pt.Y, 0f, maxCoord);
}
}
[Fact]
public void ValidationPassHash_RecordsZeroViolations()
{
var ctx = _cache.Get(0xCAFEBABEUL);
if (!ctx.World.StageHashes.TryGetValue("ValidationPass", out ulong vhash))
return; // stage didn't record hash — skip
int violations = (int)(vhash / 1000);
Assert.Equal(0, violations);
}
}
@@ -0,0 +1,107 @@
using Theriapolis.Core;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Xunit;
namespace Theriapolis.Tests.Worldgen;
/// <summary>
/// Macro-template elevation and moisture constraints must hold after generation.
/// Tests from the Phase 1 acceptance criteria.
/// </summary>
public sealed class MacroConstraintTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
private readonly WorldCache _cache;
public MacroConstraintTests(WorldCache cache) => _cache = cache;
[Fact]
public void Mountain_Cells_HaveElevation_AboveFloor()
{
var ctx = _cache.Get(TestSeed);
int W = C.WORLD_WIDTH_TILES, H = C.WORLD_HEIGHT_TILES;
int violations = 0;
for (int ty = 0; ty < H; ty += 4)
for (int tx = 0; tx < W; tx += 4)
{
ref var tile = ref ctx.World.TileAt(tx, ty);
var cell = ctx.World.MacroCellForTile(tile);
// Skip ocean tiles — they're forced below sea level regardless of macro constraint
if (tile.Biome == BiomeId.Ocean) continue;
if (cell.BiomeType.Contains("mountain", StringComparison.OrdinalIgnoreCase))
{
if (tile.Elevation < cell.ElevationFloor - 0.05f) // 5% tolerance for blending
violations++;
}
}
Assert.Equal(0, violations);
}
[Fact]
public void Grassland_Cells_HaveElevation_BelowCeiling()
{
var ctx = _cache.Get(TestSeed);
int W = C.WORLD_WIDTH_TILES, H = C.WORLD_HEIGHT_TILES;
int violations = 0;
for (int ty = 0; ty < H; ty += 4)
for (int tx = 0; tx < W; tx += 4)
{
ref var tile = ref ctx.World.TileAt(tx, ty);
if (tile.Biome == BiomeId.Ocean) continue;
var cell = ctx.World.MacroCellForTile(tile);
if (cell.BiomeType.Contains("grassland", StringComparison.OrdinalIgnoreCase))
{
if (tile.Elevation > cell.ElevationCeiling + 0.05f)
violations++;
}
}
Assert.Equal(0, violations);
}
[Fact]
public void Tundra_Cells_HaveMoisture_BelowCeiling()
{
var ctx = _cache.Get(TestSeed);
int W = C.WORLD_WIDTH_TILES, H = C.WORLD_HEIGHT_TILES;
int violations = 0;
for (int ty = 0; ty < H; ty += 4)
for (int tx = 0; tx < W; tx += 4)
{
ref var tile = ref ctx.World.TileAt(tx, ty);
if (tile.Biome == BiomeId.Ocean) continue;
var cell = ctx.World.MacroCellForTile(tile);
if (cell.BiomeType.Contains("tundra", StringComparison.OrdinalIgnoreCase))
{
if (tile.Moisture > cell.MoistureCeiling + 0.05f)
violations++;
}
}
Assert.Equal(0, violations);
}
[Fact]
public void Subtropical_Cells_HaveMoisture_AboveFloor()
{
var ctx = _cache.Get(TestSeed);
int W = C.WORLD_WIDTH_TILES, H = C.WORLD_HEIGHT_TILES;
int violations = 0;
for (int ty = 0; ty < H; ty += 4)
for (int tx = 0; tx < W; tx += 4)
{
ref var tile = ref ctx.World.TileAt(tx, ty);
if (tile.Biome == BiomeId.Ocean) continue;
var cell = ctx.World.MacroCellForTile(tile);
if (cell.BiomeType.Contains("subtropical", StringComparison.OrdinalIgnoreCase))
{
if (tile.Moisture < cell.MoistureFloor - 0.05f)
violations++;
}
}
Assert.Equal(0, violations);
}
}
@@ -0,0 +1,276 @@
using Theriapolis.Core;
using Theriapolis.Core.Util;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Polylines;
using Xunit;
using Xunit.Abstractions;
namespace Theriapolis.Tests.Worldgen;
/// <summary>
/// Road network connectivity tests:
/// 1. Every non-POI settlement has a road endpoint within reach.
/// 2. Every bridge has road geometry on both sides (not truncated).
/// 3. No duplicate road polylines connect the same settlement pair.
/// </summary>
public sealed class RoadConnectivityTests : IClassFixture<WorldCache>
{
private readonly WorldCache _cache;
private readonly ITestOutputHelper _out;
public RoadConnectivityTests(WorldCache cache, ITestOutputHelper output)
{
_cache = cache;
_out = output;
}
// ── 1. Settlement connectivity ───────────────────────────────────────────
/// <summary>
/// Every non-POI settlement must have at least one road polyline endpoint
/// within 2 tiles of its center. This catches settlements that are
/// topologically in the network but have no visible road reaching them.
/// </summary>
[Theory]
[InlineData(0xCAFEBABEUL)]
[InlineData(0xDEADBEEFUL)]
[InlineData(0x12345678UL)]
public void AllSettlements_HaveRoadEndpointNearby(ulong seed)
{
var ctx = _cache.Get(seed);
var world = ctx.World;
float maxDist = C.WORLD_TILE_PIXELS * 2.5f; // 2.5 tiles
float maxDistSq = maxDist * maxDist;
var disconnected = new List<string>();
foreach (var settle in world.Settlements)
{
if (settle.IsPoi) continue;
var center = new Vec2(settle.WorldPixelX, settle.WorldPixelY);
bool found = false;
foreach (var road in world.Roads)
{
if (road.Points.Count < 2) continue;
if (Vec2.DistSq(road.Points[0], center) < maxDistSq ||
Vec2.DistSq(road.Points[^1], center) < maxDistSq)
{
found = true;
break;
}
}
if (!found)
{
// Find closest endpoint for diagnostic output
float bestDist = float.MaxValue;
foreach (var road in world.Roads)
{
if (road.Points.Count < 2) continue;
bestDist = MathF.Min(bestDist, Vec2.Dist(road.Points[0], center));
bestDist = MathF.Min(bestDist, Vec2.Dist(road.Points[^1], center));
}
disconnected.Add(
$" {settle.Name ?? "?"} (Tier {settle.Tier}, tile {settle.TileX},{settle.TileY}) " +
$"— nearest endpoint {bestDist:F0}px away ({bestDist / C.WORLD_TILE_PIXELS:F1} tiles)");
}
}
if (disconnected.Count > 0)
{
_out.WriteLine($"Disconnected settlements ({disconnected.Count}):");
foreach (var line in disconnected) _out.WriteLine(line);
}
Assert.Empty(disconnected);
}
// ── 2. Bridgeroad continuity ────────────────────────────────────────────
/// <summary>
/// Every bridge must reference a valid road, and the road must have enough
/// geometry to visually support the bridge (not just 1-2 segments total).
/// Bridges at road polyline termini are acceptable — the "other side" is
/// covered by a different polyline from SplitByExistingFeature.
/// </summary>
[Theory]
[InlineData(0xCAFEBABEUL)]
[InlineData(0xDEADBEEFUL)]
[InlineData(0x12345678UL)]
public void AllBridges_ReferenceValidRoads(ulong seed)
{
var ctx = _cache.Get(seed);
var world = ctx.World;
// Index roads by Id for fast lookup
var roadsById = new Dictionary<int, Polyline>();
foreach (var road in world.Roads)
roadsById.TryAdd(road.Id, road);
var broken = new List<string>();
foreach (var bridge in world.Bridges)
{
if (!roadsById.TryGetValue(bridge.RoadId, out var road))
{
broken.Add($" Bridge at ({bridge.WorldPixelX:F0},{bridge.WorldPixelY:F0}) references missing road Id={bridge.RoadId}");
continue;
}
// A road with < 5 segments is too short to meaningfully support a
// bridge — the deck would be the entire road.
if (road.Points.Count < 5)
{
broken.Add(
$" Bridge at ({bridge.WorldPixelX:F0},{bridge.WorldPixelY:F0}) on road {road.Id} " +
$"({road.RoadClassification}) — road only has {road.Points.Count} points");
}
}
if (broken.Count > 0)
{
_out.WriteLine($"Invalid bridges ({broken.Count}/{world.Bridges.Count}):");
foreach (var line in broken) _out.WriteLine(line);
}
Assert.Empty(broken);
}
// ── 3. No geometrically redundant roads ─────────────────────────────────
/// <summary>
/// When multiple road polylines connect the same settlement pair (expected
/// from SplitByExistingFeature), they should cover DIFFERENT geographic
/// stretches. If two same-pair polylines have endpoints close to each other,
/// they're geometrically redundant — one should have been merged or subsumed
/// during cleanup.
/// </summary>
[Theory]
[InlineData(0xCAFEBABEUL)]
[InlineData(0xDEADBEEFUL)]
[InlineData(0x12345678UL)]
public void NoDuplicateRoads_BetweenSameSettlementPair(ulong seed)
{
var ctx = _cache.Get(seed);
var world = ctx.World;
float overlapDist = C.POLYLINE_MERGE_DIST; // 80px — if endpoints are this close, they overlap
float overlapDistSq = overlapDist * overlapDist;
// Group roads by their unordered settlement pair + classification
var groups = new Dictionary<(int, int, RoadType), List<Polyline>>();
foreach (var road in world.Roads)
{
if (road.FromSettlementId < 0 || road.ToSettlementId < 0) continue;
int a = Math.Min(road.FromSettlementId, road.ToSettlementId);
int b = Math.Max(road.FromSettlementId, road.ToSettlementId);
var key = (a, b, road.RoadClassification);
if (!groups.TryGetValue(key, out var list))
groups[key] = list = new List<Polyline>();
list.Add(road);
}
var duplicates = new List<string>();
foreach (var (key, roads) in groups)
{
if (roads.Count < 2) continue;
// Check every pair for geometric overlap.
// Skip consecutive-ID pairs: those are split segments from a single
// A* edge (SplitByExistingFeature), not duplicate routes. They share
// a junction endpoint by design.
for (int i = 0; i < roads.Count; i++)
for (int j = i + 1; j < roads.Count; j++)
{
// Consecutive IDs come from the same edge's split — not redundant
if (Math.Abs(roads[i].Id - roads[j].Id) == 1) continue;
var ptsA = roads[i].Points;
var ptsB = roads[j].Points;
if (ptsA.Count < 2 || ptsB.Count < 2) continue;
// Check if A's start is near any of B's endpoints AND
// A's end is near any of B's endpoints.
bool startOverlap =
Vec2.DistSq(ptsA[0], ptsB[0]) < overlapDistSq ||
Vec2.DistSq(ptsA[0], ptsB[^1]) < overlapDistSq;
bool endOverlap =
Vec2.DistSq(ptsA[^1], ptsB[0]) < overlapDistSq ||
Vec2.DistSq(ptsA[^1], ptsB[^1]) < overlapDistSq;
if (startOverlap && endOverlap)
{
var settleA = world.Settlements.FirstOrDefault(s => s.Id == key.Item1);
var settleB = world.Settlements.FirstOrDefault(s => s.Id == key.Item2);
duplicates.Add(
$" {settleA?.Name ?? $"#{key.Item1}"} <-> {settleB?.Name ?? $"#{key.Item2}"}: " +
$"{key.Item3} ids {roads[i].Id} & {roads[j].Id} " +
$"({ptsA.Count} pts vs {ptsB.Count} pts, endpoints within {overlapDist:F0}px)");
}
}
}
if (duplicates.Count > 0)
{
_out.WriteLine($"Geometrically redundant road pairs ({duplicates.Count}):");
foreach (var line in duplicates) _out.WriteLine(line);
}
Assert.Empty(duplicates);
}
// ── 4. Road segments near settlements aren't excessively fanning ─────────
/// <summary>
/// At each settlement, count the number of distinct road polyline endpoints
/// within 3 tiles. Settlements shouldn't have more road endpoints than their
/// degree in the MST + shortcuts would produce. A reasonable upper bound is
/// 12 for any single settlement (even a capital in a dense network).
/// </summary>
[Theory]
[InlineData(0xCAFEBABEUL)]
[InlineData(0xDEADBEEFUL)]
[InlineData(0x12345678UL)]
public void NoSettlement_HasExcessiveRoadFanout(ulong seed)
{
var ctx = _cache.Get(seed);
var world = ctx.World;
float radiusSq = (C.WORLD_TILE_PIXELS * 3f) * (C.WORLD_TILE_PIXELS * 3f);
const int maxEndpoints = 12;
var excessive = new List<string>();
foreach (var settle in world.Settlements)
{
if (settle.IsPoi) continue;
var center = new Vec2(settle.WorldPixelX, settle.WorldPixelY);
int endpointCount = 0;
foreach (var road in world.Roads)
{
if (road.Points.Count < 2) continue;
if (Vec2.DistSq(road.Points[0], center) < radiusSq) endpointCount++;
if (Vec2.DistSq(road.Points[^1], center) < radiusSq) endpointCount++;
}
if (endpointCount > maxEndpoints)
{
excessive.Add(
$" {settle.Name ?? "?"} (Tier {settle.Tier}, tile {settle.TileX},{settle.TileY}) " +
$"— {endpointCount} road endpoints within 3 tiles");
}
}
if (excessive.Count > 0)
{
_out.WriteLine($"Settlements with excessive fan-out ({excessive.Count}):");
foreach (var line in excessive) _out.WriteLine(line);
}
Assert.Empty(excessive);
}
}
@@ -0,0 +1,172 @@
using Theriapolis.Core;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Xunit;
namespace Theriapolis.Tests.Worldgen;
/// <summary>
/// Settlement placement correctness: narrative anchors, tier counts, distances,
/// reachability, and no overlaps.
/// </summary>
public sealed class SettlementTests : IClassFixture<WorldCache>
{
private const ulong TestSeed = 0xCAFEBABEUL;
private readonly WorldCache _cache;
public SettlementTests(WorldCache cache) => _cache = cache;
// ── Narrative anchors ─────────────────────────────────────────────────────
[Fact]
public void SanctumFidelis_IsPresent()
{
var ctx = _cache.Get(TestSeed);
var capital = ctx.World.Settlements
.FirstOrDefault(s => s.Anchor == NarrativeAnchor.SanctumFidelis);
Assert.NotNull(capital);
Assert.Equal(1, capital!.Tier);
}
[Fact]
public void AllNarrativeAnchors_ArePlaced()
{
var ctx = _cache.Get(TestSeed);
var anchors = ctx.World.Settlements
.Where(s => s.Anchor.HasValue)
.Select(s => s.Anchor!.Value)
.ToHashSet();
foreach (NarrativeAnchor anchor in Enum.GetValues<NarrativeAnchor>())
Assert.Contains(anchor, anchors);
}
[Theory]
[InlineData(0xCAFEBABEUL)]
[InlineData(0x11111111UL)]
[InlineData(0x99887766UL)]
[InlineData(0xABCDEF01UL)]
[InlineData(0x00000042UL)]
public void NarrativeAnchors_PlacedAcrossMultipleSeeds(ulong seed)
{
var ctx = _cache.Get(seed);
var capital = ctx.World.Settlements
.FirstOrDefault(s => s.Anchor == NarrativeAnchor.SanctumFidelis);
Assert.NotNull(capital);
}
// ── Tier counts ───────────────────────────────────────────────────────────
[Fact]
public void TierCounts_MeetMinimums()
{
var ctx = _cache.Get(TestSeed);
var ss = ctx.World.Settlements.Where(s => !s.IsPoi).ToList();
int tier1 = ss.Count(s => s.Tier == 1);
int tier2 = ss.Count(s => s.Tier == 2);
int tier3 = ss.Count(s => s.Tier == 3);
int tier4 = ss.Count(s => s.Tier == 4);
Assert.Equal(1, tier1); // exactly one capital
Assert.InRange(tier2, C.SETTLE_TIER2_MIN, C.SETTLE_TIER2_MAX);
Assert.InRange(tier3, C.SETTLE_TIER3_MIN, C.SETTLE_TIER3_MAX);
Assert.True(tier4 >= C.SETTLE_TIER4_MIN,
$"Only {tier4} tier-4 settlements, need at least {C.SETTLE_TIER4_MIN}");
}
[Fact]
public void PoICount_MeetsMinimum()
{
var ctx = _cache.Get(TestSeed);
int pois = ctx.World.Settlements.Count(s => s.IsPoi);
Assert.True(pois >= C.SETTLE_TIER5_MIN,
$"Only {pois} PoIs, need at least {C.SETTLE_TIER5_MIN}");
}
// ── No overlapping settlements ─────────────────────────────────────────────
[Fact]
public void NoSettlements_ShareTheSameTile()
{
var ctx = _cache.Get(TestSeed);
var positions = new HashSet<(int, int)>();
foreach (var s in ctx.World.Settlements)
{
bool added = positions.Add((s.TileX, s.TileY));
Assert.True(added,
$"Two settlements share tile ({s.TileX},{s.TileY})");
}
}
// ── Minimum separation ─────────────────────────────────────────────────────
[Fact]
public void Tier1And2Settlements_MeetMinimumDistance()
{
var ctx = _cache.Get(TestSeed);
var high = ctx.World.Settlements.Where(s => s.Tier <= 2 && !s.IsPoi).ToList();
int minSq = C.SETTLE_MIN_DIST_TIER2 * C.SETTLE_MIN_DIST_TIER2;
for (int i = 0; i < high.Count; i++)
for (int j = i + 1; j < high.Count; j++)
{
int dx = high[i].TileX - high[j].TileX;
int dy = high[i].TileY - high[j].TileY;
Assert.True(dx * dx + dy * dy >= minSq,
$"{high[i].Name} and {high[j].Name} are too close ({Math.Sqrt(dx*dx+dy*dy):F0} tiles, min {C.SETTLE_MIN_DIST_TIER2})");
}
}
// ── Settlement attributes ─────────────────────────────────────────────────
[Fact]
public void AllSettlements_HaveNames()
{
var ctx = _cache.Get(TestSeed);
foreach (var s in ctx.World.Settlements.Where(s => !s.IsPoi))
Assert.False(string.IsNullOrWhiteSpace(s.Name),
$"Settlement at ({s.TileX},{s.TileY}) Tier {s.Tier} has no name");
}
[Fact]
public void AllSettlements_HaveValidTileCoordinates()
{
var ctx = _cache.Get(TestSeed);
int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES;
foreach (var s in ctx.World.Settlements)
{
Assert.InRange(s.TileX, 0, W - 1);
Assert.InRange(s.TileY, 0, H - 1);
}
}
[Fact]
public void NoSettlement_PlacedOnOcean()
{
var ctx = _cache.Get(TestSeed);
foreach (var s in ctx.World.Settlements)
{
var biome = ctx.World.Tiles[s.TileX, s.TileY].Biome;
Assert.NotEqual(BiomeId.Ocean, biome);
}
}
// ── Faction influence ─────────────────────────────────────────────────────
[Fact]
public void FactionInfluence_IsComputed()
{
var ctx = _cache.Get(TestSeed);
Assert.NotNull(ctx.World.FactionInfluence);
// Capital area should have high Enforcer influence
var capital = ctx.World.Settlements
.First(s => s.Anchor == NarrativeAnchor.SanctumFidelis);
float enforcer = ctx.World.FactionInfluence!
.Get((int)FactionId.CovenantEnforcers, capital.TileX, capital.TileY);
Assert.True(enforcer > 0.5f,
$"Enforcer influence at capital is only {enforcer:F3}");
}
}