Files
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

344 lines
15 KiB
C#

using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Reputation;
namespace Theriapolis.Core.Rules.Quests;
/// <summary>
/// Phase 6 M4 — quest engine. Owns the active + completed quest lists.
/// On each <see cref="Tick"/> it walks active quests, evaluates each
/// step's <see cref="QuestStepDef.TriggerConditions"/>, fires
/// <see cref="QuestStepDef.OnEnter"/>+outcomes when ready, and chains
/// transitions until no further step fires this tick.
///
/// The engine is intentionally not a script interpreter — every trigger
/// and effect is one of a closed set of enum-tagged kinds (plan §8: hard
/// rule). New behaviour goes in via a new kind, never via dynamic code.
/// </summary>
public sealed class QuestEngine
{
private readonly Dictionary<string, QuestState> _active = new(System.StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, QuestState> _completed = new(System.StringComparer.OrdinalIgnoreCase);
public IReadOnlyDictionary<string, QuestState> Active => _active;
public IReadOnlyDictionary<string, QuestState> Completed => _completed;
/// <summary>Append-only player-facing log of "milestones reached" for the journal screen.</summary>
public List<string> Journal { get; } = new();
public QuestStatus StatusOf(string questId)
{
if (_active.TryGetValue(questId, out var a)) return a.Status;
if (_completed.TryGetValue(questId, out var c)) return c.Status;
return QuestStatus.Active; // sentinel for "never started" — caller checks IsKnown
}
public bool IsActive(string questId) => _active.ContainsKey(questId);
public bool IsCompleted(string questId) => _completed.TryGetValue(questId, out var c) && c.Status == QuestStatus.Completed;
public bool IsFailed(string questId) => _completed.TryGetValue(questId, out var c) && c.Status == QuestStatus.Failed;
public QuestState? Get(string questId)
=> _active.TryGetValue(questId, out var a) ? a
: _completed.TryGetValue(questId, out var c) ? c
: null;
/// <summary>
/// Start a quest. Idempotent — re-starting an already-active quest is
/// a no-op; re-starting a completed quest is also a no-op (Phase 6
/// M4 has no "redo" semantics).
/// </summary>
public bool Start(string questId, QuestContext ctx)
{
if (_active.ContainsKey(questId) || _completed.ContainsKey(questId)) return false;
if (!ctx.Content.Quests.TryGetValue(questId, out var def)) return false;
if (_active.Count >= C.QUEST_MAX_ACTIVE) return false;
var state = new QuestState
{
QuestId = questId,
CurrentStep = def.EntryStep,
Status = QuestStatus.Active,
StartedAt = ctx.Clock.InGameSeconds,
StepStartedAt = ctx.Clock.InGameSeconds,
};
_active[questId] = state;
Journal.Add($"Started: {def.Title}");
// Run on_enter for the entry step immediately so the quest can do
// setup (set_flag, give_item, etc.) before its first tick.
if (FindStep(def, def.EntryStep) is { } entry)
ApplyEffects(entry.OnEnter, ctx, state);
// The entry step might itself satisfy its outcomes immediately
// (e.g. trigger conditions all already met). Run a follow-up tick.
TickQuest(def, state, ctx);
return true;
}
/// <summary>End an active quest manually (e.g. dialogue effect or quest-failure step).</summary>
public void End(string questId, QuestStatus status, QuestContext ctx)
{
if (!_active.TryGetValue(questId, out var state)) return;
if (!ctx.Content.Quests.TryGetValue(questId, out var def)) return;
FinishQuest(def, state, status);
}
/// <summary>Per-frame tick. Runs auto-start checks then advances every active quest.</summary>
public void Tick(QuestContext ctx)
{
// Auto-start checks: any quest whose AutoStartWhen conditions all
// pass and which isn't yet active or completed kicks off.
foreach (var def in ctx.Content.Quests.Values)
{
if (def.AutoStartWhen.Length == 0) continue;
if (_active.ContainsKey(def.Id) || _completed.ContainsKey(def.Id)) continue;
if (AreConditionsMet(def.AutoStartWhen, ctx))
Start(def.Id, ctx);
}
// Advance each active quest. Collect first to allow Tick → end-quest
// → modify _active mid-iteration.
var snap = new List<QuestState>(_active.Values);
foreach (var state in snap)
{
if (!ctx.Content.Quests.TryGetValue(state.QuestId, out var def)) continue;
if (state.Status != QuestStatus.Active) continue;
TickQuest(def, state, ctx);
}
}
/// <summary>Walk one quest forward until no step fires this tick.</summary>
private void TickQuest(QuestDef def, QuestState state, QuestContext ctx)
{
// Iterate so an outcome that lands on a step whose triggers also
// fire chains into the next step in the same frame.
int hops = 0;
const int MaxHops = 32; // sanity guard against pathological cycles
while (hops++ < MaxHops)
{
var step = FindStep(def, state.CurrentStep);
if (step is null) break;
// Quest-terminal step?
if (step.CompletesQuest) { FinishQuest(def, state, QuestStatus.Completed); return; }
if (step.FailsQuest) { FinishQuest(def, state, QuestStatus.Failed); return; }
// Triggers gate the step: if not all met, wait for next tick.
if (step.TriggerConditions.Length > 0 && !AreConditionsMet(step.TriggerConditions, ctx))
break;
// Pick first outcome whose `when` clauses are all satisfied.
QuestOutcomeDef? chosen = null;
foreach (var o in step.Outcomes)
{
if (o.When.Length == 0 || AreConditionsMet(o.When, ctx))
{
chosen = o;
break;
}
}
if (chosen is null) break;
ApplyEffects(chosen.Effects, ctx, state);
string? nextId = string.Equals(chosen.Next, "<end>", System.StringComparison.OrdinalIgnoreCase)
? null
: chosen.Next;
if (nextId is null) { FinishQuest(def, state, QuestStatus.Completed); return; }
state.CurrentStep = nextId;
state.StepStartedAt = ctx.Clock.InGameSeconds;
Journal.Add($" → {def.Id}: '{nextId}'");
// Run on_enter for the new step.
if (FindStep(def, nextId) is { } newStep)
ApplyEffects(newStep.OnEnter, ctx, state);
}
}
private void FinishQuest(QuestDef def, QuestState state, QuestStatus status)
{
state.Status = status;
_active.Remove(def.Id);
// Cap completed history to keep save size bounded.
if (_completed.Count >= C.QUEST_LOG_COMPLETED_LIMIT)
{
// Drop the oldest (insertion order via OrderBy on StartedAt).
string? oldest = _completed.Values.OrderBy(s => s.StartedAt).FirstOrDefault()?.QuestId;
if (oldest is not null) _completed.Remove(oldest);
}
_completed[def.Id] = state;
Journal.Add(status switch
{
QuestStatus.Completed => $"Completed: {def.Title}",
QuestStatus.Failed => $"Failed: {def.Title}",
_ => $"Ended: {def.Title}",
});
}
private static QuestStepDef? FindStep(QuestDef def, string stepId)
{
foreach (var s in def.Steps)
if (string.Equals(s.Id, stepId, System.StringComparison.OrdinalIgnoreCase))
return s;
return null;
}
// ── Conditions ───────────────────────────────────────────────────────
private bool AreConditionsMet(QuestConditionDef[] conditions, QuestContext ctx)
{
foreach (var c in conditions)
if (!Evaluate(c, ctx)) return false;
return true;
}
private bool Evaluate(QuestConditionDef c, QuestContext ctx) => c.Kind.ToLowerInvariant() switch
{
"flag_set" => ctx.Flags.TryGetValue(c.Flag, out int v) && v != 0,
"flag_clear" => !ctx.Flags.TryGetValue(c.Flag, out int v2) || v2 == 0,
"flag_at_least" => ctx.Flags.TryGetValue(c.Flag, out int vv) && vv >= c.Value,
"enter_anchor" => CheckEnterAnchor(c, ctx),
"enter_role_proximity" => CheckEnterRoleProximity(c, ctx),
"npc_dead" => CheckNpcAlive(c, ctx) is { } liveDead && !liveDead,
"npc_alive" => CheckNpcAlive(c, ctx) is { } live && live,
"time_elapsed_seconds" => ctx.Clock.InGameSeconds >= c.Seconds,
"rep_at_least" => RepFor(c.Faction, ctx) >= c.Value,
"rep_below" => RepFor(c.Faction, ctx) < c.Value,
"has_item" => ctx.HasItem(c.Id),
"not_has_item" => !ctx.HasItem(c.Id),
"quest_complete" => IsCompleted(c.Quest),
"quest_active" => IsActive(c.Quest),
"dialogue_choice" => string.Equals(ctx.LastDialogueNodeReached, c.Id,
System.StringComparison.OrdinalIgnoreCase),
_ => false,
};
private bool CheckEnterAnchor(QuestConditionDef c, QuestContext ctx)
{
if (string.IsNullOrEmpty(c.Anchor)) return false;
int? sId = ctx.Anchors.ResolveAnchor(c.Anchor.StartsWith("anchor:")
? c.Anchor : $"anchor:{c.Anchor}");
if (sId is null) return false;
var dist = ctx.PlayerDistanceToSettlement(sId.Value);
return dist is not null && dist.Value <= C.QUEST_ENTER_ANCHOR_RADIUS_TILES;
}
private bool CheckEnterRoleProximity(QuestConditionDef c, QuestContext ctx)
{
if (string.IsNullOrEmpty(c.Role)) return false;
int? npcId = ctx.Anchors.ResolveRole(c.Role.StartsWith("role:")
? c.Role : $"role:{c.Role}");
if (npcId is null) return false;
if (ctx.PlayerTacticalPos() is not { } ppos) return false;
foreach (var npc in ctx.Actors.Npcs)
{
if (npc.Id != npcId.Value) continue;
int dx = (int)System.Math.Abs(npc.Position.X - ppos.x);
int dy = (int)System.Math.Abs(npc.Position.Y - ppos.y);
return System.Math.Max(dx, dy) <= C.QUEST_ENTER_ROLE_RADIUS_TILES;
}
return false;
}
private bool? CheckNpcAlive(QuestConditionDef c, QuestContext ctx)
{
string roleTag = string.IsNullOrEmpty(c.Role) ? c.Npc : c.Role;
if (string.IsNullOrEmpty(roleTag)) return null;
int? npcId = ctx.Anchors.ResolveRole(roleTag.StartsWith("role:") ? roleTag : $"role:{roleTag}");
if (npcId is null)
{
// The NPC is not currently spawned; treat as ALIVE if the engine
// hasn't recorded a kill flag (chunks evict NPCs frequently).
return true;
}
foreach (var npc in ctx.Actors.Npcs)
if (npc.Id == npcId.Value) return npc.IsAlive;
return true;
}
private static int RepFor(string faction, QuestContext ctx)
{
if (string.IsNullOrEmpty(faction)) return 0;
return ctx.Reputation.Factions.Get(faction);
}
// ── Effects ──────────────────────────────────────────────────────────
private void ApplyEffects(QuestEffectDef[] effects, QuestContext ctx, QuestState state)
{
foreach (var e in effects) ApplyEffect(e, ctx, state);
}
private void ApplyEffect(QuestEffectDef e, QuestContext ctx, QuestState state)
{
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.PlayerCharacter is not null && ctx.Content.Items.TryGetValue(e.Id, out var giveDef))
ctx.PlayerCharacter.Inventory.Add(giveDef, System.Math.Max(1, e.Qty));
break;
case "take_item": TakeItem(ctx, e.Id, System.Math.Max(1, e.Qty)); break;
case "give_xp":
if (ctx.PlayerCharacter is not null)
ctx.PlayerCharacter.Xp = System.Math.Max(0, ctx.PlayerCharacter.Xp + e.Xp);
break;
case "rep_event": if (e.Event is { } ev) SubmitRepEvent(ev, ctx); break;
case "start_quest": if (!string.IsNullOrEmpty(e.Quest)) Start(e.Quest, ctx); break;
case "end_quest": if (_active.ContainsKey(state.QuestId)) FinishQuest(
ctx.Content.Quests[state.QuestId], state, QuestStatus.Completed); break;
case "fail_quest": if (_active.ContainsKey(state.QuestId)) FinishQuest(
ctx.Content.Quests[state.QuestId], state, QuestStatus.Failed); break;
// spawn_npc / despawn_npc are M4 stubs — Phase 6 M5 wires the
// residency manipulation. Recording in the journal so the
// player can see the quest *intends* it.
case "spawn_npc": Journal.Add($"(quest) spawn_npc {e.Role} ← {e.Template}"); break;
case "despawn_npc": Journal.Add($"(quest) despawn_npc {e.Role}"); break;
}
}
private static void TakeItem(QuestContext ctx, string itemId, int qty)
{
if (ctx.PlayerCharacter is null || string.IsNullOrEmpty(itemId)) return;
var inv = ctx.PlayerCharacter.Inventory;
for (int i = inv.Items.Count - 1; i >= 0 && qty > 0; i--)
{
var inst = inv.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) inv.Remove(inst);
}
}
private static void SubmitRepEvent(DialogueRepEventDef ev, QuestContext ctx)
{
if (!System.Enum.TryParse<RepEventKind>(ev.Kind, true, out var kind)) kind = RepEventKind.Quest;
var live = new RepEvent
{
Kind = kind,
FactionId = ev.Faction,
RoleTag = ev.RoleTag,
Magnitude = ev.Magnitude,
Note = ev.Note,
TimestampSeconds = ctx.Clock.InGameSeconds,
};
ctx.Reputation.Submit(live, ctx.Content.Factions);
}
public void Clear()
{
_active.Clear();
_completed.Clear();
Journal.Clear();
}
/// <summary>
/// Save-load helpers — used by <see cref="Persistence.QuestCodec"/> to
/// restore engine state without re-firing on_enter effects (those
/// already applied in the saved game).
/// </summary>
public void AdoptActive(QuestState state) => _active[state.QuestId] = state;
public void AdoptCompleted(QuestState state) => _completed[state.QuestId] = state;
}