using System; using Cthangover.Mods.Dialog.Scenarios; using System.Collections.Generic; using System.IO; using System.Linq; using Cthangover.Core.Scenarios; using Cthangover.Core.Factories.Impls; using Cthangover.Core.Utils; using Cthangover.Core.Mods; namespace Cthangover.Tools.Services { /// Parsed metadata from a scenario file header (before the --- separator). public sealed class ScenarioMetadata { /// Target scene name for the scenario. public string Scene { get; set; } = "-"; /// Priority level of the scenario. public string Priority { get; set; } = "-"; /// Condition string for the scenario to activate. public string Condition { get; set; } = "-"; } /// Collected references extracted from scenario command lines for the info panel. public sealed class ScenarioReferences { /// Scene switch targets referenced in commands. public List SwitchTargets { get; set; } = new(); /// Localisation keys found in named parameters. public List LocaleKeys { get; set; } = new(); /// Quest IDs referenced in named quest_id parameters. public List QuestRefs { get; set; } = new(); /// Background IDs referenced in positional parameters of background-related commands. public List BackgroundRefs { get; set; } = new(); } /// /// Describes a single scenario file found during scanning. The file is /// addressed by its owning mod and mod-relative path, so both folder /// mods and zip mods are covered; is the stable /// identity used by the editor UI. /// public sealed class ScenarioFileInfo { /// Identifier of the mod that provides the file. public string ModId { get; set; } /// Mod-relative path, e.g. scenarios/start.scenario. public string RelativePath { get; set; } /// Filename without extension. public string Name { get; set; } /// Directory path relative to the mod's scenarios/ folder. public string RelativeDir { get; set; } /// Stable UI identity: modId:relativePath. public string Key => ScenarioFileService.MakeKey(ModId, RelativePath); } /// A group of scenario files within the same subdirectory under scenarios/. public sealed class ScenarioGroup { /// Relative directory path (empty for root-level files). public string DirectoryPath { get; set; } /// Files in this group, sorted by name. public List Files { get; set; } = new(); } /// Represents all scenario files for one mod, grouped by subdirectory. public sealed class ModScenarioTree { /// The mod's identifier. public string ModId { get; set; } /// Groups of scenario files within this mod. public List Groups { get; set; } = new(); } /// /// Static service for discovering, reading, writing, and analysing scenario files. /// Scans every loaded mod's scenarios/ folder through the mod file /// provider abstraction (), so folder and zip /// mods are discovered identically. Writing is only possible for folder mods: /// zip mods are read-only, and reports that instead /// of touching the filesystem. Used by for /// the file tree and info panel. /// public static class ScenarioFileService { private const string ScenariosDir = "scenarios"; private const string ScenarioExtension = ".scenario"; private const string KeySeparator = ":"; /// /// Recursively scans every loaded mod's scenarios/ folder for /// .scenario files via . Groups /// results by mod ID and subdirectory. Returns an empty list when no /// mods are loaded or none provide scenarios. /// public static List ScanScenarioFiles() { var result = new List(); var manager = ModManager.Instance; foreach (var mod in manager.Mods.Values.OrderBy(m => m.Id, StringComparer.OrdinalIgnoreCase)) { var files = new List(); CollectScenarioFiles(manager, mod.Id, ScenariosDir, files); if (files.Count == 0) continue; var groups = files .GroupBy(f => f.RelativeDir) .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) .Select(g => new ScenarioGroup { DirectoryPath = g.Key, Files = g.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase).ToList() }) .ToList(); result.Add(new ModScenarioTree { ModId = mod.Id, Groups = groups }); } return result; } private static void CollectScenarioFiles(ModManager manager, string modId, string directory, List result) { foreach (var entry in manager.ListFiles(modId, directory)) { if (entry.EndsWith("/")) { CollectScenarioFiles(manager, modId, entry.TrimEnd('/'), result); continue; } if (!entry.EndsWith(ScenarioExtension, StringComparison.OrdinalIgnoreCase)) continue; var path = entry.Replace('\\', '/'); var lastSlash = path.LastIndexOf('/'); var fileName = lastSlash >= 0 ? path.Substring(lastSlash + 1) : path; var dirPath = lastSlash >= 0 ? path.Substring(0, lastSlash) : ""; var relativeDir = ""; if (dirPath.StartsWith(ScenariosDir + "/", StringComparison.OrdinalIgnoreCase)) relativeDir = dirPath.Substring(ScenariosDir.Length + 1); else if (!dirPath.Equals(ScenariosDir, StringComparison.OrdinalIgnoreCase)) relativeDir = dirPath; result.Add(new ScenarioFileInfo { ModId = modId, RelativePath = path, Name = Path.GetFileNameWithoutExtension(fileName), RelativeDir = relativeDir }); } } /// Builds the stable UI identity for a scenario file. public static string MakeKey(string modId, string relativePath) => modId + KeySeparator + relativePath; /// Splits a key produced by back into mod ID and mod-relative path. public static bool TryParseKey(string key, out string modId, out string relativePath) { modId = null; relativePath = null; if (string.IsNullOrEmpty(key)) return false; var separator = key.IndexOf(KeySeparator, StringComparison.Ordinal); if (separator <= 0 || separator >= key.Length - 1) return false; modId = key.Substring(0, separator); relativePath = key.Substring(separator + 1); return true; } /// /// Parses the header block (before first --- line) in YAML-like /// key: value format to extract scene, priority, and condition. /// public static ScenarioMetadata ParseMetadata(string text) { var meta = new ScenarioMetadata(); var metaEnd = text.IndexOf("\n---"); var metaText = metaEnd >= 0 ? text.Substring(0, metaEnd) : ""; foreach (var line in metaText.Split('\n')) { var t = line.Trim(); if (t.Length == 0 || t.StartsWith("#")) continue; var ci = t.IndexOf(':'); if (ci <= 0) continue; var key = t.Substring(0, ci).Trim().ToLowerInvariant(); var val = t.Substring(ci + 1).Trim(); switch (key) { case "scene": meta.Scene = val; break; case "priority": meta.Priority = val; break; case "condition": meta.Condition = val; break; } } return meta; } /// /// Walks the body of the scenario (after ---) and extracts references: /// background IDs, scene switch targets, locale keys, and quest IDs. /// Uses to tokenise /// each line and metadata to /// classify positional parameters. /// /// Full scenario file text including header. public static ScenarioReferences ExtractReferences(string text) { var refs = new ScenarioReferences(); var metaEnd = text.IndexOf("\n---"); var content = metaEnd >= 0 ? text.Substring(metaEnd + 4) : text; foreach (var rawLine in content.Split('\n')) { var line = rawLine.Trim(); if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(":")) continue; ScenarioSyntaxService.ExtractCommandParams(line, out var cmd, out var positional, out var named); if (string.IsNullOrEmpty(cmd)) continue; var meta = ScenarioCommandStrategyFactory.GetReferenceMetadata(cmd); if (meta != null && positional.Count > 0) { switch (meta.Positional0Kind) { case PositionalReferenceKind.Background: refs.BackgroundRefs.Add(positional[0]); break; case PositionalReferenceKind.Scene: refs.SwitchTargets.Add(positional[0]); break; } } if (named.TryGetValue("key", out var lk)) refs.LocaleKeys.Add(lk); if (named.TryGetValue("quest_id", out var qid)) refs.QuestRefs.Add(qid); } return refs; } /// Reads a scenario file through its mod provider. Returns null when not found. public static string ReadAllText(string modId, string relativePath) { return ModManager.Instance.ReadFileText(modId, relativePath); } /// /// Writes a scenario file back to disk. Returns false when the /// owning mod is not filesystem-backed (zip mods are read-only). /// public static bool WriteAllText(string modId, string relativePath, string text) { var absolutePath = ModManager.Instance.GetFileSystemPath(modId, relativePath); if (string.IsNullOrEmpty(absolutePath)) return false; File.WriteAllText(absolutePath, text); return true; } /// /// Creates scenarios/<scene>[_n].scenario in a folder mod: a header bound to /// and one line of text to replace. Returns the file key, or /// null when the mod is not writable. /// public static string CreateForScene(string modId, string scene, string firstLine) { var path = $"{ScenariosDir}/{scene}{ScenarioExtension}"; for (var n = 2; ModManager.Instance.ReadFileText(modId, path) != null; n++) path = $"{ScenariosDir}/{scene}_{n}{ScenarioExtension}"; var text = $"scene: {scene}\npriority: 10\n---\n{firstLine}\nend\n"; return ModFileService.WriteText(modId, path, text) ? MakeKey(modId, path) : null; } /// Absolute filesystem path of a scenario file, or null for zip-backed mods. public static string GetAbsolutePath(string modId, string relativePath) { return ModManager.Instance.GetFileSystemPath(modId, relativePath); } /// Project-style display path (mods/<modId>/<relativePath>). public static string GetDisplayPath(string modId, string relativePath) { return "mods/" + modId + "/" + relativePath; } /// Scans every mod's scenarios and builds the cross-scene graph (nodes per scene, edges per switch target). public static SceneGraphData BuildSceneGraph() { var data = new SceneGraphData(); var sceneNodes = new Dictionary(StringComparer.OrdinalIgnoreCase); var edgeSet = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var mod in ScanScenarioFiles()) { foreach (var group in mod.Groups) { foreach (var file in group.Files) { var text = ReadAllText(file.ModId, file.RelativePath); if (text == null) continue; var metadata = ParseMetadata(text); if (string.IsNullOrEmpty(metadata.Scene) || metadata.Scene == "-") continue; var sceneKey = metadata.Scene.ToLowerInvariant(); if (!sceneNodes.TryGetValue(sceneKey, out var node)) { node = new SceneGraphNode { SceneName = metadata.Scene, ModId = file.ModId, IsStartScene = string.Equals(metadata.Scene, "start_scene", StringComparison.OrdinalIgnoreCase) }; sceneNodes[sceneKey] = node; } node.ScenarioCount++; node.Scenarios.Add(new ScenarioGraphLink { Name = file.Name, Priority = int.TryParse(metadata.Priority, out var p) ? p : 0, Key = file.Key }); var refs = ExtractReferences(text); foreach (var target in refs.SwitchTargets) { var srcKey = metadata.Scene.ToLowerInvariant(); var dstKey = target.ToLowerInvariant(); var edgeKey = srcKey + "|" + dstKey; if (!edgeSet.Contains(edgeKey)) { edgeSet.Add(edgeKey); data.Edges.Add(new SceneGraphEdge { FromScene = metadata.Scene, ToScene = target }); } } } } } foreach (var node in sceneNodes.Values) data.Nodes.Add(node); return data; } } }