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