init mod
This commit is contained in:
commit
b9b508a1d0
24 files changed
+2201
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user