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,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}).");
}
}