b451f83174
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>
329 lines
13 KiB
C#
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);
|
|
}
|