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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user