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; /// /// Phase 6 M3 — walks a graph, evaluates option /// conditions, branches skill checks against a deterministic dice /// stream, and applies effects. /// /// Determinism: /// dialogueSeed = worldSeed ^ C.RNG_DIALOGUE ^ npcId ^ turnIndex /// Each skill-check option pulls a fresh d20 keyed by /// (npcId, turnIndex, optionIndex) — the cache means re-rendering /// the same node (e.g. tooltip refresh) doesn't re-roll. /// /// The runner does not own UI. It exposes and /// for the screen to render, plus for scrollback. The screen calls /// when the player picks one; the runner returns a result describing /// what happened (text to append, skill-check rolled, dialogue ended, /// shop requested). /// public sealed class DialogueRunner { private readonly DialogueDef _tree; private readonly DialogueContext _ctx; private readonly ulong _worldSeed; private readonly ulong _npcId; private readonly Dictionary _nodesById; /// Cache of (turnIndex, optionIndex) → (rolled, total) so re-renders don't re-roll. private readonly Dictionary<(int turn, int option), SkillCheckRoll> _rollCache = new(); public int TurnIndex { get; private set; } public DialogueNodeDef CurrentNode { get; private set; } public List History { get; } = new(); public bool IsOver { get; private set; } /// Direct accessor to the runtime context — exposed so the UI /// can read after option selection. 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); } /// Options that pass their visibility predicates at the current turn. 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); } } /// /// Pick an option by its index *into the original options array* (not /// the visible-only list — index stability across re-renders). /// 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); } /// Force-close the dialogue (player pressed Esc). 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(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(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, "", 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))); } /// /// Substitute placeholders in dialogue text. Phase 6 M3 supports /// {pc.name}, {npc.role}, {npc.name}, {disposition_label}. /// 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); }