using Theriapolis.Core.Data; using Theriapolis.Core.Rules.Reputation; namespace Theriapolis.Core.Rules.Quests; /// /// Phase 6 M4 — quest engine. Owns the active + completed quest lists. /// On each it walks active quests, evaluates each /// step's , fires /// +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. /// public sealed class QuestEngine { private readonly Dictionary _active = new(System.StringComparer.OrdinalIgnoreCase); private readonly Dictionary _completed = new(System.StringComparer.OrdinalIgnoreCase); public IReadOnlyDictionary Active => _active; public IReadOnlyDictionary Completed => _completed; /// Append-only player-facing log of "milestones reached" for the journal screen. public List 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; /// /// 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). /// 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; } /// End an active quest manually (e.g. dialogue effect or quest-failure step). 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); } /// Per-frame tick. Runs auto-start checks then advances every active quest. 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(_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); } } /// Walk one quest forward until no step fires this tick. 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, "", 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(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(); } /// /// Save-load helpers — used by to /// restore engine state without re-firing on_enter effects (those /// already applied in the saved game). /// public void AdoptActive(QuestState state) => _active[state.QuestId] = state; public void AdoptCompleted(QuestState state) => _completed[state.QuestId] = state; }