Files
TheriapolisV3/Theriapolis.Core/Rules/Dialogue/DialogueRunner.cs
T
Christopher Wiebe b451f83174 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>
2026-04-30 20:40:51 -07:00

329 lines
13 KiB
C#

using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Dialogue;
/// <summary>
/// Phase 6 M3 — walks a <see cref="DialogueDef"/> graph, evaluates option
/// conditions, branches skill checks against a deterministic dice
/// stream, and applies effects.
///
/// Determinism:
/// <c>dialogueSeed = worldSeed ^ C.RNG_DIALOGUE ^ npcId ^ turnIndex</c>
/// Each skill-check option pulls a fresh d20 keyed by
/// <c>(npcId, turnIndex, optionIndex)</c> — the cache means re-rendering
/// the same node (e.g. tooltip refresh) doesn't re-roll.
///
/// The runner does not own UI. It exposes <see cref="CurrentNode"/> and
/// <see cref="VisibleOptions"/> for the screen to render, plus <see
/// cref="History"/> for scrollback. The screen calls <see cref="ChooseOption"/>
/// when the player picks one; the runner returns a result describing
/// what happened (text to append, skill-check rolled, dialogue ended,
/// shop requested).
/// </summary>
public sealed class DialogueRunner
{
private readonly DialogueDef _tree;
private readonly DialogueContext _ctx;
private readonly ulong _worldSeed;
private readonly ulong _npcId;
private readonly Dictionary<string, DialogueNodeDef> _nodesById;
/// <summary>Cache of (turnIndex, optionIndex) → (rolled, total) so re-renders don't re-roll.</summary>
private readonly Dictionary<(int turn, int option), SkillCheckRoll> _rollCache = new();
public int TurnIndex { get; private set; }
public DialogueNodeDef CurrentNode { get; private set; }
public List<DialogueLogEntry> History { get; } = new();
public bool IsOver { get; private set; }
/// <summary>Direct accessor to the runtime context — exposed so the UI
/// can read <see cref="DialogueContext.ShopRequested"/> after option selection.</summary>
public DialogueContext Context => _ctx;
public DialogueRunner(DialogueDef tree, DialogueContext ctx, ulong worldSeed)
{
_tree = tree ?? throw new System.ArgumentNullException(nameof(tree));
_ctx = ctx ?? throw new System.ArgumentNullException(nameof(ctx));
_worldSeed = worldSeed;
_npcId = (ulong)ctx.Npc.Id;
_nodesById = tree.Nodes.ToDictionary(n => n.Id, System.StringComparer.OrdinalIgnoreCase);
if (!_nodesById.TryGetValue(tree.Root, out var root))
throw new System.InvalidOperationException($"Dialogue '{tree.Id}' root '{tree.Root}' missing");
CurrentNode = root;
AppendNodeText(root);
ApplyEffects(root.OnEnter);
}
/// <summary>Options that pass their visibility predicates at the current turn.</summary>
public IEnumerable<(int Index, DialogueOptionDef Option)> VisibleOptions()
{
for (int i = 0; i < CurrentNode.Options.Length; i++)
{
var opt = CurrentNode.Options[i];
if (AreConditionsMet(opt.Conditions))
yield return (i, opt);
}
}
/// <summary>
/// Pick an option by its index *into the original options array* (not
/// the visible-only list — index stability across re-renders).
/// </summary>
public DialogueChooseResult ChooseOption(int optionIndex)
{
if (IsOver) return DialogueChooseResult.Closed("(dialogue is already over)");
if (optionIndex < 0 || optionIndex >= CurrentNode.Options.Length)
return DialogueChooseResult.Closed("(no such option)");
var opt = CurrentNode.Options[optionIndex];
if (!AreConditionsMet(opt.Conditions))
return DialogueChooseResult.Closed("(option not available)");
// Append the player's choice to history.
History.Add(new DialogueLogEntry(DialogueSpeaker.Pc, opt.Text));
TurnIndex++;
// Skill-check option: roll, branch on success/failure, apply
// appropriate effects/next.
if (opt.SkillCheck is { } check)
{
var roll = ResolveSkillCheck(optionIndex, check);
string log = $" [{check.Skill.ToUpperInvariant()} DC {check.Dc}] roll {roll.D20Raw} + {roll.Bonus} = {roll.Total} → {(roll.Succeeded ? "SUCCESS" : "FAILURE")}";
History.Add(new DialogueLogEntry(DialogueSpeaker.Narration, log));
ApplyEffects(roll.Succeeded ? opt.EffectsOnSuccess : opt.EffectsOnFailure);
string nextId = roll.Succeeded ? opt.NextOnSuccess : opt.NextOnFailure;
return AdvanceTo(nextId, roll);
}
// Plain option.
ApplyEffects(opt.Effects);
return AdvanceTo(opt.Next, default);
}
/// <summary>Force-close the dialogue (player pressed Esc).</summary>
public void End()
{
if (IsOver) return;
IsOver = true;
}
// ── Conditions ───────────────────────────────────────────────────────
private bool AreConditionsMet(DialogueConditionDef[] conditions)
{
foreach (var c in conditions)
if (!Evaluate(c)) return false;
return true;
}
private bool Evaluate(DialogueConditionDef c) => c.Kind.ToLowerInvariant() switch
{
"rep_at_least" => RepFor(c.Faction) >= c.Value,
"rep_below" => RepFor(c.Faction) < c.Value,
"has_item" => HasItem(c.Id),
"not_has_item" => !HasItem(c.Id),
"has_flag" => _ctx.Flags.TryGetValue(c.Flag, out int v) && v != 0,
"not_has_flag" => !_ctx.Flags.TryGetValue(c.Flag, out int v2) || v2 == 0,
"ability_min" => AbilityMod(c.Ability) >= c.Value,
_ => true, // unknown kind → permissive (validated at content-load)
};
private int RepFor(string faction)
{
if (string.IsNullOrEmpty(faction))
return _ctx.EffectiveDispositionScore();
return _ctx.Reputation.Factions.Get(faction);
}
private bool HasItem(string itemId)
{
if (string.IsNullOrEmpty(itemId)) return false;
foreach (var inst in _ctx.Pc.Inventory.Items)
if (string.Equals(inst.Def.Id, itemId, System.StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
private int AbilityMod(string abilityRaw)
{
if (!System.Enum.TryParse<AbilityId>(abilityRaw, true, out var id)) return 0;
return _ctx.Pc.Abilities.ModFor(id);
}
// ── Effects ──────────────────────────────────────────────────────────
private void ApplyEffects(DialogueEffectDef[] effects)
{
foreach (var e in effects) ApplyEffect(e);
}
private void ApplyEffect(DialogueEffectDef e)
{
switch (e.Kind.ToLowerInvariant())
{
case "set_flag":
_ctx.Flags[e.Flag] = e.Value;
break;
case "clear_flag":
_ctx.Flags.Remove(e.Flag);
break;
case "give_item":
if (_ctx.Content.Items.TryGetValue(e.Id, out var giveDef))
_ctx.Pc.Inventory.Add(giveDef, System.Math.Max(1, e.Qty));
break;
case "take_item":
TakeFromInventory(e.Id, System.Math.Max(1, e.Qty));
break;
case "rep_event":
if (e.Event is { } ev)
SubmitRepEvent(ev);
break;
case "open_shop":
_ctx.ShopRequested = true;
break;
case "start_quest":
if (!string.IsNullOrEmpty(e.Quest))
_ctx.StartQuestRequests.Add(e.Quest);
break;
case "give_xp":
_ctx.Pc.Xp = System.Math.Max(0, _ctx.Pc.Xp + e.Xp);
break;
}
}
private void TakeFromInventory(string itemId, int qty)
{
if (string.IsNullOrEmpty(itemId)) return;
for (int i = _ctx.Pc.Inventory.Items.Count - 1; i >= 0 && qty > 0; i--)
{
var inst = _ctx.Pc.Inventory.Items[i];
if (!string.Equals(inst.Def.Id, itemId, System.StringComparison.OrdinalIgnoreCase)) continue;
int take = System.Math.Min(qty, inst.Qty);
inst.Qty -= take;
qty -= take;
if (inst.Qty <= 0) _ctx.Pc.Inventory.Remove(inst);
}
}
private void SubmitRepEvent(DialogueRepEventDef ev)
{
if (!System.Enum.TryParse<RepEventKind>(ev.Kind, true, out var kind)) kind = RepEventKind.Dialogue;
var live = new RepEvent
{
Kind = kind,
FactionId = ev.Faction,
RoleTag = string.IsNullOrEmpty(ev.RoleTag) ? _ctx.Npc.RoleTag : ev.RoleTag,
Magnitude = ev.Magnitude,
Note = ev.Note,
OriginTileX = _ctx.PlayerWorldTileX,
OriginTileY = _ctx.PlayerWorldTileY,
TimestampSeconds = _ctx.WorldClockSeconds,
};
_ctx.Reputation.Submit(live, _ctx.Content.Factions);
}
// ── Skill check ──────────────────────────────────────────────────────
private SkillCheckRoll ResolveSkillCheck(int optionIndex, DialogueSkillCheckDef check)
{
var key = (TurnIndex - 1, optionIndex);
if (_rollCache.TryGetValue(key, out var cached)) return cached;
var skill = SkillIdExtensions.FromJson(check.Skill);
int abilityMod = _ctx.Pc.Abilities.ModFor(skill.Ability());
int profBonus = _ctx.Pc.SkillProficiencies.Contains(skill) ? _ctx.Pc.ProficiencyBonus : 0;
int bonus = abilityMod + profBonus;
ulong seed = _worldSeed
^ C.RNG_DIALOGUE
^ _npcId
^ ((ulong)(uint)key.Item1 << 8)
^ ((ulong)(uint)key.Item2 << 24);
var rng = new SeededRng(seed);
int d20 = (int)(rng.NextUInt64() % 20UL) + 1;
int total = d20 + bonus;
var roll = new SkillCheckRoll(skill, check.Dc, d20, bonus, total, total >= check.Dc);
_rollCache[key] = roll;
return roll;
}
// ── Node transitions ─────────────────────────────────────────────────
private DialogueChooseResult AdvanceTo(string nextId, SkillCheckRoll skillRoll)
{
if (string.IsNullOrEmpty(nextId) || string.Equals(nextId, "<end>", System.StringComparison.OrdinalIgnoreCase))
{
IsOver = true;
return DialogueChooseResult.Closed(skillRoll.Skill == 0 ? "" : "");
}
if (!_nodesById.TryGetValue(nextId, out var next))
{
IsOver = true;
return DialogueChooseResult.Closed($"(missing node '{nextId}' — content bug)");
}
CurrentNode = next;
AppendNodeText(next);
ApplyEffects(next.OnEnter);
return DialogueChooseResult.Advanced(skillRoll);
}
private void AppendNodeText(DialogueNodeDef node)
{
var speaker = node.Speaker.ToLowerInvariant() switch
{
"pc" => DialogueSpeaker.Pc,
"narration" => DialogueSpeaker.Narration,
_ => DialogueSpeaker.Npc,
};
History.Add(new DialogueLogEntry(speaker, ResolvePlaceholders(node.Text)));
}
/// <summary>
/// Substitute placeholders in dialogue text. Phase 6 M3 supports
/// {pc.name}, {npc.role}, {npc.name}, {disposition_label}.
/// </summary>
private string ResolvePlaceholders(string text)
{
if (string.IsNullOrEmpty(text)) return text;
return text
.Replace("{pc.name}", _ctx.Pc is null ? "Wanderer" : "the wanderer", System.StringComparison.OrdinalIgnoreCase)
.Replace("{npc.role}", _ctx.Npc.RoleTag ?? "", System.StringComparison.OrdinalIgnoreCase)
.Replace("{npc.name}", _ctx.Npc.DisplayName, System.StringComparison.OrdinalIgnoreCase)
.Replace("{disposition_label}",
DispositionLabels.DisplayName(DispositionLabels.For(_ctx.EffectiveDispositionScore())),
System.StringComparison.OrdinalIgnoreCase);
}
}
public enum DialogueSpeaker : byte { Npc, Pc, Narration }
public readonly record struct DialogueLogEntry(DialogueSpeaker Speaker, string Text);
public readonly record struct SkillCheckRoll(SkillId Skill, int Dc, int D20Raw, int Bonus, int Total, bool Succeeded);
public readonly struct DialogueChooseResult
{
public bool ClosedAfter { get; }
public string Note { get; }
public SkillCheckRoll? Roll { get; }
private DialogueChooseResult(bool closed, string note, SkillCheckRoll? roll)
{
ClosedAfter = closed;
Note = note;
Roll = roll;
}
public static DialogueChooseResult Closed(string note)
=> new(true, note, null);
public static DialogueChooseResult Advanced(SkillCheckRoll roll)
=> new(false, "", roll.Dc == 0 ? null : roll);
}