commit b9b508a1d07619cc49328e76aaf9c24b4a1d2c0b Author: iisaev Date: Sun Oct 18 12:41:39 2026 +0300 init mod diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..7b03299 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,4 @@ +mod_quests_base_name = Quests Engine +mod_quests_base_desc = Engine quests subsystem: the quest factory and runtime state, the IQuestService implementation, quest scenario actions, quest scene conditions and the quest journal UI. +ui/journal/new = New journal entry +ui/journal/updated = Quest updated diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..9fcd6fe --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,4 @@ +mod_quests_base_name = Движок квестов +mod_quests_base_desc = Подсистема квестов движка: фабрика квестов и их состояние, реализация IQuestService, сценарные действия, условия квестов в сценах и журнал квестов. +ui/journal/new = Новая запись в журнале +ui/journal/updated = Задание обновлено diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..3e8ce90 --- /dev/null +++ b/manifest.json @@ -0,0 +1,22 @@ +{ + "id": "quests_base", + "name": "Quests Engine", + "version": "1.0.0", + "author": "ct", + "description": "Engine quests subsystem: the quest factory and runtime state (QuestFactory, QuestBase/QuestData), the IQuestService implementation, quest scenario actions, quest scene conditions with the QuestParser leaf parser and the quest journal UI. Replaces the former Core/Quests state, Core/Actions quest services and actions, Core/Scenes quest conditions and Core/UI/Quests - QuestsModInitializer publishes the kernel contract (IQuestService via QuestServiceRegistry), registers the quest leaf parser and the new-game resettable; QuestsSaveable persists quest state through the generic mod save slot.", + "category": "engine, library", + "sources": [ + "src/**/*.cs" + ], + "depends": ["characters_base>=1", "interface>=1"], + "ui": [ + { + "id": "journal", + "kind": "panel", + "slot": "ModBeforeUIPanel", + "scene_path": "scenes/ui/quest_journal.tscn", + "button": { "icon": "journal", "locale": "ui/journal/title", "index": 60 }, + "priority": 10 + } + ] +} diff --git a/scenes/ui/quest_journal.tscn b/scenes/ui/quest_journal.tscn new file mode 100644 index 0000000..2ffdc6b --- /dev/null +++ b/scenes/ui/quest_journal.tscn @@ -0,0 +1,300 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://mods/quests_base/src/UI/Quests/QuestJournalBehaviour.cs" id="1_script"] + +[node name="QuestJournal" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_script") + +[node name="Bg" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Title" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 0 +offset_left = 44.0 +offset_top = 28.0 +offset_right = 400.0 +offset_bottom = 64.0 +mouse_filter = 2 + +[node name="CloseButton" type="Button" parent="."] +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -46.0 +offset_top = 28.0 +offset_right = -8.0 +offset_bottom = 66.0 +grow_horizontal = 0 +focus_mode = 0 +text = "✕" + +[node name="BookArea" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 40.0 +offset_top = 84.0 +offset_right = -40.0 +offset_bottom = -40.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Book" type="HBoxContainer" parent="BookArea"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 + +[node name="LeftPage" type="Panel" parent="BookArea/Book"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_stretch_ratio = 1.0 +mouse_filter = 1 + +[node name="PageGradient" type="TextureRect" parent="BookArea/Book/LeftPage"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +expand_mode = 1 +stretch_mode = 6 + +[node name="InnerBorder" type="Panel" parent="BookArea/Book/LeftPage"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 14.0 +offset_top = 14.0 +offset_right = -14.0 +offset_bottom = -14.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Content" type="MarginContainer" parent="BookArea/Book/LeftPage"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 36 +theme_override_constants/margin_top = 32 +theme_override_constants/margin_right = 30 +theme_override_constants/margin_bottom = 28 + +[node name="Column" type="VBoxContainer" parent="BookArea/Book/LeftPage/Content"] +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="FilterTabs" type="HBoxContainer" parent="BookArea/Book/LeftPage/Content/Column"] +custom_minimum_size = Vector2(0, 34) +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="ActiveFilterBtn" type="Button" parent="BookArea/Book/LeftPage/Content/Column/FilterTabs"] +custom_minimum_size = Vector2(0, 34) +layout_mode = 2 +focus_mode = 0 + +[node name="CompletedFilterBtn" type="Button" parent="BookArea/Book/LeftPage/Content/Column/FilterTabs"] +custom_minimum_size = Vector2(0, 34) +layout_mode = 2 +focus_mode = 0 + +[node name="TabsSpacer" type="Control" parent="BookArea/Book/LeftPage/Content/Column/FilterTabs"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 2 + +[node name="TocHeader" type="Label" parent="BookArea/Book/LeftPage/Content/Column"] +layout_mode = 2 +theme_override_font_sizes/font_size = 14 + +[node name="EmptyLabel" type="Label" parent="BookArea/Book/LeftPage/Content/Column"] +visible = false +layout_mode = 2 +theme_override_font_sizes/font_size = 14 +horizontal_alignment = 1 + +[node name="Scroll" type="ScrollContainer" parent="BookArea/Book/LeftPage/Content/Column"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="QuestListContainer" type="VBoxContainer" parent="BookArea/Book/LeftPage/Content/Column/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 2 + +[node name="Spine" type="Panel" parent="BookArea/Book"] +custom_minimum_size = Vector2(56, 0) +layout_mode = 2 +size_flags_vertical = 3 + +[node name="SpineShade" type="TextureRect" parent="BookArea/Book/Spine"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +expand_mode = 1 +stretch_mode = 6 + +[node name="RightPage" type="Panel" parent="BookArea/Book"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_stretch_ratio = 1.0 +mouse_filter = 1 + +[node name="PageGradient" type="TextureRect" parent="BookArea/Book/RightPage"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +expand_mode = 1 +stretch_mode = 6 + +[node name="InnerBorder" type="Panel" parent="BookArea/Book/RightPage"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 14.0 +offset_top = 14.0 +offset_right = -14.0 +offset_bottom = -14.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Content" type="MarginContainer" parent="BookArea/Book/RightPage"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 44 +theme_override_constants/margin_top = 80 +theme_override_constants/margin_right = 38 +theme_override_constants/margin_bottom = 28 + +[node name="Scroll" type="ScrollContainer" parent="BookArea/Book/RightPage/Content"] +layout_mode = 2 +horizontal_scroll_mode = 0 + +[node name="Column" type="VBoxContainer" parent="BookArea/Book/RightPage/Content/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="EntryHeader" type="Label" parent="BookArea/Book/RightPage/Content/Scroll/Column"] +layout_mode = 2 +theme_override_font_sizes/font_size = 14 + +[node name="NameMargin" type="MarginContainer" parent="BookArea/Book/RightPage/Content/Scroll/Column"] +layout_mode = 2 +theme_override_constants/margin_top = 14 +theme_override_constants/margin_bottom = 6 + +[node name="QuestNameLabel" type="Label" parent="BookArea/Book/RightPage/Content/Scroll/Column/NameMargin"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +autowrap_mode = 3 + +[node name="StageLabel" type="Label" parent="BookArea/Book/RightPage/Content/Scroll/Column"] +layout_mode = 2 +theme_override_font_sizes/font_size = 13 + +[node name="Divider" type="MarginContainer" parent="BookArea/Book/RightPage/Content/Scroll/Column"] +layout_mode = 2 +theme_override_constants/margin_top = 18 +theme_override_constants/margin_bottom = 20 + +[node name="Row" type="HBoxContainer" parent="BookArea/Book/RightPage/Content/Scroll/Column/Divider"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="LineL" type="TextureRect" parent="BookArea/Book/RightPage/Content/Scroll/Column/Divider/Row"] +custom_minimum_size = Vector2(30, 1) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 4 +mouse_filter = 2 +expand_mode = 1 +stretch_mode = 6 + +[node name="Diamond" type="Panel" parent="BookArea/Book/RightPage/Content/Scroll/Column/Divider/Row"] +custom_minimum_size = Vector2(9, 9) +layout_mode = 2 +size_flags_vertical = 4 +rotation = 0.7854 +pivot_offset = Vector2(4.5, 4.5) +mouse_filter = 2 + +[node name="LineR" type="TextureRect" parent="BookArea/Book/RightPage/Content/Scroll/Column/Divider/Row"] +custom_minimum_size = Vector2(30, 1) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 4 +mouse_filter = 2 +expand_mode = 1 +stretch_mode = 6 + +[node name="EntryText" type="RichTextLabel" parent="BookArea/Book/RightPage/Content/Scroll/Column"] +layout_mode = 2 +size_flags_horizontal = 3 +bbcode_enabled = true +fit_content = true +scroll_active = false +selection_enabled = false +autowrap_mode = 3 +theme_override_font_sizes/normal_font_size = 16 +theme_override_constants/line_separation = 7 + +[node name="SealsBlock" type="MarginContainer" parent="BookArea/Book/RightPage/Content/Scroll/Column"] +visible = false +layout_mode = 2 +theme_override_constants/margin_top = 28 + +[node name="Column" type="VBoxContainer" parent="BookArea/Book/RightPage/Content/Scroll/Column/SealsBlock"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="SealsHeader" type="Label" parent="BookArea/Book/RightPage/Content/Scroll/Column/SealsBlock/Column"] +layout_mode = 2 +theme_override_font_sizes/font_size = 14 + +[node name="SealsContainer" type="HFlowContainer" parent="BookArea/Book/RightPage/Content/Scroll/Column/SealsBlock/Column"] +layout_mode = 2 +theme_override_constants/h_separation = 10 +theme_override_constants/v_separation = 10 diff --git a/src/Actions/Atomic/QuestAddTagAction.cs b/src/Actions/Atomic/QuestAddTagAction.cs new file mode 100644 index 0000000..ffff436 --- /dev/null +++ b/src/Actions/Atomic/QuestAddTagAction.cs @@ -0,0 +1,43 @@ +namespace Cthangover.Core.Actions.Atomic +{ + + /// + /// Adds a string tag to a quest. Tags are used for conditional logic in + /// scenario scripts — a quest can be checked for tag presence to branch + /// dialog. The tag is added via QuestBase.AddTag, which likely stores it + /// in the quest's save data. + /// + public class QuestAddTagAction : IScenarioAction + { + /// + /// Registered as "quest.add_tag" — attaches a string tag to a + /// quest for use in conditional logic. Tags are queried by the + /// scenario DSL's "has_tag" condition to branch dialog or gate + /// quest progression. Both "quest_id" and "tag" dialog variables + /// are required — returns early with a warning if either is + /// missing. + /// + public string Name => "quest.add_tag"; + + /// + /// Reads "quest_id" and "tag" from dialog variables and delegates + /// to ctx.Quests.AddTag. Routes through QuestServiceImpl.TryGet — + /// missing quests are silently skipped. Tags are persisted with + /// save data and survive game restart. + /// + public void Run(IActionContext ctx) + { + var questId = ctx.GetParam("quest_id"); + var tag = ctx.GetParam("tag"); + + if (string.IsNullOrEmpty(questId) || string.IsNullOrEmpty(tag)) + { + ctx.Log("EVENT", "QuestAddTagAction: missing 'quest_id' or 'tag' variable"); + return; + } + + ctx.Quests.AddTag(questId, tag); + ctx.Log("QUEST", $"QuestAddTagAction: {questId}.AddTag('{tag}')"); + } + } +} diff --git a/src/Actions/Atomic/QuestRemoveTagAction.cs b/src/Actions/Atomic/QuestRemoveTagAction.cs new file mode 100644 index 0000000..80bfb03 --- /dev/null +++ b/src/Actions/Atomic/QuestRemoveTagAction.cs @@ -0,0 +1,41 @@ +namespace Cthangover.Core.Actions.Atomic +{ + + /// + /// Removes a string tag from a quest. Paired with QuestAddTagAction for + /// reversible quest state. Used in branching scenarios where a tag marks + /// a temporary condition that should be cleared when resolved. + /// + public class QuestRemoveTagAction : IScenarioAction + { + /// + /// Registered as "quest.remove_tag" — the inverse of + /// QuestAddTagAction. Removes a tag from a quest to clear + /// temporary conditions (e.g. removing a "witnessed_event" tag + /// after the event has been resolved). Both "quest_id" and "tag" + /// are required. + /// + public string Name => "quest.remove_tag"; + + /// + /// Reads "quest_id" and "tag" from dialog variables and delegates + /// to ctx.Quests.RemoveTag. Removing a non-existent tag is a + /// no-op at the collection level. Missing quests are silently + /// skipped via TryGet. + /// + public void Run(IActionContext ctx) + { + var questId = ctx.GetParam("quest_id"); + var tag = ctx.GetParam("tag"); + + if (string.IsNullOrEmpty(questId) || string.IsNullOrEmpty(tag)) + { + ctx.Log("EVENT", "QuestRemoveTagAction: missing 'quest_id' or 'tag' variable"); + return; + } + + ctx.Quests.RemoveTag(questId, tag); + ctx.Log("QUEST", $"QuestRemoveTagAction: {questId}.RemoveTag('{tag}')"); + } + } +} diff --git a/src/Actions/Atomic/QuestSendNotificationAction.cs b/src/Actions/Atomic/QuestSendNotificationAction.cs new file mode 100644 index 0000000..757388d --- /dev/null +++ b/src/Actions/Atomic/QuestSendNotificationAction.cs @@ -0,0 +1,43 @@ +namespace Cthangover.Core.Actions.Atomic +{ + + /// + /// Triggers a UI notification for a quest (e.g. "Quest Updated" popup). + /// This is the visual feedback companion to quest state changes — the + /// notification itself doesn't change quest state, it just informs the + /// player that something happened. Call this after SetStatus/SetDataStatus + /// to make the UI react. + /// + public class QuestSendNotificationAction : IScenarioAction + { + /// + /// Registered as "quest.send_notification" — triggers a UI popup + /// for a quest (e.g. "Quest Updated" or "New Quest"). This is the + /// visual companion to state changes — call after SetStatus or + /// SetDataStatus so the player sees the update. The notification + /// itself does not modify quest state. + /// + public string Name => "quest.send_notification"; + + /// + /// Reads the "quest_id" variable and delegates to + /// ctx.Quests.SendNotification. The notification content is + /// derived from the quest's current state (title, description, + /// status) — ensure the quest state is updated before calling + /// this. Missing quests are silently skipped via TryGet. + /// + public void Run(IActionContext ctx) + { + var questId = ctx.GetParam("quest_id"); + + if (string.IsNullOrEmpty(questId)) + { + ctx.Log("EVENT", "QuestSendNotificationAction: missing 'quest_id' variable"); + return; + } + + ctx.Quests.SendNotification(questId); + ctx.Log("QUEST", $"QuestSendNotificationAction: sent notification for '{questId}'"); + } + } +} diff --git a/src/Actions/Atomic/QuestSetDataStatusAction.cs b/src/Actions/Atomic/QuestSetDataStatusAction.cs new file mode 100644 index 0000000..a5554b3 --- /dev/null +++ b/src/Actions/Atomic/QuestSetDataStatusAction.cs @@ -0,0 +1,47 @@ +namespace Cthangover.Core.Actions.Atomic +{ + + /// + /// Sets the numeric progress level within a quest (quest.Data.Status). + /// Unlike QuestSetStatusAction which changes the global quest state + /// (Active/Completed), this handles incremental progress — e.g. "kill 3/5 + /// enemies". The "level" param is parsed as int (no fallback) so scenario + /// authors must ensure the value is always a valid integer. + /// + public class QuestSetDataStatusAction : IScenarioAction + { + /// + /// Registered as "quest.set_data_status" — sets the numeric + /// progress level within a quest (quest.Data.Status). This is the + /// incremental counter (e.g. "2/5 wolves killed"), separate from + /// the global quest state managed by QuestSetStatusAction. The + /// "level" dialog variable is parsed as int with no fallback — + /// scenario authors must ensure it's always a valid integer. + /// + public string Name => "quest.set_data_status"; + + /// + /// Reads "quest_id" and "level" from dialog variables, parses + /// level as int, and delegates to ctx.Quests.SetDataStatus. No + /// bounds checking on the level value — the quest's data object + /// is responsible for validating the range. Missing quests are + /// silently skipped. Returns early with a warning if either + /// variable is missing. + /// + public void Run(IActionContext ctx) + { + var questId = ctx.GetParam("quest_id"); + var levelRaw = ctx.GetParam("level"); + + if (string.IsNullOrEmpty(questId) || string.IsNullOrEmpty(levelRaw)) + { + ctx.Log("EVENT", "QuestSetDataStatusAction: missing 'quest_id' or 'level' variable"); + return; + } + + var level = int.Parse(levelRaw); + ctx.Quests.SetDataStatus(questId, level); + ctx.Log("QUEST", $"QuestSetDataStatusAction: {questId}.Data.Status = {level}"); + } + } +} diff --git a/src/Actions/Atomic/QuestSetStatusAction.cs b/src/Actions/Atomic/QuestSetStatusAction.cs new file mode 100644 index 0000000..620c78a --- /dev/null +++ b/src/Actions/Atomic/QuestSetStatusAction.cs @@ -0,0 +1,44 @@ +namespace Cthangover.Core.Actions.Atomic +{ + + /// + /// Sets the global state of a quest (e.g. "Active", "Completed", "Failed"). + /// The "status" param is parsed via Enums<QuestStatus>.Parse, which is + /// case-insensitive — invalid status strings are caught by QuestServiceImpl's + /// TryGet pattern and logged as errors rather than crashing the dialog. + /// + public class QuestSetStatusAction : IScenarioAction + { + /// + /// Registered as "quest.set_status" — sets the global lifecycle + /// state of a quest (Active, Completed, Failed, etc.). The + /// "status" dialog variable is parsed via + /// Enums<QuestStatus>.Parse as a case-insensitive + /// QuestStatus enum name (NotStarted / Progress / End). Invalid + /// status strings are + /// caught by QuestServiceImpl and logged as errors without + /// crashing the dialog. + /// + public string Name => "quest.set_status"; + + /// + /// Reads "quest_id" and "status" from dialog variables and + /// delegates to ctx.Quests.SetStatus. Missing quests are silently + /// skipped. Invalid status values are logged and ignored. + /// + public void Run(IActionContext ctx) + { + var questId = ctx.GetParam("quest_id"); + var statusRaw = ctx.GetParam("status"); + + if (string.IsNullOrEmpty(questId) || string.IsNullOrEmpty(statusRaw)) + { + ctx.Log("EVENT", "QuestSetStatusAction: missing 'quest_id' or 'status' variable"); + return; + } + + ctx.Quests.SetStatus(questId, statusRaw); + ctx.Log("QUEST", $"QuestSetStatusAction: {questId}.Status = {statusRaw}"); + } + } +} diff --git a/src/Actions/QuestServiceImpl.cs b/src/Actions/QuestServiceImpl.cs new file mode 100644 index 0000000..6a799aa --- /dev/null +++ b/src/Actions/QuestServiceImpl.cs @@ -0,0 +1,151 @@ +using System; +using Cthangover.Core.Events; +using Cthangover.Core.Quests; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Actions +{ + /// + /// Quest service implementation with internal TryGet for safe fallback. + /// Public methods (SetStatus, SetDataStatus, AddTag, etc.) use TryGet which + /// catches KeyNotFoundException from QuestFactory — this prevents scenario + /// scripts from crashing when referencing non-existent quests. The separation + /// of SetStatus (global quest state like Active/Completed) from SetDataStatus + /// (numeric progress level) matches QuestBase's dual-state design. Exists() + /// returns false on any exception, so missing or broken quest data is treated + /// as "doesn't exist" rather than throwing. + /// + internal class QuestServiceImpl : IQuestService + { + /// + /// Direct lookup from QuestFactory by ID. Throws + /// KeyNotFoundException if the quest doesn't exist — use + /// Exists() or TryGet() when the quest may legitimately be + /// absent from the registry. + /// + public IQuest Get(string id) => QuestFactory.Instance.Get(id); + + /// + /// Safe existence check. Wraps QuestFactory.Get in try/catch + /// and returns false on any exception — corrupted save data or + /// broken quest definitions are treated as "doesn't exist" + /// rather than propagating errors to the dialog engine. + /// + public bool Exists(string id) + { + try { return QuestFactory.Instance.Get(id) != null; } + catch { return false; } + } + + /// + /// Internal safe-get pattern that catches KeyNotFoundException + /// from QuestFactory. Returns null for missing quests so + /// callers can use the null-conditional operator (?.) rather + /// than try/catch blocks. All mutating methods (SetStatus, + /// AddTag, etc.) route through TryGet to prevent scenario + /// scripts from crashing on broken quest references. + /// + public QuestBase TryGet(string id) + { + QuestBase result = null; + try + { + result = QuestFactory.Instance.Get(id); + } + catch(Exception ex) + { + GameLogger.Log("QUEST", $"try get quest '{id}' exception - {ex.Message}"); + } + return result; + } + + /// + /// Sets the global quest status (Active, Completed, Failed, + /// etc.) by parsing the status string via + /// Enums<QuestStatus>.Parse. Invalid status strings are + /// caught and logged as errors without throwing — the dialog + /// continues with the quest unchanged. Routes through TryGet + /// so missing quests are silently skipped. Publishes a + /// only when the status + /// actually changed; setting the same status is a no-op that + /// publishes nothing. + /// + public void SetStatus(string id, string status) + { + var quest = TryGet(id); + if(quest == null) + return; + try + { + var newStatus = Enums.Parse(status); + if (quest.Status == newStatus) + return; + var oldStatus = quest.Status.ToString(); + quest.Status = newStatus; + ModEventBus.Publish(new QuestStatusChangedEvent + { + QuestId = id, + OldStatus = oldStatus, + NewStatus = status, + }); + } + catch (Exception ex) + { + GameLogger.Log("QUEST", $"status '{status}' invalid - {ex.Message}", LogLevel.Error); + } + } + + /// + /// Updates the numeric progress level within the quest's data + /// object (quest.Data.Status). Separate from SetStatus which + /// controls the global lifecycle state. Routes through TryGet + /// with null-conditional access — if the quest doesn't exist, + /// the call is silently skipped. No bounds checking: the caller + /// is responsible for meaningful level values. The + /// is published by + /// itself. + /// + public void SetDataStatus(string id, int level) + { + TryGet(id)?.Data.SetStatus(level); + } + + /// + /// Attaches a string tag to the quest's tag collection via + /// QuestBase.AddTag. Tags are persisted with save data and + /// queried by scenario conditions (has_tag). Adding a duplicate + /// tag is handled by the underlying collection. Routes through + /// TryGet for safe fallback on missing quests. The + /// is published by + /// itself (change-only). + /// + public void AddTag(string id, string tag) + { + TryGet(id)?.AddTag(tag); + } + + /// + /// Removes a tag from the quest. Used for clearing temporary + /// conditions in branching scenarios. Removing a non-existent + /// tag is a collection-level no-op. Routes through TryGet. The + /// is published by + /// itself (change-only). + /// + public void RemoveTag(string id, string tag) + { + TryGet(id)?.RemoveTag(tag); + } + + /// + /// Dispatches a UI notification for the quest (e.g. "Quest + /// Updated" popup) via QuestBase.SendNotification. Does not + /// modify quest state — call SetStatus/SetDataStatus before + /// this to ensure the notification reflects the new state. + /// Routes through TryGet. + /// + public void SendNotification(string id) + { + TryGet(id)?.SendNotification(); + } + } +} \ No newline at end of file diff --git a/src/Quests/IQuestExtensions.cs b/src/Quests/IQuestExtensions.cs new file mode 100644 index 0000000..3cf0591 --- /dev/null +++ b/src/Quests/IQuestExtensions.cs @@ -0,0 +1,29 @@ +using Cthangover.Core.UI.Messages; +using Cthangover.Core.Quests; +using Godot; + +using Cthangover.Mods.Interface.Messages; +namespace Cthangover.Core.Quests +{ + /// + /// Extension methods layered on top of so that + /// dialogue scripts and event handlers can trigger common side-effects + /// (such as notification banner display) without forcing every quest + /// implementation to carry UI dependencies. + /// + public static class IQuestExtensions + { + /// + /// Posts a localised "New Journal Entry" notification to the message + /// queue via . The notification text + /// prepends the translation of the "ui/journal/new" key to the + /// translated quest , producing a + /// journal‑style announcement that the in‑game HUD renders as a + /// floating banner. + /// + public static void SendNotification(this IQuest quest) + { + MessagesHelper.AddMessage(TranslationServer.Translate("ui/journal/new") + " - " + TranslationServer.Translate(quest.Name)); + } + } +} \ No newline at end of file diff --git a/src/Quests/QuestBase.cs b/src/Quests/QuestBase.cs new file mode 100644 index 0000000..aa513ce --- /dev/null +++ b/src/Quests/QuestBase.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Events; + +namespace Cthangover.Core.Quests +{ + + /// + /// Primary concrete implementation of that serves as + /// both the runtime quest instance (created by + /// from data) and the serialization unit + /// written into save files. Because it is marked [Serializable], + /// Godot's JSON serialization can round-trip the entire quest graph + /// — including the mutable payload — when the + /// player saves or loads a game. The + /// dictionary maps raw integer values to + /// localised description strings that the journal UI renders for each + /// progression stage. + /// + [Serializable] + public class QuestBase : IQuest + { + + /// + /// Unique string identifier matching the quest's definition key in + /// the mod data. Used by for lookup + /// and preserved in save files so that loaded state can be reattached + /// to the correct definition. + /// + public string ID { get; set; } + + /// + /// Localised display name shown in the quest journal and notification + /// banners. This value originates from the mod definition file and is + /// translated before display. + /// + public string Name { get; set; } + + /// + /// Current progression state of the quest. Defaults to + /// . This property is updated at + /// runtime by quest‑advancement logic and restored from save data + /// by . + /// + public QuestStatus Status { get; set; } = QuestStatus.NotStarted; + + /// + /// Mutable runtime payload holding the quest's numeric status and + /// the collection of string tags accumulated during gameplay. The + /// factory overwrites this object when restoring a save file via + /// , so any modifications to tags + /// or status through , , + /// or persist until the next load. + /// + public QuestData Data { get; set; } = new QuestData(); + + /// + /// Lookup table that maps integer values to + /// human‑readable description strings. The journal window uses this + /// to select which narrative text to display underneath the quest + /// title as the player advances through NotStarted, + /// Progress, and End states. + /// + public IDictionary StatusToDescription { get; set; } = new Dictionary(); + + /// + /// Returns true when is present in the + /// underlying . set. + /// Tags act as boolean flags for sub‑objective completion; dialogue + /// conditions and scene‑transition gates call this to decide whether + /// a particular branch should be available. + /// + public bool ContainsTag(string tag) + { + return Data.Tags.Contains(tag); + } + + /// + /// Syntactic negation helper that returns true when + /// is absent from the quest's tag set. + /// Exists so that scenario scripts can express "tag not yet earned" + /// conditions as a direct method call without writing a negation + /// operator in YAML condition strings. + /// + public bool NotContainsTag(string tag) + { + return !ContainsTag(tag); + } + + /// + /// Inserts into the mutable + /// . set, marking a + /// sub‑objective or milestone as completed. Typically called from + /// scenario action nodes or dialogue event handlers when the player + /// reaches a logical checkpoint. Publishes a + /// only when the tag was actually + /// added (duplicates are a no-op and publish nothing), so condition + /// caches invalidate exactly on real change. + /// + public void AddTag(string tag) + { + if (Data.Tags.Add(tag)) + ModEventBus.Publish(new QuestTagChangedEvent { QuestId = ID, Tag = tag, Added = true }); + } + + /// + /// Removes from the mutable + /// . set, effectively + /// revoking a previously earned milestone. Useful for branching + /// narratives where the player can lose access to a completed + /// sub‑objective through a different choice path. Publishes a + /// only when the tag was actually + /// present and removed. + /// + public void RemoveTag(string tag) + { + if (Data.Tags.Remove(tag)) + ModEventBus.Publish(new QuestTagChangedEvent { QuestId = ID, Tag = tag, Added = false }); + } + + } + +} \ No newline at end of file diff --git a/src/Quests/QuestData.cs b/src/Quests/QuestData.cs new file mode 100644 index 0000000..e6d223f --- /dev/null +++ b/src/Quests/QuestData.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Events; + +namespace Cthangover.Core.Quests +{ + /// + /// Serialisable payload that holds the mutable, per‑save‑file runtime + /// state of a single quest. Separating this from the immutable + /// allows the save system to persist only + /// the dirty data (current status and earned tags) without duplicating + /// the static definition fields that are reloaded from mod JSON every + /// session. An instance of this class lives inside + /// and is replaced wholesale by + /// when the player loads a game. + /// + [Serializable] + public class QuestData + { + /// + /// Numeric representation of the current quest stage, castable to + /// . The journal UI maps this to a + /// description string via . + /// + public int Status { get; set; } + + /// + /// Set of string tags earned during this playthrough. Each tag + /// represents a branching milestone or sub‑objective; scenario + /// scripts query membership via to + /// gate dialogue options and scene transitions. + /// + public HashSet Tags { get; set; } = new(); + + /// + /// Sets the numeric status to , advancing + /// (or potentially resetting) the quest's progression stage. Called + /// by scenario action nodes that handle quest advancement commands. + /// Publishes a only when the + /// value actually changed, so cached condition results invalidate on + /// real change rather than on every redundant assignment. + /// + public void SetStatus(int value) + { + if (Status == value) + return; + ModEventBus.Publish(new QuestDataStatusChangedEvent { Old = Status, New = value }); + Status = value; + } + } +} \ No newline at end of file diff --git a/src/Quests/QuestDefinition.cs b/src/Quests/QuestDefinition.cs new file mode 100644 index 0000000..f27bbc3 --- /dev/null +++ b/src/Quests/QuestDefinition.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using Cthangover.Core.Factories; + +namespace Cthangover.Core.Quests +{ + /// + /// Immutable data template loaded from a mod JSON file whose envelope + /// declares the quest kind. Each definition carries the quest's + /// identity, display name, and a mapping from + /// integer values to localised description + /// strings. reads these definitions once on + /// initialisation and uses them to seed runtime + /// instances. The definition itself never changes during gameplay; all + /// mutable progression lives in . + /// Implements so the factory can store + /// quest definitions in a keyed collection. + /// + [EntityKind("quest")] + public class QuestDefinition : IIdentifiable + { + /// + /// Unique string key used both as the JSON filename (minus + /// extension) in the mod archive and as the lookup key inside + /// 's internal dictionary. + /// + public string ID { get; set; } + + /// + /// Display name of the quest as written in the mod definition; + /// passed through TranslationServer.Translate before being + /// shown in the journal or notification banners. + /// + public string Name { get; set; } + + /// + /// Dictionary mapping integer status codes to localised description + /// strings. During initialisation copies + /// this dictionary into the corresponding + /// property of the + /// runtime quest instance. + /// + public Dictionary StatusToDescription { get; set; } + } +} \ No newline at end of file diff --git a/src/Quests/QuestFactory.cs b/src/Quests/QuestFactory.cs new file mode 100644 index 0000000..df7b6c4 --- /dev/null +++ b/src/Quests/QuestFactory.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Events; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Quests +{ + + /// + /// Thread‑safe singleton that manages the entire lifecycle of + /// instances: lazy initialisation from mod JSON + /// definitions, on‑demand lookup by ID, and restoration of mutable state + /// when the player loads a save file. The factory defers loading until + /// the first access (via or ) and + /// guards against re‑initialisation through the isLoaded flag. + /// Because quest definitions come from + /// objects collected by through the + /// DTO's [EntityKind("quest")] attribute, the factory remains + /// decoupled from any directory layout — new mods register quests simply + /// by shipping a JSON file whose envelope declares the quest kind. + /// + /// The single instance is process-wide and deliberately not owned by + /// : quest state is identity-sensitive across the + /// whole process (save/restore, scenario conditions), and a swappable owner + /// would break parallel test runs. The new-game reset is wired through the + /// AppServices resettable adapter registered by QuestsModInitializer + /// (see ). + /// + public class QuestFactory + { + + /// + /// Owns the process-wide instance; laziness defers construction to the + /// first access, avoiding order-dependent initialisation races with + /// other subsystems. + /// + private static Lazy instance = new(() => new QuestFactory()); + + /// + /// Process-wide singleton. Deliberately NOT owned by AppServices: quest + /// state is identity-sensitive across the whole process (save/restore, + /// scenario conditions), and a swappable owner breaks that contract in + /// parallel test runs. Reset goes through the AppServices resettable + /// adapter, construction timing stays lazy. + /// + public static QuestFactory Instance => instance.Value; + + private readonly IDictionary dataByID = new Dictionary(); + private bool isLoaded; + + private QuestFactory() + { + } + + /// + /// Seeds the internal dataByID dictionary from mod JSON if it + /// has not been seeded yet. When is still + /// mid‑initialisation, the call is silently deferred (logged at + /// debug level) so that early code paths don't crash; the next + /// access will retry. Each is + /// converted into a fresh with default + /// status and an empty tag set. + /// + private void EnsureLoaded() + { + if (isLoaded) + return; + + if (!ModManager.Instance.IsInitialized) + { + GameLogger.Log("QUEST", "mods not initialized, quest loading deferred"); + return; + } + + isLoaded = true; + + try + { + var definitions = Entities.Collect(); + if (definitions != null) + { + foreach (var (id, def) in definitions) + { + var quest = new QuestBase + { + ID = def.ID, + Name = def.Name, + StatusToDescription = def.StatusToDescription ?? new Dictionary(), + }; + dataByID.Add(quest.ID, quest); + } + } + } + catch (Exception ex) + { + GameLogger.Log("QUEST", "failed to load quests from mods: " + ex.Message, LogLevel.Error); + } + + GameLogger.Log("QUEST", "loaded '" + dataByID.Count + "' quests"); + } + + /// + /// Looks up a quest by its unique string identifier and returns the + /// shared runtime instance. Throws + /// KeyNotFoundException when no definition for + /// was loaded from mod data, which typically + /// indicates a typo in a scenario script's quest reference. + /// + public QuestBase Get(string id) + { + EnsureLoaded(); + + if(dataByID.TryGetValue(id, out var quest)) + return quest; + + GameLogger.Log("QUEST", "quest '" + id + "' not registered", LogLevel.Error); + throw new KeyNotFoundException(id); + } + + /// + /// Returns a snapshot list of every instance + /// known to the factory. Used primarily by the save system to + /// serialise the full quest state for the save file, and by the + /// journal UI to populate the quest list window. + /// + public List GetAll() + { + EnsureLoaded(); + + return dataByID.Values.ToList(); + } + + /// + /// Restores mutable quest state from a deserialised save file. + /// Iterates over every quest in the provided list, looks up the + /// corresponding entry by ID, and overwrites its + /// and + /// properties. Entries whose ID is not registered (a removed mod or a + /// quest renamed between sessions) are skipped with a warning so that + /// an ageing save cannot block loading. Reads from empty or null + /// lists are silently ignored so that save files created before the + /// quest system existed (or with no quest progress) do not break the + /// pipeline. Publishes a single + /// marker after the restore loop (not per-quest) so condition caches + /// invalidate once without a per-quest event storm. + /// + public void SetAll(List quests) + { + EnsureLoaded(); + + if(Lists.IsEmpty(quests)) + return; + foreach (var questFromSave in quests) + { + if(!dataByID.TryGetValue(questFromSave.ID, out var quest)) + { + GameLogger.Log("QUEST", "quest '" + questFromSave.ID + "' from save is not registered, skipped", LogLevel.Warning); + continue; + } + quest.Data = questFromSave.Data; + quest.Status = questFromSave.Status; + } + + ModEventBus.Publish(new QuestStateRestoredEvent()); + } + + /// + /// Returns every quest to the state it has right after mod loading: + /// , numeric stage zero, and an + /// empty tag set. Called by GameData.StartNewGame because the + /// factory is a process‑wide singleton that survives returning to the + /// main menu — without this reset, tags and statuses earned in the + /// previous playthrough would leak into a new game and satisfy (or + /// block) prologue conditions that test for absent tags. Publishes + /// so cached scenario conditions + /// invalidate once. + /// + public void ResetAll() + { + EnsureLoaded(); + + foreach (var quest in dataByID.Values) + { + quest.Status = QuestStatus.NotStarted; + quest.Data = new QuestData(); + } + + ModEventBus.Publish(new QuestStateRestoredEvent()); + } + + } + +} \ No newline at end of file diff --git a/src/Quests/QuestTagLocale.cs b/src/Quests/QuestTagLocale.cs new file mode 100644 index 0000000..bedccd8 --- /dev/null +++ b/src/Quests/QuestTagLocale.cs @@ -0,0 +1,62 @@ +using Cthangover.Core.Localization; + +namespace Cthangover.Core.Quests +{ + /// + /// Maps quest tags to locale keys and resolves their display text. A bare + /// tag id is only unique inside its quest — the same word may mean + /// different milestones in different quests — so the key is composed from + /// the quest's locale prefix () and the tag: + /// {quest.Name}/tag/{tag}. For + /// MeetingMurakamiQuest (Name = quest/meeting/murakami) the + /// tag go_home maps to quest/meeting/murakami/tag/go_home, + /// sharing the namespace of the quest's own name and stage descriptions. + /// The journal renders every earned tag as a stamp through + /// ; when no translation exists the + /// raw tag id is shown, which is the behaviour tags had before locale + /// support was added. + /// + public static class QuestTagLocale + { + /// + /// Builds the composite locale key for inside + /// : {quest.Name}/tag/{tag}. Returns + /// null when the quest is missing, has no locale prefix, or the + /// tag is empty, so callers can distinguish "no key to look up" from + /// "key not translated" and fall back to the raw tag. + /// + public static string Key(IQuest quest, string tag) + { + if (quest == null || string.IsNullOrEmpty(quest.Name) || string.IsNullOrEmpty(tag)) + return null; + + return quest.Name + "/tag/" + tag; + } + + /// + /// Returns the display text of in the player's + /// current language, falling back to the raw tag id when the composite + /// key is absent from all loaded mods' properties files. + /// + public static string Translate(IQuest quest, string tag) + { + return Translate(quest, tag, GodotTranslationProvider.Instance); + } + + /// + /// Provider-injected variant of + /// for tests and for callers that already hold an + /// . A provider that returns + /// null for the composite key (the "untranslated" signal of + /// ) also yields the raw tag. + /// + public static string Translate(IQuest quest, string tag, ILocalizationProvider provider) + { + var key = Key(quest, tag); + if (key == null || provider == null) + return tag; + + return provider.Get(key) ?? tag; + } + } +} \ No newline at end of file diff --git a/src/QuestsModInitializer.cs b/src/QuestsModInitializer.cs new file mode 100644 index 0000000..eda8c61 --- /dev/null +++ b/src/QuestsModInitializer.cs @@ -0,0 +1,77 @@ +using Cthangover.Core.Actions; +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Mods; +using Cthangover.Core.Quests; +using Cthangover.Core.Scenes.Parsing; +using Cthangover.Core.Scenes.Parsing.Impls; +using Cthangover.Core.Utils; + +namespace Cthangover.Mods.Quests +{ + /// + /// Engine-mod initializer for the quests subsystem. Publishes the services + /// living in this assembly to the kernel contracts as soon as the assembly + /// is registered, so core-side consumers — dialog action contexts + /// (ctx.Quests) and scenario condition evaluation — work through the + /// core registry without referencing a runtime-compiled mod assembly. + /// + /// Three publications happen here: + /// + /// — the + /// implementation used by + /// ScenarioActionContext and GDQuestService; + /// — the quest leaf + /// parser for scene conditions (QuestId.method(...) / + /// QuestId.Property op n); the parser moved out of the core + /// registry so that the parser set is fully extensible; + /// the new-game resettable (id quests) so + /// GameData.StartNewGame clears quest state through the common + /// AppServices loop (the core used to register this lambda itself). + /// + /// Registration happens in (the earliest and most + /// frequent initializer callback) rather than on resources-ready, so any + /// mod or scenario evaluated later already resolves quests. Registration is + /// guarded and idempotent because ModManager.Reload() re-runs + /// for every mod — the guard prevents duplicate + /// parsers and provider overwrites; the resettable registration is + /// deduplicated by AppServices itself. + /// + public class QuestsModInitializer : IModInitializer + { + private static bool registered; + + /// Publishes the service provider, leaf parser and reset owner; safe to call again after a mod reload. + public void OnModLoaded(string modId) + { + if (registered) + return; + registered = true; + + QuestServiceRegistry.Provider = new QuestServiceImpl(); + LeafParserRegistry.RegisterFirst(new QuestParser()); + + GameLogger.Log("MODS", "QuestsModInitializer: quest service provider and QuestParser registered"); + } + + /// Registers the new-game quest reset owner with AppServices (deduplicated by id). + public void OnModResourcesReady() + { + AppServices.Current.RegisterResettable(new QuestsResettable()); + } + } + + /// + /// New-game state owner of the quests mod: returns every quest to its + /// fresh state (). Registered from + /// ; AppServices + /// deduplicates by , so repeated registration is a no-op. + /// + internal sealed class QuestsResettable : IResettable + { + /// Resettable key under which AppServices deduplicates registration. + public string Id => "quests"; + + /// Resets all quest statuses, stages and tags on "New Game". + public void ResetForNewGame() => QuestFactory.Instance.ResetAll(); + } +} diff --git a/src/QuestsSaveable.cs b/src/QuestsSaveable.cs new file mode 100644 index 0000000..4b770fc --- /dev/null +++ b/src/QuestsSaveable.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using Cthangover.Core.Mods; +using Cthangover.Core.Settings; + +namespace Cthangover.Core.Quests +{ + /// + /// Persists quest state through the generic + /// contract under the mod id quests_base. The core save used to + /// carry a "Quests" blob inside the __core__ slot + /// (RuntimeSerializer.BuildCoreSlot), but quest state now belongs to + /// the quests engine mod — so it saves through the mod slot like every + /// other migrated subsystem. + /// + /// feeds the deserialised list into + /// , which restores mutable state per + /// quest ID, skips quests that are no longer registered and publishes the + /// single QuestStateRestoredEvent invalidation marker — exactly the + /// behaviour the core save path had. Backward compatibility with the old + /// core blob is intentionally not provided (same decision as the + /// recruiting mod): old saves start with fresh quest state. + /// + public class QuestsSaveable : IModSaveable + { + private const string BlobKey = "Quests"; + + /// Writes the full quest state list into the mod slot. + public void OnSave(ModSlot slot) + { + slot.Data[BlobKey] = new DataBlob(1, QuestFactory.Instance.GetAll()); + } + + /// Restores quest state from the mod slot; an absent blob keeps fresh quest state. + public bool OnLoad(ModSlot slot, out string errorMessage) + { + errorMessage = null; + + if (slot?.Data != null && slot.Data.TryGetValue(BlobKey, out var blob)) + QuestFactory.Instance.SetAll(blob.ReadJson>()); + + return true; + } + } +} diff --git a/src/Scenes/Parsing/Impls/QuestParser.cs b/src/Scenes/Parsing/Impls/QuestParser.cs new file mode 100644 index 0000000..187998f --- /dev/null +++ b/src/Scenes/Parsing/Impls/QuestParser.cs @@ -0,0 +1,69 @@ +using System; + +namespace Cthangover.Core.Scenes.Parsing.Impls +{ + /// + /// Parses quest expressions: QuestId.method("arg") and + /// QuestId.Property comparison. + /// + /// Matches when the stream contains an identifier followed by a dot + /// that is NOT true. or false. (those are quest IDs, + /// not boolean literals followed by quest access — handled here because + /// already rejected them). + /// + public class QuestParser : ILeafParser + { + /// True for identifiers followed by a dot (dot-forms that survived the boolean-literal check are quest references). + public bool CanParse(TokenPeeker peeker) + { + if (peeker.AtEnd || peeker.Peek.Type != ConditionParser.TokenType.Ident) + return false; + + var next = peeker.PeekAhead(1); + return next.Type == ConditionParser.TokenType.Dot; + } + + /// Dispatches on the member after the dot: hasTag/notHasTag, IsActive, or Status/DataStatus comparisons; unknown members throw. + public ICondition Parse(TokenPeeker peeker, Func parseSubExpr) + { + var questId = peeker.Advance().Value; + peeker.Expect(ConditionParser.TokenType.Dot, "expected '.' after quest id"); + + var methodToken = peeker.Expect(ConditionParser.TokenType.Ident, "expected method or property name after '.'"); + var method = methodToken.Value; + + if (method == "hasTag" || method == "notHasTag") + { + peeker.Expect(ConditionParser.TokenType.LParen, "expected '('"); + var tag = peeker.Expect(ConditionParser.TokenType.String, "expected string argument"); + peeker.Expect(ConditionParser.TokenType.RParen, "expected ')'"); + return new QuestMethodCondition(questId, method, tag.Value); + } + + if (method == "IsActive") + { + return new QuestBoolPropCondition(questId, method); + } + + if (method == "Status" || method == "DataStatus") + { + var op = ParseCompOp(peeker); + var num = peeker.Expect(ConditionParser.TokenType.Number, "expected number after comparison"); + return new QuestPropCondition(questId, method, op, int.Parse(num.Value)); + } + + throw new FormatException($"Condition parse error: unknown quest property/method '{method}'"); + } + + private static string ParseCompOp(TokenPeeker peeker) + { + if (peeker.Check(ConditionParser.TokenType.Eq)) { peeker.Advance(); return "=="; } + if (peeker.Check(ConditionParser.TokenType.Neq)) { peeker.Advance(); return "!="; } + if (peeker.Check(ConditionParser.TokenType.Ge)) { peeker.Advance(); return ">="; } + if (peeker.Check(ConditionParser.TokenType.Le)) { peeker.Advance(); return "<="; } + if (peeker.Check(ConditionParser.TokenType.Gt)) { peeker.Advance(); return ">"; } + if (peeker.Check(ConditionParser.TokenType.Lt)) { peeker.Advance(); return "<"; } + throw new FormatException($"Condition parse error: expected comparison operator, got '{peeker.Peek.Value}'"); + } + } +} \ No newline at end of file diff --git a/src/Scenes/QuestBoolPropCondition.cs b/src/Scenes/QuestBoolPropCondition.cs new file mode 100644 index 0000000..9497e89 --- /dev/null +++ b/src/Scenes/QuestBoolPropCondition.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Cthangover.Core.Quests; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Scenes +{ + /// + /// An AST leaf node that checks a boolean property on a quest instance. + /// Currently the only supported property is IsActive, which returns + /// true when the quest status equals . + /// Fetches the quest via and + /// returns false (with a log warning) when the quest ID is not found. + /// + public class QuestBoolPropCondition : ConditionNode + { + /// The quest identifier to query. + public string QuestId { get; } + + /// The property name: currently only "IsActive". + public string Property { get; } + + /// Creates a boolean quest-property condition node. + /// The quest identifier. + /// The property name (currently only "IsActive"). + public QuestBoolPropCondition(string questId, string property) + { + QuestId = questId; + Property = property; + } + + /// Resolves the quest and reads the boolean property; unknown quest IDs evaluate to false. + public override bool Evaluate(EvaluationContext ctx) + { + try + { + var quest = ctx.QuestFactory.Get(QuestId); + if (Property == "IsActive") + return quest.Status == QuestStatus.Progress; + return false; + } + catch (KeyNotFoundException) + { + GameLogger.Log("COND", $"quest '{QuestId}' not found, condition → false", LogLevel.Error); + return false; + } + } + } +} \ No newline at end of file diff --git a/src/Scenes/QuestMethodCondition.cs b/src/Scenes/QuestMethodCondition.cs new file mode 100644 index 0000000..5e71c4d --- /dev/null +++ b/src/Scenes/QuestMethodCondition.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Scenes +{ + /// + /// An AST leaf node invoking a boolean method on a quest instance: either + /// hasTag or notHasTag. Fetches the quest via + /// at evaluation time and calls + /// the corresponding method with the given tag string. Returns false + /// (with a log warning) when the quest ID is not found in the factory. + /// + public class QuestMethodCondition : ConditionNode + { + /// The quest identifier to query. + public string QuestId { get; } + + /// The method name: "hasTag" or "notHasTag". + public string Method { get; } + + /// The tag string passed as the method argument. + public string Tag { get; } + + /// + /// Creates a quest method invocation condition node. + /// + /// The quest identifier. + /// The method name ("hasTag" or "notHasTag"). + /// The tag argument for the method call. + public QuestMethodCondition(string questId, string method, string tag) + { + QuestId = questId; + Method = method; + Tag = tag; + } + + /// Resolves the quest and invokes the tag check; unknown quest IDs evaluate to false. + public override bool Evaluate(EvaluationContext ctx) + { + try + { + var quest = ctx.QuestFactory.Get(QuestId); + return Method == "hasTag" + ? quest.ContainsTag(Tag) + : !quest.ContainsTag(Tag); + } + catch (KeyNotFoundException) + { + GameLogger.Log("COND", $"quest '{QuestId}' not found, condition → false", LogLevel.Error); + return false; + } + } + } +} \ No newline at end of file diff --git a/src/Scenes/QuestPropCondition.cs b/src/Scenes/QuestPropCondition.cs new file mode 100644 index 0000000..d428d28 --- /dev/null +++ b/src/Scenes/QuestPropCondition.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using Cthangover.Core.Quests; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Scenes +{ + /// + /// An AST leaf node comparing a quest's integer property (Status or + /// DataStatus) against a numeric value. Fetches the quest via + /// at evaluation time, reads the + /// corresponding property, and compares using . + /// Returns false (with a log warning) when the quest ID is not found. + /// + public class QuestPropCondition : ConditionNode + { + /// The quest identifier to query. + public string QuestId { get; } + + /// The property name: "Status" or "DataStatus". + public string Property { get; } + + /// The comparison operator (==, !=, >=, etc.). + public string Op { get; } + + /// The integer value to compare the quest property against. + public int Value { get; } + + /// + /// Creates a quest property comparison condition node. + /// + /// The quest identifier. + /// The property name ("Status" or "DataStatus"). + /// The comparison operator. + /// The integer value to compare against. + public QuestPropCondition(string questId, string property, string op, int value) + { + QuestId = questId; + Property = property; + Op = op; + Value = value; + } + + /// Resolves the quest and compares the numeric property; unknown quest IDs evaluate to false. + public override bool Evaluate(EvaluationContext ctx) + { + try + { + var quest = ctx.QuestFactory.Get(QuestId); + var actual = Property == "Status" + ? (int)quest.Status + : ((QuestBase)quest).Data.Status; + return ConditionCompare.Compare(Op, actual, Value); + } + catch (KeyNotFoundException) + { + GameLogger.Log("COND", $"quest '{QuestId}' not found, condition → false", LogLevel.Error); + return false; + } + } + } +} \ No newline at end of file diff --git a/src/SubsystemRegistration.cs b/src/SubsystemRegistration.cs new file mode 100644 index 0000000..e423950 --- /dev/null +++ b/src/SubsystemRegistration.cs @@ -0,0 +1,16 @@ +namespace Cthangover.Mods.Quests +{ + /// + /// Migration marker scanned by the integration coverage test + /// (SubsystemsBridge): declares the quests subsystem as + /// migrated out of the core assembly into this engine mod. The + /// subsystem id must match the entry in + /// SubsystemsCoverageTestSetup.DeclaredTests and + /// tests/tests.manifest.json. + /// + public static class SubsystemRegistration + { + /// Subsystem id reported to the coverage test. + public static string SubsystemId => "quests"; + } +} diff --git a/src/UI/Quests/QuestJournalBehaviour.cs b/src/UI/Quests/QuestJournalBehaviour.cs new file mode 100644 index 0000000..f356d39 --- /dev/null +++ b/src/UI/Quests/QuestJournalBehaviour.cs @@ -0,0 +1,627 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Cthangover.Core.Quests; +using Cthangover.Core.UI.Theming; +using Godot; + +using Cthangover.Mods.Interface.Base.Lists; +using Cthangover.Mods.Interface.Theming; +namespace Cthangover.Core.UI.Quests +{ + /// + /// Quest journal widget styled as an open folio: a two-page spread on a dark + /// backdrop, with filter tabs tucked into the top of the left page. The left page is a + /// table of contents listing quests filtered by active + /// () or completed () + /// state; quests are never shown. The right + /// page renders the selected quest as a journal entry: entries with index less than + /// the current are struck through, the entry at the + /// current status is highlighted, and entries beyond it are hidden. For completed + /// quests every entry is struck through. Earned are + /// displayed as gold tags under the entry. All decoration (surface gradients, + /// inner borders, spine shading, tags) is generated in code because mod scenes + /// ignore theme overrides and the project ships no font or texture assets. + /// The palette is the shared graphite/gold one () so the + /// journal does not stand out from the inventory and character panels. + /// + public partial class QuestJournalBehaviour : Widget + { + private enum FilterType + { + Active, + Completed + } + + private static readonly Color InkColor = UiTokens.Ink; + private static readonly Color InkBrightColor = UiTokens.InkBright; + private static readonly Color InkDimColor = UiTokens.Muted; + private static readonly Color InkFaintColor = UiTokens.Faint; + private static readonly Color PageTopColor = UiTokens.Surface1; + private static readonly Color PageColor = UiTokens.Surface0; + private static readonly Color PageLineColor = UiTokens.LineSoft; + private static readonly Color RuleColor = UiTokens.LineFaint; + private static readonly Color CoverLineColor = UiTokens.Line; + private static readonly Color GoldColor = UiTokens.Accent; + private static readonly Color GoldGlowColor = UiTokens.AccentGlow; + private static readonly Color GoldTraceColor = UiTokens.AccentFaint; + private static readonly Color SealColor = UiTokens.AccentLine; + private static readonly Color SealTextColor = UiTokens.AccentBright; + private static readonly Color SealFillColor = UiTokens.AccentSoft; + + private const int FilterTabsHeight = 34; + private const int FilterTabsGap = 14; + + private FilterType _currentFilter = FilterType.Active; + private string _selectedQuestId; + + private VBoxContainer _questListContainer; + private RichTextLabel _entryText; + private Label _questNameLabel; + private Label _stageLabel; + private Label _emptyLabel; + private MarginContainer _sealsBlock; + private HFlowContainer _sealsContainer; + private Button _activeFilterBtn; + private Button _completedFilterBtn; + + // Scene nodes (quest_journal.tscn): the folio layout lives in the scene, + // the behaviour resolves it and applies gradient textures, styles and + // the dynamic quest data. + private Label _title; + private Button _closeButton; + private Label _tocHeader; + private Label _entryHeader; + private Label _sealsHeader; + private TextureRect _leftPageGradient; + private TextureRect _spineShade; + private TextureRect _rightPageGradient; + private Panel _diamond; + private TextureRect _dividerLineL; + private TextureRect _dividerLineR; + + protected override void OnceConstruct() + { + MouseFilter = MouseFilterEnum.Stop; + + _title = GetNode