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; /// /// Phase 6 M3 — DialogueRunner mechanics: option visibility, effect /// application (set_flag, give_item, rep_event, open_shop), skill-check /// branching, deterministic dice, history scrollback. /// 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(); 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 flags) { var pc = WolfPc(content); var npc = SyntheticInnkeeper(content); rep = new PlayerReputation(); flags = new Dictionary(); 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 = "" }, }, }, 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 = "" }, }, }, }, }; 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 = "", }, }, }, }, }; var content = LoadContent(); var (runner, _) = NewRunner(tree, content, out var rep, out _); runner.ChooseOption(0); Assert.True(rep.Personal["test.innkeeper"].Score < 0); } }