This commit is contained in:
iisaev committed 2026-10-18 12:41:39 +03:00
commit b9b508a1d0
24 files changed
+2201

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
using Cthangover.Core.UI.Messages;
using Cthangover.Core.Quests;
using Godot;
using Cthangover.Mods.Interface.Messages;
namespace Cthangover.Core.Quests
{
/// <summary>
/// Extension methods layered on top of <see cref="IQuest"/> 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.
/// </summary>
public static class IQuestExtensions
{
/// <summary>
/// Posts a localised "New Journal Entry" notification to the message
/// queue via <see cref="MessagesHelper"/>. The notification text
/// prepends the translation of the <c>"ui/journal/new"</c> key to the
/// translated quest <see cref="IQuest.Name"/>, producing a
/// journal‑style announcement that the in‑game HUD renders as a
/// floating banner.
/// </summary>
public static void SendNotification(this IQuest quest)
{
MessagesHelper.AddMessage(TranslationServer.Translate("ui/journal/new") + " - " + TranslationServer.Translate(quest.Name));
}
}
}
+123
View File
@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Events;
namespace Cthangover.Core.Quests
{
/// <summary>
/// Primary concrete implementation of <see cref="IQuest"/> that serves as
/// both the runtime quest instance (created by <see cref="QuestFactory"/>
/// from <see cref="QuestDefinition"/> data) and the serialization unit
/// written into save files. Because it is marked <c>[Serializable]</c>,
/// Godot's JSON serialization can round-trip the entire quest graph
/// — including the mutable <see cref="Data"/> payload — when the
/// player saves or loads a game. The <see cref="StatusToDescription"/>
/// dictionary maps raw <see cref="QuestStatus"/> integer values to
/// localised description strings that the journal UI renders for each
/// progression stage.
/// </summary>
[Serializable]
public class QuestBase : IQuest
{
/// <summary>
/// Unique string identifier matching the quest's definition key in
/// the mod data. Used by <see cref="QuestFactory.Get"/> for lookup
/// and preserved in save files so that loaded state can be reattached
/// to the correct definition.
/// </summary>
public string ID { get; set; }
/// <summary>
/// Localised display name shown in the quest journal and notification
/// banners. This value originates from the mod definition file and is
/// translated before display.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Current progression state of the quest. Defaults to
/// <see cref="QuestStatus.NotStarted"/>. This property is updated at
/// runtime by quest‑advancement logic and restored from save data
/// by <see cref="QuestFactory.SetAll"/>.
/// </summary>
public QuestStatus Status { get; set; } = QuestStatus.NotStarted;
/// <summary>
/// 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
/// <see cref="QuestFactory.SetAll"/>, so any modifications to tags
/// or status through <see cref="AddTag"/>, <see cref="RemoveTag"/>,
/// or <see cref="QuestData.SetStatus"/> persist until the next load.
/// </summary>
public QuestData Data { get; set; } = new QuestData();
/// <summary>
/// Lookup table that maps integer <see cref="QuestStatus"/> 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 <c>NotStarted</c>,
/// <c>Progress</c>, and <c>End</c> states.
/// </summary>
public IDictionary<int, string> StatusToDescription { get; set; } = new Dictionary<int, string>();
/// <summary>
/// Returns <c>true</c> when <paramref name="tag"/> is present in the
/// underlying <see cref="Data"/>.<see cref="QuestData.Tags"/> 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.
/// </summary>
public bool ContainsTag(string tag)
{
return Data.Tags.Contains(tag);
}
/// <summary>
/// Syntactic negation helper that returns <c>true</c> when
/// <paramref name="tag"/> 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.
/// </summary>
public bool NotContainsTag(string tag)
{
return !ContainsTag(tag);
}
/// <summary>
/// Inserts <paramref name="tag"/> into the mutable
/// <see cref="Data"/>.<see cref="QuestData.Tags"/> 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
/// <see cref="QuestTagChangedEvent"/> only when the tag was actually
/// added (duplicates are a no-op and publish nothing), so condition
/// caches invalidate exactly on real change.
/// </summary>
public void AddTag(string tag)
{
if (Data.Tags.Add(tag))
ModEventBus.Publish(new QuestTagChangedEvent { QuestId = ID, Tag = tag, Added = true });
}
/// <summary>
/// Removes <paramref name="tag"/> from the mutable
/// <see cref="Data"/>.<see cref="QuestData.Tags"/> 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
/// <see cref="QuestTagChangedEvent"/> only when the tag was actually
/// present and removed.
/// </summary>
public void RemoveTag(string tag)
{
if (Data.Tags.Remove(tag))
ModEventBus.Publish(new QuestTagChangedEvent { QuestId = ID, Tag = tag, Added = false });
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Events;
namespace Cthangover.Core.Quests
{
/// <summary>
/// Serialisable payload that holds the mutable, per‑save‑file runtime
/// state of a single quest. Separating this from the immutable
/// <see cref="QuestDefinition"/> 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
/// <see cref="QuestBase.Data"/> and is replaced wholesale by
/// <see cref="QuestFactory.SetAll"/> when the player loads a game.
/// </summary>
[Serializable]
public class QuestData
{
/// <summary>
/// Numeric representation of the current quest stage, castable to
/// <see cref="QuestStatus"/>. The journal UI maps this to a
/// description string via <see cref="QuestBase.StatusToDescription"/>.
/// </summary>
public int Status { get; set; }
/// <summary>
/// Set of string tags earned during this playthrough. Each tag
/// represents a branching milestone or sub‑objective; scenario
/// scripts query membership via <see cref="IQuest.ContainsTag"/> to
/// gate dialogue options and scene transitions.
/// </summary>
public HashSet<string> Tags { get; set; } = new();
/// <summary>
/// Sets the numeric status to <paramref name="value"/>, advancing
/// (or potentially resetting) the quest's progression stage. Called
/// by scenario action nodes that handle quest advancement commands.
/// Publishes a <see cref="QuestDataStatusChangedEvent"/> only when the
/// value actually changed, so cached condition results invalidate on
/// real change rather than on every redundant assignment.
/// </summary>
public void SetStatus(int value)
{
if (Status == value)
return;
ModEventBus.Publish(new QuestDataStatusChangedEvent { Old = Status, New = value });
Status = value;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using Cthangover.Core.Factories;
namespace Cthangover.Core.Quests
{
/// <summary>
/// Immutable data template loaded from a mod JSON file whose envelope
/// declares the <c>quest</c> kind. Each definition carries the quest's
/// identity, display name, and a mapping from
/// <see cref="QuestStatus"/> integer values to localised description
/// strings. <see cref="QuestFactory"/> reads these definitions once on
/// initialisation and uses them to seed <see cref="QuestBase"/> runtime
/// instances. The definition itself never changes during gameplay; all
/// mutable progression lives in <see cref="QuestData"/>.
/// Implements <see cref="IIdentifiable"/> so the factory can store
/// quest definitions in a keyed collection.
/// </summary>
[EntityKind("quest")]
public class QuestDefinition : IIdentifiable
{
/// <summary>
/// Unique string key used both as the JSON filename (minus
/// extension) in the mod archive and as the lookup key inside
/// <see cref="QuestFactory"/>'s internal dictionary.
/// </summary>
public string ID { get; set; }
/// <summary>
/// Display name of the quest as written in the mod definition;
/// passed through <c>TranslationServer.Translate</c> before being
/// shown in the journal or notification banners.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Dictionary mapping integer status codes to localised description
/// strings. During initialisation <see cref="QuestFactory"/> copies
/// this dictionary into the corresponding
/// <see cref="QuestBase.StatusToDescription"/> property of the
/// runtime quest instance.
/// </summary>
public Dictionary<int, string> StatusToDescription { get; set; }
}
}
+197
View File
@@ -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
{
/// <summary>
/// Thread‑safe singleton that manages the entire lifecycle of
/// <see cref="QuestBase"/> 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 <see cref="Get"/> or <see cref="GetAll"/>) and
/// guards against re‑initialisation through the <c>isLoaded</c> flag.
/// Because quest definitions come from <see cref="QuestDefinition"/>
/// objects collected by <see cref="ModManager.GetResolver{EntityResolver}()"/> through the
/// DTO's <c>[EntityKind("quest")]</c> 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.
/// <para/>
/// The single instance is process-wide and deliberately not owned by
/// <see cref="AppServices"/>: 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 <see cref="ResetAll"/>).
/// </summary>
public class QuestFactory
{
/// <summary>
/// Owns the process-wide instance; laziness defers construction to the
/// first access, avoiding order-dependent initialisation races with
/// other subsystems.
/// </summary>
private static Lazy<QuestFactory> instance = new(() => new QuestFactory());
/// <summary>
/// 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.
/// </summary>
public static QuestFactory Instance => instance.Value;
private readonly IDictionary<string, QuestBase> dataByID = new Dictionary<string, QuestBase>();
private bool isLoaded;
private QuestFactory()
{
}
/// <summary>
/// Seeds the internal <c>dataByID</c> dictionary from mod JSON if it
/// has not been seeded yet. When <see cref="ModManager"/> 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 <see cref="QuestDefinition"/> is
/// converted into a fresh <see cref="QuestBase"/> with default
/// status and an empty tag set.
/// </summary>
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<QuestDefinition>();
if (definitions != null)
{
foreach (var (id, def) in definitions)
{
var quest = new QuestBase
{
ID = def.ID,
Name = def.Name,
StatusToDescription = def.StatusToDescription ?? new Dictionary<int, string>(),
};
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");
}
/// <summary>
/// Looks up a quest by its unique string identifier and returns the
/// shared <see cref="QuestBase"/> runtime instance. Throws
/// <c>KeyNotFoundException</c> when no definition for
/// <paramref name="id"/> was loaded from mod data, which typically
/// indicates a typo in a scenario script's quest reference.
/// </summary>
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);
}
/// <summary>
/// Returns a snapshot list of every <see cref="QuestBase"/> 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.
/// </summary>
public List<QuestBase> GetAll()
{
EnsureLoaded();
return dataByID.Values.ToList();
}
/// <summary>
/// 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
/// <see cref="QuestBase.Data"/> and <see cref="QuestBase.Status"/>
/// 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 <c>null</c>
/// 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 <see cref="QuestStateRestoredEvent"/>
/// marker after the restore loop (not per-quest) so condition caches
/// invalidate once without a per-quest event storm.
/// </summary>
public void SetAll(List<QuestBase> 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());
}
/// <summary>
/// Returns every quest to the state it has right after mod loading:
/// <see cref="QuestStatus.NotStarted"/>, numeric stage zero, and an
/// empty tag set. Called by <c>GameData.StartNewGame</c> 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
/// <see cref="QuestStateRestoredEvent"/> so cached scenario conditions
/// invalidate once.
/// </summary>
public void ResetAll()
{
EnsureLoaded();
foreach (var quest in dataByID.Values)
{
quest.Status = QuestStatus.NotStarted;
quest.Data = new QuestData();
}
ModEventBus.Publish(new QuestStateRestoredEvent());
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using Cthangover.Core.Localization;
namespace Cthangover.Core.Quests
{
/// <summary>
/// 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 (<see cref="IQuest.Name"/>) and the tag:
/// <c>{quest.Name}/tag/{tag}</c>. For
/// <c>MeetingMurakamiQuest</c> (<c>Name = quest/meeting/murakami</c>) the
/// tag <c>go_home</c> maps to <c>quest/meeting/murakami/tag/go_home</c>,
/// sharing the namespace of the quest's own name and stage descriptions.
/// The journal renders every earned tag as a stamp through
/// <see cref="Translate(IQuest, string)"/>; when no translation exists the
/// raw tag id is shown, which is the behaviour tags had before locale
/// support was added.
/// </summary>
public static class QuestTagLocale
{
/// <summary>
/// Builds the composite locale key for <paramref name="tag"/> inside
/// <paramref name="quest"/>: <c>{quest.Name}/tag/{tag}</c>. Returns
/// <c>null</c> 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.
/// </summary>
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;
}
/// <summary>
/// Returns the display text of <paramref name="tag"/> 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.
/// </summary>
public static string Translate(IQuest quest, string tag)
{
return Translate(quest, tag, GodotTranslationProvider.Instance);
}
/// <summary>
/// Provider-injected variant of <see cref="Translate(IQuest, string)"/>
/// for tests and for callers that already hold an
/// <see cref="ILocalizationProvider"/>. A provider that returns
/// <c>null</c> for the composite key (the "untranslated" signal of
/// <see cref="ILocalizationProvider"/>) also yields the raw tag.
/// </summary>
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;
}
}
}