diff --git a/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs b/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs index 4499f3743..4c7e5cd7d 100644 --- a/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs +++ b/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs @@ -8,371 +8,371 @@ using AibisDream.SaveSystem; using AibisDream.Utility; using Newtonsoft.Json; using UnityEditor; +using UnityEditor.AddressableAssets; +using UnityEditor.AddressableAssets.Settings; using UnityEngine; namespace AibisDream.EditorTools { - /// - /// 将本机 testsavs 归档提升到 StreamingAssets/TestSaveFiles,便于提交与跨机器复用。 - /// + /// 从本机 testsavs 选择章节、去重排序并提升为可提交测试档。 public sealed class DevSavePromoteWindow : EditorWindow { - private const string HuoshanSectionId = "Huoshan1"; - private const string HuoshanSceneName = "Scene/HuoShanFixScene"; - private const string HuoshanYarnProjectId = "FP_Huoshan1"; - - private static readonly (string NodeName, string Label, string FolderName)[] HuoshanCheckpoints = - { - ("开头对话", "Stage1 开场", "01_Stage1"), - ("Stage2就绪", "Stage2 初检", "02_Stage2"), - ("Stage3就绪", "Stage3 滤波器", "03_Stage3"), - ("Stage4就绪", "Stage4 表达深入", "04_Stage4"), - ("Stage5就绪", "Stage5 中场对话", "05_Stage5"), - ("Stage6就绪", "Stage6 LOG 释放", "06_Stage6"), - ("Stage7就绪", "Stage7 最终检查", "07_Stage7"), - ("结束对话", "Stage8 结束", "08_Stage8"), - }; - - private string sectionId = "Huoshan1"; - private string yarnProjectFilter = "FP_Huoshan1"; - private string sceneFilter = "Scene/HuoShanFixScene"; - private bool matchYarn = true; - private bool matchScene = true; - private bool keepLatestPerNode = true; - private bool clearSectionFirst; + private readonly List groups = new(); + private readonly List preview = new(); + private int selectedGroup; + private string sectionId = string.Empty; + private string sectionTitle = string.Empty; + private string status = string.Empty; private Vector2 scroll; - private string status = ""; - private List preview = new(); [MenuItem("Tools/Aibis/Dev Save Promote")] public static void Open() { var window = GetWindow("Dev Save Promote"); - window.minSize = new Vector2(520, 420); + window.minSize = new Vector2(720f, 520f); window.Show(); } + private void OnEnable() => RefreshGroups(); + private void OnGUI() { - EditorGUILayout.LabelField("Promote testsavs → StreamingAssets/TestSaveFiles", EditorStyles.boldLabel); + EditorGUILayout.LabelField("测试存档提升与校验", EditorStyles.boldLabel); EditorGUILayout.HelpBox( - "从本机 TestAutoSaveArchive(testsavs)筛选存档,复制到可提交目录。\n" + - $"目标: {ConstRef.TestSaveFilePath}", + "来源是本机 testsavs;目标是可提交、可随 Development Build 分发的 StreamingAssets/TestSaveFiles。", MessageType.Info); - sectionId = EditorGUILayout.TextField("Section Id", sectionId); - matchYarn = EditorGUILayout.Toggle("Filter by YarnProject", matchYarn); - using (new EditorGUI.DisabledScope(!matchYarn)) - yarnProjectFilter = EditorGUILayout.TextField("YarnProject Id", yarnProjectFilter); - matchScene = EditorGUILayout.Toggle("Filter by Scene", matchScene); - using (new EditorGUI.DisabledScope(!matchScene)) - sceneFilter = EditorGUILayout.TextField("Scene Name", sceneFilter); - keepLatestPerNode = EditorGUILayout.Toggle("Keep latest per nodeName", keepLatestPerNode); - clearSectionFirst = EditorGUILayout.Toggle("Clear section folder first", clearSectionFirst); - - EditorGUILayout.Space(8); using (new EditorGUILayout.HorizontalScope()) { - if (GUILayout.Button("Refresh Preview", GUILayout.Height(28))) - RefreshPreview(); - if (GUILayout.Button("Promote Selected Filters", GUILayout.Height(28))) - Promote(); + if (GUILayout.Button("刷新本机归档", GUILayout.Height(26f))) RefreshGroups(); + if (GUILayout.Button("校验全部已提交测试档", GUILayout.Height(26f))) ValidateCommittedCatalog(); } - if (!string.IsNullOrEmpty(status)) - EditorGUILayout.HelpBox(status, MessageType.None); - - EditorGUILayout.Space(4); - EditorGUILayout.LabelField($"Matches: {preview.Count}", EditorStyles.miniBoldLabel); - scroll = EditorGUILayout.BeginScrollView(scroll); - foreach (var item in preview) + if (groups.Count == 0) { - EditorGUILayout.LabelField( - $"{item.FolderName} | node={item.NodeName} | yarn={item.YarnProjectId} | scene={item.SceneName}"); + EditorGUILayout.HelpBox("本机没有可用 testsavs。", MessageType.Warning); + DrawStatus(); + return; } + var labels = groups.Select(group => + $"{group.SceneSoName} | {group.YarnProjectId} | {group.SceneName} ({group.Items.Count})").ToArray(); + var nextGroup = EditorGUILayout.Popup("章节归档", Mathf.Clamp(selectedGroup, 0, labels.Length - 1), labels); + if (nextGroup != selectedGroup || preview.Count == 0) + { + selectedGroup = nextGroup; + SelectGroup(groups[selectedGroup]); + } + + sectionId = EditorGUILayout.TextField("Section Id", sectionId); + sectionTitle = EditorGUILayout.TextField("显示名称", sectionTitle); + EditorGUILayout.LabelField("规则", "每个 Yarn 节点保留 savedAt 最新一份;默认按 savedAt 升序"); + + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.FlexibleSpace(); + if (GUILayout.Button("提升当前章节", GUILayout.Width(180f), GUILayout.Height(28f))) Promote(); + } + + EditorGUILayout.Space(5f); + EditorGUILayout.LabelField($"阶段预览:{preview.Count}", EditorStyles.boldLabel); + scroll = EditorGUILayout.BeginScrollView(scroll); + for (var i = 0; i < preview.Count; i++) + { + var item = preview[i]; + using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox)) + { + EditorGUILayout.LabelField($"{i + 1:000}", GUILayout.Width(38f)); + item.Label = EditorGUILayout.TextField(item.Label, GUILayout.Width(210f)); + EditorGUILayout.LabelField(item.NodeName, GUILayout.MinWidth(180f)); + EditorGUILayout.LabelField(item.SavedAt, GUILayout.Width(140f)); + using (new EditorGUI.DisabledScope(i == 0)) + { + if (GUILayout.Button("↑", GUILayout.Width(26f))) Move(i, i - 1); + } + using (new EditorGUI.DisabledScope(i == preview.Count - 1)) + { + if (GUILayout.Button("↓", GUILayout.Width(26f))) Move(i, i + 1); + } + } + } EditorGUILayout.EndScrollView(); + DrawStatus(); } - private void RefreshPreview() + private void DrawStatus() { - preview = CollectCandidates(); - status = $"Preview {preview.Count} entries from {ConstRef.TestAutoSaveArchivePath}"; + if (!string.IsNullOrWhiteSpace(status)) + EditorGUILayout.HelpBox(status, status.StartsWith("OK", StringComparison.Ordinal) ? MessageType.Info : MessageType.Warning); + } + + private void RefreshGroups() + { + groups.Clear(); + preview.Clear(); + var root = ConstRef.TestAutoSaveArchivePath; + if (!Directory.Exists(root)) + { + status = $"找不到本机归档:{root}"; + return; + } + + var candidates = new List(); + foreach (var directory in Directory.GetDirectories(root)) + { + var snapshotPath = Path.Combine(directory, $"{ConstRef.SaveSnapshotFileName}.json"); + if (!File.Exists(snapshotPath)) continue; + try + { + var snapshot = SnapshotPersistence.Load(Path.Combine(directory, ConstRef.SaveSnapshotFileName)); + if (snapshot?.anchor == null || string.IsNullOrWhiteSpace(snapshot.anchor.nodeName)) continue; + candidates.Add(new Candidate( + snapshotPath, + Path.Combine(directory, $"{ConstRef.SaveMetaFileName}.json"), + snapshot.scene?.sceneName, + snapshot.anchor.sceneSoName, + snapshot.anchor.yarnProjectId, + snapshot.anchor.nodeName, + snapshot.savedAt)); + } + catch (Exception ex) + { + Debug.LogWarning($"[DevSavePromote] 跳过损坏归档 {snapshotPath}: {ex.Message}"); + } + } + + groups.AddRange(candidates + .GroupBy(item => $"{item.SceneName}\n{item.SceneSoName}\n{item.YarnProjectId}", StringComparer.Ordinal) + .Select(group => new ArchiveGroup(group.ToList())) + .OrderBy(group => group.SceneSoName, StringComparer.Ordinal)); + selectedGroup = Mathf.Clamp(selectedGroup, 0, Math.Max(0, groups.Count - 1)); + if (groups.Count > 0) SelectGroup(groups[selectedGroup]); + status = $"OK:读取 {candidates.Count} 份归档,分为 {groups.Count} 个章节。"; + } + + private void SelectGroup(ArchiveGroup group) + { + preview.Clear(); + preview.AddRange(group.Items + .GroupBy(item => item.NodeName, StringComparer.Ordinal) + .Select(nodes => nodes.OrderByDescending(item => item.SavedAt, StringComparer.Ordinal).First()) + .OrderBy(item => item.SavedAt, StringComparer.Ordinal)); + sectionId = SanitizeIdentifier(group.SceneSoName); + sectionTitle = group.SceneSoName; + } + + private void Move(int from, int to) + { + if (from < 0 || to < 0 || from >= preview.Count || to >= preview.Count) return; + var item = preview[from]; + preview.RemoveAt(from); + preview.Insert(to, item); + GUI.FocusControl(null); } private void Promote() { - if (string.IsNullOrWhiteSpace(sectionId)) + if (preview.Count == 0 || string.IsNullOrWhiteSpace(sectionId)) { - status = "Section Id 不能为空。"; + status = "没有可提升阶段,或 Section Id 为空。"; return; } - var candidates = CollectCandidates(); - if (candidates.Count == 0) + var validation = ValidateCandidates(preview); + if (validation.Count > 0) { - status = "没有匹配的 testsavs 条目。先在 Editor 里打通流程(测试存档模式会写入 testsavs)。"; - preview = candidates; + status = string.Join("\n", validation); return; } - if (IsHuoshanSection() - && HuoshanCheckpoints.Any(checkpoint => candidates.All(c => c.NodeName != checkpoint.NodeName))) - { - var missing = HuoshanCheckpoints - .Where(checkpoint => candidates.All(c => c.NodeName != checkpoint.NodeName)) - .Select(checkpoint => checkpoint.NodeName); - status = $"Huoshan1 缺少阶段存档:{string.Join(", ", missing)}。请完整跑通流程后再提升。"; - preview = candidates; - return; - } - - var sectionDir = Path.Combine(ConstRef.TestSaveFilePath, sectionId.Trim()); - if ((clearSectionFirst || IsHuoshanSection()) && Directory.Exists(sectionDir)) - Directory.Delete(sectionDir, recursive: true); - - Directory.CreateDirectory(sectionDir); - EnsureCatalogSection(sectionId.Trim()); - - int copied = 0; - int index = 1; - foreach (var item in OrderCandidates(candidates)) - { - var safeNode = SanitizeFolderName(string.IsNullOrEmpty(item.NodeName) ? "no_node" : item.NodeName); - var folderName = IsHuoshanSection() - ? HuoshanCheckpoints.First(checkpoint => checkpoint.NodeName == item.NodeName).FolderName - : $"{index:00}_{safeNode}"; - var destDir = Path.Combine(sectionDir, folderName); - Directory.CreateDirectory(destDir); - - CopyIfExists(item.SnapshotPath, Path.Combine(destDir, $"{ConstRef.SaveSnapshotFileName}.json")); - CopyIfExists(item.MetaPath, Path.Combine(destDir, $"{ConstRef.SaveMetaFileName}.json")); - copied++; - index++; - } - - preview = candidates; - AssetDatabase.Refresh(); - status = $"已复制 {copied} 份到 TestSaveFiles/{sectionId.Trim()}"; - } - - private List CollectCandidates() - { - var list = new List(); - if (!Directory.Exists(ConstRef.TestAutoSaveArchivePath)) - return list; - - foreach (var dir in Directory.GetDirectories(ConstRef.TestAutoSaveArchivePath)) - { - var snapshotPath = Path.Combine(dir, $"{ConstRef.SaveSnapshotFileName}.json"); - if (!File.Exists(snapshotPath)) - continue; - - var metaPath = Path.Combine(dir, $"{ConstRef.SaveMetaFileName}.json"); - SlotMeta meta = null; - if (File.Exists(metaPath)) - { - try - { - meta = JsonConvert.DeserializeObject(File.ReadAllText(metaPath, Encoding.UTF8)); - } - catch - { - // ignore broken meta - } - } - - var yarn = meta?.yarnProjectId ?? string.Empty; - var scene = meta?.sceneName ?? string.Empty; - var node = meta?.nodeName ?? string.Empty; - var savedAt = meta?.savedAt ?? string.Empty; - - bool yarnOk = !matchYarn || string.Equals(yarn, yarnProjectFilter, StringComparison.OrdinalIgnoreCase); - bool sceneOk = !matchScene || string.Equals(scene, sceneFilter, StringComparison.OrdinalIgnoreCase); - if (!yarnOk || !sceneOk) - continue; - - list.Add(new Candidate( - Path.GetFileName(dir), - dir, - snapshotPath, - File.Exists(metaPath) ? metaPath : null, - node, - yarn, - scene, - savedAt)); - } - - if (!keepLatestPerNode) - return FilterHuoshanCheckpoints(list.OrderBy(c => c.SavedAt, StringComparer.Ordinal).ToList()); - - var latestPerNode = list - .GroupBy(c => string.IsNullOrEmpty(c.NodeName) ? c.FolderName : c.NodeName, StringComparer.Ordinal) - .Select(g => g.OrderByDescending(c => c.SavedAt, StringComparer.Ordinal).First()) - .OrderBy(c => c.SavedAt, StringComparer.Ordinal) - .ToList(); - return FilterHuoshanCheckpoints(latestPerNode); - } - - private bool IsHuoshanSection() - { - return string.Equals(sectionId?.Trim(), HuoshanSectionId, StringComparison.OrdinalIgnoreCase); - } - - private List FilterHuoshanCheckpoints(List candidates) - { - if (!IsHuoshanSection()) - return candidates; - - var allowedNodes = new HashSet( - HuoshanCheckpoints.Select(checkpoint => checkpoint.NodeName), - StringComparer.Ordinal); - return candidates.Where(candidate => allowedNodes.Contains(candidate.NodeName)).ToList(); - } - - private IEnumerable OrderCandidates(List candidates) - { - if (!IsHuoshanSection()) - { - return candidates.OrderBy(c => c.SavedAt, StringComparer.Ordinal) - .ThenBy(c => c.NodeName, StringComparer.Ordinal); - } - - return HuoshanCheckpoints.Select(checkpoint => - candidates.First(candidate => candidate.NodeName == checkpoint.NodeName)); - } - - private static void EnsureCatalogSection(string sectionId) - { + var group = groups[selectedGroup]; var root = ConstRef.TestSaveFilePath; - Directory.CreateDirectory(root); - var catalogPath = Path.Combine(root, "catalog.json"); + var targetSection = Path.Combine(root, sectionId); + if (Directory.Exists(targetSection)) Directory.Delete(targetSection, true); + Directory.CreateDirectory(targetSection); - CatalogDto catalog; - if (File.Exists(catalogPath)) + var catalog = LoadCatalogForWrite(); + catalog.sections.RemoveAll(section => string.Equals(section.id, sectionId, StringComparison.OrdinalIgnoreCase)); + var sectionDto = new DevSaveCatalogSectionDto { + id = sectionId, + title = string.IsNullOrWhiteSpace(sectionTitle) ? sectionId : sectionTitle, + expectedSceneName = group.SceneName, + expectedSceneSoName = group.SceneSoName, + expectedYarnProjectId = group.YarnProjectId + }; + + for (var i = 0; i < preview.Count; i++) + { + var item = preview[i]; + var folder = $"{i + 1:000}_{SanitizeIdentifier(item.NodeName)}"; + var destination = Path.Combine(targetSection, folder); + Directory.CreateDirectory(destination); + File.Copy(item.SnapshotPath, Path.Combine(destination, $"{ConstRef.SaveSnapshotFileName}.json"), true); + if (File.Exists(item.MetaPath)) + File.Copy(item.MetaPath, Path.Combine(destination, $"{ConstRef.SaveMetaFileName}.json"), true); + sectionDto.entries.Add(new DevSaveCatalogEntryDto + { + order = i + 1, + label = string.IsNullOrWhiteSpace(item.Label) ? item.NodeName : item.Label, + path = $"{sectionId}/{folder}", + anchorNode = item.NodeName + }); + } + + catalog.sections.Add(sectionDto); + WriteCatalog(catalog); + AssetDatabase.Refresh(); + ValidateCommittedCatalog(); + } + + private static List ValidateCandidates(IEnumerable candidates) + { + var errors = new List(); + var nodes = new HashSet(StringComparer.Ordinal); + foreach (var item in candidates) + { + if (!nodes.Add(item.NodeName)) errors.Add($"重复节点:{item.NodeName}"); try { - catalog = JsonConvert.DeserializeObject(File.ReadAllText(catalogPath, Encoding.UTF8)) - ?? new CatalogDto(); + var snapshot = SnapshotPersistence.Load(Path.Combine(Path.GetDirectoryName(item.SnapshotPath)!, ConstRef.SaveSnapshotFileName)); + if (snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion) + errors.Add($"{item.NodeName}: schema {snapshot.schemaVersion} 不兼容"); } - catch + catch (Exception ex) { - catalog = new CatalogDto(); + errors.Add($"{item.NodeName}: {ex.Message}"); } } - else - { - catalog = new CatalogDto(); - } + return errors; + } - catalog.sections ??= new List(); - var section = catalog.sections.FirstOrDefault( - s => string.Equals(s.id, sectionId, StringComparison.OrdinalIgnoreCase)); - if (section == null) + private void ValidateCommittedCatalog() + { + var sections = DevSaveCatalog.Load(ConstRef.TestSaveFilePath); + var errors = new List(); + if (!string.IsNullOrWhiteSpace(DevSaveCatalog.LastError)) errors.Add(DevSaveCatalog.LastError); + foreach (var section in sections) { - section = new CatalogSectionDto + var orders = new HashSet(); + var nodes = new HashSet(StringComparer.Ordinal); + foreach (var entry in section.Entries) { - id = sectionId, - title = sectionId - }; - catalog.sections.Add(section); + if (!orders.Add(entry.Order)) errors.Add($"{section.Id}: 重复 order {entry.Order}"); + if (!nodes.Add(entry.AnchorNode)) errors.Add($"{section.Id}: 重复节点 {entry.AnchorNode}"); + if (!entry.IsValid) errors.Add($"{section.Id}/{entry.Label}: {entry.Error}"); + } } - if (string.Equals(sectionId, HuoshanSectionId, StringComparison.OrdinalIgnoreCase)) + var dto = LoadCatalogForWrite(); + foreach (var section in dto.sections) { - section.title = "Huoshan 1"; - section.expectedSceneName = HuoshanSceneName; - section.expectedYarnProjectId = HuoshanYarnProjectId; - section.allowedNodes = HuoshanCheckpoints.Select(checkpoint => checkpoint.NodeName).ToList(); - section.entries = HuoshanCheckpoints.Select(checkpoint => new CatalogEntryDto + if (!SceneAddressExists(section.expectedSceneName)) + errors.Add($"{section.id}: Addressable 场景不存在 {section.expectedSceneName}"); + var sceneSo = FindTalkSceneSo(section.expectedSceneSoName); + if (sceneSo == null) + errors.Add($"{section.id}: TalkSceneSO 不存在 {section.expectedSceneSoName}"); + else if (sceneSo.yarnProject == null || sceneSo.yarnProject.name != section.expectedYarnProjectId) + errors.Add($"{section.id}: TalkSceneSO 的 YarnProject 不匹配 {section.expectedYarnProjectId}"); + else { - label = checkpoint.Label, - path = $"{HuoshanSectionId}/{checkpoint.FolderName}", - anchorNode = checkpoint.NodeName - }).ToList(); + var nodeNames = new HashSet(sceneSo.yarnProject.NodeNames, StringComparer.Ordinal); + foreach (var entry in section.entries.Where(entry => !nodeNames.Contains(entry.anchorNode))) + errors.Add($"{section.id}: Yarn 节点不存在 {entry.anchorNode}"); + } } + status = errors.Count == 0 + ? $"OK:{sections.Count} 个章节、{sections.Sum(s => s.Entries.Count)} 个阶段全部通过校验。" + : $"校验失败({errors.Count}):\n{string.Join("\n", errors)}"; + if (errors.Count > 0) Debug.LogError($"[DevSavePromote] {status}"); + else Debug.Log($"[DevSavePromote] {status}"); + } + + private static bool SceneAddressExists(string address) + { + var settings = AddressableAssetSettingsDefaultObject.Settings; + return settings != null && settings.groups + .Where(group => group != null) + .SelectMany(group => group.entries) + .Any(entry => string.Equals(entry.address, address, StringComparison.Ordinal)); + } + + private static TalkSceneSO FindTalkSceneSo(string assetName) + { + foreach (var guid in AssetDatabase.FindAssets($"t:TalkSceneSO {assetName}")) + { + var asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid)); + if (asset != null && asset.name == assetName) return asset; + } + return null; + } + + private static DevSaveCatalogDto LoadCatalogForWrite() + { + var path = Path.Combine(ConstRef.TestSaveFilePath, "catalog.json"); + if (!File.Exists(path)) return new DevSaveCatalogDto(); + try + { + return JsonConvert.DeserializeObject(File.ReadAllText(path, Encoding.UTF8)) + ?? new DevSaveCatalogDto(); + } + catch + { + return new DevSaveCatalogDto(); + } + } + + private static void WriteCatalog(DevSaveCatalogDto catalog) + { + Directory.CreateDirectory(ConstRef.TestSaveFilePath); File.WriteAllText( - catalogPath, + Path.Combine(ConstRef.TestSaveFilePath, "catalog.json"), JsonConvert.SerializeObject(catalog, Formatting.Indented), - new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + new UTF8Encoding(false)); } - private static void CopyIfExists(string src, string dest) + private static string SanitizeIdentifier(string value) { - if (string.IsNullOrEmpty(src) || !File.Exists(src)) - return; - - File.Copy(src, dest, overwrite: true); + if (string.IsNullOrWhiteSpace(value)) return "unnamed"; + foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); + value = value.Replace(' ', '_'); + return value.Length <= 48 ? value : value.Substring(0, 48); } - private static string SanitizeFolderName(string name) + private sealed class ArchiveGroup { - foreach (var c in Path.GetInvalidFileNameChars()) - name = name.Replace(c, '_'); - name = name.Replace(' ', '_'); - if (name.Length > 48) - name = name.Substring(0, 48); - return string.IsNullOrWhiteSpace(name) ? "no_node" : name; + public readonly List Items; + public string SceneName => Items[0].SceneName; + public string SceneSoName => Items[0].SceneSoName; + public string YarnProjectId => Items[0].YarnProjectId; + public ArchiveGroup(List items) => Items = items; } private sealed class Candidate { - public readonly string FolderName; - public readonly string DirectoryPath; public readonly string SnapshotPath; public readonly string MetaPath; - public readonly string NodeName; - public readonly string YarnProjectId; public readonly string SceneName; + public readonly string SceneSoName; + public readonly string YarnProjectId; + public readonly string NodeName; public readonly string SavedAt; + public string Label; - public Candidate( - string folderName, - string directoryPath, - string snapshotPath, - string metaPath, - string nodeName, - string yarnProjectId, - string sceneName, - string savedAt) + public Candidate(string snapshotPath, string metaPath, string sceneName, string sceneSoName, + string yarnProjectId, string nodeName, string savedAt) { - FolderName = folderName; - DirectoryPath = directoryPath; SnapshotPath = snapshotPath; MetaPath = metaPath; - NodeName = nodeName; - YarnProjectId = yarnProjectId; SceneName = sceneName; - SavedAt = savedAt; + SceneSoName = sceneSoName; + YarnProjectId = yarnProjectId; + NodeName = nodeName; + SavedAt = savedAt ?? string.Empty; + Label = nodeName; } } - - [Serializable] - private class CatalogDto - { - public List sections = new(); - } - - [Serializable] - private class CatalogSectionDto - { - public string id; - public string title; - public List entries; - public string expectedSceneName; - public string expectedYarnProjectId; - public List allowedNodes; - } - - [Serializable] - private class CatalogEntryDto - { - public string label; - public string path; - public string anchorNode; - } } } #endif diff --git a/Assets/Scripts/Game Loop/GameManager.cs b/Assets/Scripts/Game Loop/GameManager.cs index 393fa87f0..589964708 100644 --- a/Assets/Scripts/Game Loop/GameManager.cs +++ b/Assets/Scripts/Game Loop/GameManager.cs @@ -7,6 +7,7 @@ using AibisDream.Kit; using AibisDream.SaveSystem; using AibisDream.UI; using UnityEngine; +using UnityEngine.EventSystems; namespace AibisDream { @@ -219,6 +220,16 @@ namespace AibisDream StartCoroutine(RestartCoroutine()); } + /// + /// 可等待的完整游戏会话清理。开发跳转等编排流程必须等待它结束,避免场景卸载与读档并发。 + /// + public IEnumerator ResetGameSessionRoutine() + { + Time.timeScale = 1; + ScreenEffectManager.Instance?.ResetSaturation(); + yield return RestartCoroutine(); + } + public void QuitApp() { Application.Quit(); @@ -262,6 +273,8 @@ namespace AibisDream { yield return playTool.FadeOutAsync(MainMenuFadeDuration); } + + EventSystem.current?.SetSelectedGameObject(null); } #endregion @@ -338,8 +351,7 @@ namespace AibisDream public bool SetSceneSoByName(string soName) { - var sceneSo = _totalSceneSos?.Find(so => so != null && so.name == soName); - sceneSo ??= additionalRestoreSceneSos?.Find(so => so != null && so.name == soName); + var sceneSo = FindSceneSoByName(soName); if (sceneSo == null) { @@ -351,6 +363,13 @@ namespace AibisDream return true; } + /// 只查询章节资产,不改变当前章节;供严格读档预检使用。 + public TalkSceneSO FindSceneSoByName(string soName) + { + var sceneSo = _totalSceneSos?.Find(so => so != null && so.name == soName); + return sceneSo ?? additionalRestoreSceneSos?.Find(so => so != null && so.name == soName); + } + public void StartDialog() { DialogController.Instance.LoadDialog(_currentTalkSceneSo.Value.yarnProject); diff --git a/Assets/Scripts/SaveSystem/ISnapshotProvider.cs b/Assets/Scripts/SaveSystem/ISnapshotProvider.cs index 1a2fad042..7ae656cc6 100644 --- a/Assets/Scripts/SaveSystem/ISnapshotProvider.cs +++ b/Assets/Scripts/SaveSystem/ISnapshotProvider.cs @@ -1,9 +1,33 @@ using System; using System.Collections; +using System.Collections.Generic; using UnityEngine; namespace AibisDream.SaveSystem { + [Serializable] + public sealed class RestoreOptions + { + public bool CleanSessionFirst; + public bool StrictValidation; + + public static RestoreOptions Default => new(); + public static RestoreOptions DevJump => new() + { + CleanSessionFirst = true, + StrictValidation = true + }; + } + + public sealed class RestoreResult + { + public bool Success { get; internal set; } + public string FailedPhase { get; internal set; } + public IReadOnlyList Errors { get; internal set; } = Array.Empty(); + public IReadOnlyList Warnings { get; internal set; } = Array.Empty(); + public IReadOnlyList Log { get; internal set; } = Array.Empty(); + } + /// /// 快照提供者契约:将某一子系统的运行时状态转换为纯数据 DTO。 /// 还原侧由同步 / 异步子接口显式声明,避免 Provider 内部 fire-and-forget。 @@ -56,6 +80,19 @@ namespace AibisDream.SaveSystem public SaveSnapshot Snapshot { get; } public bool StrictMode { get; } public Action LogStep { get; } + public string CurrentPhase { get; private set; } + public IReadOnlyList Warnings => _warnings; + public IReadOnlyList Errors => _errors; + public bool HasErrors => _errors.Count > 0; + + private readonly List _warnings = new(); + private readonly List _errors = new(); + + public void SetPhase(string phase) + { + CurrentPhase = phase; + Log(phase); + } public void Log(string message) { @@ -65,12 +102,14 @@ namespace AibisDream.SaveSystem public void Warn(string message) { + _warnings.Add(message); LogStep?.Invoke($"WARN: {message}"); Debug.LogWarning($"[SnapshotRestore] {message}"); } public void Error(string message) { + _errors.Add(message); LogStep?.Invoke($"ERROR: {message}"); Debug.LogError($"[SnapshotRestore] {message}"); } diff --git a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs index 1007d0e92..3d382b18c 100644 --- a/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs +++ b/Assets/Scripts/SaveSystem/SaveRestoreOrchestrator.cs @@ -154,20 +154,70 @@ namespace AibisDream.SaveSystem /// 从文件读档并还原;自动区分新快照格式与 legacy 格式。 public static IEnumerator RestoreFromFile(string savePath) { - if (SnapshotPersistence.IsLegacyFormat(savePath)) + yield return RestoreFromFile(savePath, RestoreOptions.Default, null); + } + + /// 从文件恢复并返回结构化结果;开发跳转使用严格校验和完整会话清理。 + public static IEnumerator RestoreFromFile( + string savePath, + RestoreOptions options, + Action completed) + { + options ??= RestoreOptions.Default; + var result = new RestoreResult(); + + bool isLegacy; + try { - Debug.LogWarning("[SaveRestoreOrchestrator] 检测到旧格式存档,请使用新快照格式重新保存。"); - yield return RestoreLegacyWithFlow(savePath); + isLegacy = SnapshotPersistence.IsLegacyFormat(savePath); + } + catch (Exception ex) + { + result.Success = false; + result.FailedPhase = "LoadSnapshot"; + result.Errors = new[] { ex.Message }; + completed?.Invoke(result); yield break; } - SaveSnapshot snapshot; - using (new CodeTimer("LoadSnapshot")) + if (isLegacy) { - snapshot = SnapshotPersistence.Load(savePath); + if (options.StrictValidation) + { + result.Success = false; + result.FailedPhase = "Preflight"; + result.Errors = new[] { "legacy format is not supported by strict restore" }; + completed?.Invoke(result); + yield break; + } + + Debug.LogWarning("[SaveRestoreOrchestrator] 检测到旧格式存档,请使用新快照格式重新保存。"); + yield return RestoreLegacyWithFlow(savePath); + result.Success = true; + result.Log = new List(_lastRestoreLog); + completed?.Invoke(result); + yield break; } - yield return RestoreSnapshot(snapshot, savePath); + SaveSnapshot snapshot = null; + try + { + using (new CodeTimer("LoadSnapshot")) + { + snapshot = SnapshotPersistence.Load(savePath); + } + } + catch (Exception ex) + { + result.Success = false; + result.FailedPhase = "LoadSnapshot"; + result.Errors = new[] { ex.Message }; + completed?.Invoke(result); + yield break; + } + + yield return RestoreSnapshot(snapshot, savePath, options, result); + completed?.Invoke(result); } public static IDisposable SuppressAutoSaveScope(string reason) @@ -224,10 +274,22 @@ namespace AibisDream.SaveSystem } private static IEnumerator RestoreSnapshot(SaveSnapshot snapshot, string sourceLabel) + { + yield return RestoreSnapshot(snapshot, sourceLabel, RestoreOptions.Default, new RestoreResult()); + } + + private static IEnumerator RestoreSnapshot( + SaveSnapshot snapshot, + string sourceLabel, + RestoreOptions options, + RestoreResult result) { if (snapshot == null) { Debug.LogError("[SaveRestoreOrchestrator] snapshot 为 null,无法读档。"); + result.Success = false; + result.FailedPhase = "Preflight"; + result.Errors = new[] { "snapshot is null" }; yield break; } @@ -235,22 +297,48 @@ namespace AibisDream.SaveSystem { IsRestoring = true; ResetRestoreLog(sourceLabel); - var context = new SnapshotRestoreContext(snapshot, logStep: AddRestoreLog); + var context = new SnapshotRestoreContext( + snapshot, + strictMode: options.StrictValidation, + logStep: AddRestoreLog); try { + context.SetPhase("Preflight"); + ValidateSnapshotForRestore(snapshot, context); + if (context.StrictMode && context.HasErrors) + { + yield break; + } + + if (options.CleanSessionFirst && GameManager.Instance != null) + { + context.SetPhase("Clean game session"); + yield return GameManager.Instance.ResetGameSessionRoutine(); + } + PrepareGameSessionForRestore(); yield return FadeInForRestore(); SnapshotRegistry.EnsureInitialized(); yield return SnapshotRestore.RestoreState(YarnVariableStorage.Instance, snapshot, context); - yield return SnapshotRestore.RestoreAnchor(snapshot, context); + if (!context.StrictMode || !context.HasErrors) + { + yield return SnapshotRestore.RestoreAnchor(snapshot, context); + } - AddRestoreLog("Phase 3.5: settle one frame"); - yield return null; + if (!context.StrictMode || !context.HasErrors) + { + context.SetPhase("Phase 3.5: settle one frame"); + yield return null; + ValidateRestoredRuntime(snapshot, context); + } - FinalizeGameSessionAfterRestore(); + if (!context.StrictMode || !context.HasErrors) + { + FinalizeGameSessionAfterRestore(); + } yield return FadeOutForRestore(); ScreenSnapshotHelper.ApplyDeferredFadeScreenIfNeeded(); @@ -259,10 +347,76 @@ namespace AibisDream.SaveSystem { IsRestoring = false; AddRestoreLog("Restore finished"); + result.Success = !context.HasErrors; + result.FailedPhase = context.HasErrors ? context.CurrentPhase : null; + result.Errors = new List(context.Errors); + result.Warnings = new List(context.Warnings); + result.Log = new List(_lastRestoreLog); } } } + private static void ValidateSnapshotForRestore(SaveSnapshot snapshot, SnapshotRestoreContext context) + { + if (snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion) + context.Error($"Unsupported schema {snapshot.schemaVersion}; expected {SaveSnapshotSchema.CurrentVersion}."); + if (string.IsNullOrWhiteSpace(snapshot.scene?.sceneName)) + context.Error("Snapshot scene is missing."); + if (string.IsNullOrWhiteSpace(snapshot.anchor?.sceneSoName)) + context.Error("Snapshot TalkSceneSO is missing."); + if (string.IsNullOrWhiteSpace(snapshot.anchor?.yarnProjectId)) + context.Error("Snapshot YarnProject is missing."); + if (string.IsNullOrWhiteSpace(snapshot.anchor?.nodeName)) + context.Error("Snapshot Yarn node is missing."); + + if (context.HasErrors) return; + var sceneSo = GameManager.Instance?.FindSceneSoByName(snapshot.anchor.sceneSoName); + if (sceneSo == null) + { + context.Error($"TalkSceneSO does not exist: {snapshot.anchor.sceneSoName}."); + return; + } + if (sceneSo.yarnProject == null + || !string.Equals(sceneSo.yarnProject.name, snapshot.anchor.yarnProjectId, StringComparison.Ordinal)) + { + context.Error($"TalkSceneSO YarnProject mismatch: {snapshot.anchor.yarnProjectId}."); + return; + } + if (!Array.Exists(sceneSo.yarnProject.NodeNames, + node => string.Equals(node, snapshot.anchor.nodeName, StringComparison.Ordinal))) + { + context.Error($"Yarn node does not exist: {snapshot.anchor.nodeName}."); + } + } + + private static void ValidateRestoredRuntime(SaveSnapshot snapshot, SnapshotRestoreContext context) + { + context.SetPhase("Postflight validation"); + var sceneLoader = SceneLoader.Instance; + if (sceneLoader == null || sceneLoader.IsLoading) + context.Error("SceneLoader is not ready."); + else if (!string.Equals(sceneLoader.CurrentSceneName, snapshot.scene.sceneName, StringComparison.Ordinal)) + context.Error($"Scene mismatch: {sceneLoader.CurrentSceneName ?? "none"}."); + + var sceneSo = GameManager.Instance?.GetCurrentTalkSceneSo(); + if (!string.Equals(sceneSo?.name, snapshot.anchor.sceneSoName, StringComparison.Ordinal)) + context.Error($"TalkSceneSO mismatch: {sceneSo?.name ?? "none"}."); + + var runner = DialogController.Instance?.DialogueRunner; + if (!string.Equals(runner?.YarnProject?.name, snapshot.anchor.yarnProjectId, StringComparison.Ordinal)) + context.Error($"YarnProject mismatch: {runner?.YarnProject?.name ?? "none"}."); + if (!string.IsNullOrEmpty(snapshot.anchor.nodeName) && runner != null && !runner.IsDialogueRunning) + context.Error($"Yarn node did not start: {snapshot.anchor.nodeName}."); + + if (snapshot.sections != null + && snapshot.sections.ContainsKey(SnapshotProviderIds.Fix) + && (FixSystem.FixSystemCenter.Instance == null + || !FixSystem.FixSystemCenter.Instance.IsDirectorReady)) + { + context.Error("FixSystem is not ready."); + } + } + /// /// 读档前对齐「已进入游戏」的 UI 与输入状态。 /// 编辑器测试工具等路径可能跳过 ,导致 TerminalPanel 未关闭、 diff --git a/Assets/Scripts/SaveSystem/SnapshotRestore.cs b/Assets/Scripts/SaveSystem/SnapshotRestore.cs index 4ddee700c..a66fcb175 100644 --- a/Assets/Scripts/SaveSystem/SnapshotRestore.cs +++ b/Assets/Scripts/SaveSystem/SnapshotRestore.cs @@ -25,16 +25,18 @@ namespace AibisDream.SaveSystem context ??= new SnapshotRestoreContext(snapshot); - context.Log("Phase 0: Restore Yarn variables"); + context.SetPhase("Phase 0: Restore Yarn variables"); RestoreYarnVariables(storage, snapshot); - context.Log("Phase 1: Load scene"); + context.SetPhase("Phase 1: Load scene"); yield return RestoreScene(snapshot, context); + if (context.StrictMode && context.HasErrors) yield break; - context.Log("Phase 1.5: Restore scene SO"); + context.SetPhase("Phase 1.5: Restore scene SO"); RestoreSceneSo(snapshot, context); + if (context.StrictMode && context.HasErrors) yield break; - context.Log("Phase 2: Restore providers (ordered by RestoreOrder)"); + context.SetPhase("Phase 2: Restore providers (ordered by RestoreOrder)"); yield return RestoreProvidersInOrder(snapshot, context); } @@ -52,7 +54,7 @@ namespace AibisDream.SaveSystem context ??= new SnapshotRestoreContext(snapshot); var hasNode = !string.IsNullOrEmpty(snapshot.anchor.nodeName); - context.Log(hasNode + context.SetPhase(hasNode ? $"Phase 3: Restore anchor {snapshot.anchor.nodeName}" : "Phase 3: No anchor node, restore YarnProject only"); @@ -192,6 +194,11 @@ namespace AibisDream.SaveSystem context.Warn($"Provider {provider.SaveId} 未实现同步或异步还原契约,已跳过。"); break; } + + if (context.StrictMode && context.HasErrors) + { + yield break; + } } } diff --git a/Assets/Scripts/Utility/DevSaveJumpTool.cs b/Assets/Scripts/Utility/DevSaveJumpTool.cs index a5cc38d80..4c10c3bf4 100644 --- a/Assets/Scripts/Utility/DevSaveJumpTool.cs +++ b/Assets/Scripts/Utility/DevSaveJumpTool.cs @@ -11,592 +11,326 @@ using UnityEngine; namespace AibisDream { - /// - /// 开发用通用跳转工具:从 (可提交)读档。 - /// 目录约定:StreamingAssets/TestSaveFiles/{SectionId}/{EntryFolder}/snapshot.json - /// - public class DevSaveJumpTool : MonoBehaviour + /// 从随项目提交的 TestSaveFiles 清单中执行严格、可诊断的开发跳转。 + public sealed class DevSaveJumpTool : MonoBehaviour { - private const float UiMargin = 16f; - private const float DevButtonWidth = 96f; - private const float DevButtonHeight = 34f; - private const float MenuWidth = 320f; - private const float MaxMenuHeight = 560f; - private const float RowHeight = 30f; - private const float HeaderHeight = 24f; - private const float StatusWidth = 360f; - private const float StatusHeight = 28f; + private const float Margin = 16f; + private const float ButtonWidth = 96f; + private const float ButtonHeight = 34f; + private const float PanelWidth = 500f; + private const float PanelHeight = 680f; private static DevSaveJumpTool instance; + private readonly Dictionary expanded = new(StringComparer.Ordinal); + private List sections = new(); + private Vector2 scroll; private bool isJumping; private bool menuOpen; - private string status = ""; - private float statusVisibleUntil; - private Vector2 menuScroll; - private GUIStyle buttonStyle; - private GUIStyle headerStyle; - private GUIStyle menuBoxStyle; + private string search = string.Empty; + private string status = string.Empty; + private string details = string.Empty; + private GUIStyle invalidStyle; private GUIStyle statusStyle; - private List sections = new(); - private float cachedContentHeight; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void Bootstrap() { - if (instance != null) - return; - - var existing = FindObjectOfType(); - if (existing != null) + if (instance != null) return; + instance = FindObjectOfType(); + if (instance == null) { - instance = existing; - DontDestroyOnLoad(existing.gameObject); - return; + var go = new GameObject("[Dev] Save Jump Tool"); + instance = go.AddComponent(); } - var toolObject = new GameObject("[Dev] Save Jump Tool"); - DontDestroyOnLoad(toolObject); - instance = toolObject.AddComponent(); + DontDestroyOnLoad(instance.gameObject); } - private void Awake() - { - ReloadCatalog(); - } + private void Awake() => ReloadCatalog(); private void OnGUI() { EnsureStyles(); - - float buttonX = Screen.width - UiMargin - DevButtonWidth; - float buttonY = Screen.height - UiMargin - DevButtonHeight; - var devButtonRect = new Rect(buttonX, buttonY, DevButtonWidth, DevButtonHeight); + var buttonRect = new Rect( + Screen.width - Margin - ButtonWidth, + Screen.height - Margin - ButtonHeight, + ButtonWidth, + ButtonHeight); if (menuOpen) - DrawCommandMenu(devButtonRect); - - string label = isJumping ? "Busy..." : "Dev"; - if (GUI.Button(devButtonRect, label, buttonStyle)) { - if (!menuOpen) - ReloadCatalog(); + var height = Mathf.Min(PanelHeight, Screen.height - Margin * 2f); + var panelRect = new Rect( + Screen.width - Margin - PanelWidth, + Mathf.Max(Margin, buttonRect.y - height - 8f), + PanelWidth, + height); + GUI.Box(panelRect, GUIContent.none); + GUILayout.BeginArea(new Rect(panelRect.x + 10f, panelRect.y + 10f, panelRect.width - 20f, panelRect.height - 20f)); + DrawPanel(); + GUILayout.EndArea(); + } + + GUI.enabled = !isJumping; + if (GUI.Button(buttonRect, isJumping ? "Busy..." : "Dev Saves")) menuOpen = !menuOpen; - } - - if (!string.IsNullOrEmpty(status) && Time.unscaledTime < statusVisibleUntil) - { - float menuHeight = GetMenuHeight(); - float statusY = menuOpen - ? buttonY - menuHeight - StatusHeight - UiMargin - : buttonY - StatusHeight - 8f; - var statusRect = new Rect( - Screen.width - UiMargin - StatusWidth, - Mathf.Max(UiMargin, statusY), - StatusWidth, - StatusHeight); - GUI.Label(statusRect, status, statusStyle); - } - } - - private void DrawCommandMenu(Rect devButtonRect) - { - float menuHeight = GetMenuHeight(); - float menuX = Screen.width - UiMargin - MenuWidth; - float menuY = Mathf.Max(UiMargin, devButtonRect.y - menuHeight - 8f); - var menuRect = new Rect(menuX, menuY, MenuWidth, menuHeight); - - GUI.Box(menuRect, GUIContent.none, menuBoxStyle); - - var viewRect = new Rect(0f, 0f, MenuWidth - 28f, GetContentHeight()); - var scrollRect = new Rect( - menuRect.x + 6f, - menuRect.y + 6f, - menuRect.width - 12f, - menuRect.height - 12f); - - menuScroll = GUI.BeginScrollView(scrollRect, menuScroll, viewRect); - - float y = 0f; - if (sections.Count == 0) - { - GUI.Label(new Rect(0f, y, MenuWidth - 32f, RowHeight), "No TestSaveFiles found", headerStyle); - } - else - { - for (int i = 0; i < sections.Count; i++) - { - var section = sections[i]; - DrawHeader(ref y, section.Title); - if (section.Entries.Count == 0) - { - GUI.Label( - new Rect(0f, y, MenuWidth - 32f, RowHeight), - "(empty — run Test Mode or promote saves)", - statusStyle); - y += RowHeight; - } - else - { - DrawSaveEntries(ref y, section.Entries); - } - - if (i < sections.Count - 1) - y += 6f; - } - } - - GUI.EndScrollView(); - } - - private void DrawHeader(ref float y, string label) - { - GUI.Label(new Rect(0f, y, MenuWidth - 32f, HeaderHeight), label, headerStyle); - y += HeaderHeight; - } - - private void DrawSaveEntries(ref float y, List entries) - { - foreach (var entry in entries) - { - var rect = new Rect(0f, y, MenuWidth - 32f, RowHeight); - var captured = entry; - DrawCommandButton(rect, captured.Label, !isJumping, () => StartCoroutine(LoadDevSave(captured))); - y += RowHeight; - } - } - - private float GetMenuHeight() - { - return Mathf.Min(MaxMenuHeight, GetContentHeight() + 12f); - } - - private float GetContentHeight() - { - if (cachedContentHeight > 0f) - return cachedContentHeight; - - float height = 0f; - if (sections.Count == 0) - { - height = RowHeight; - } - else - { - for (int i = 0; i < sections.Count; i++) - { - height += HeaderHeight; - int rows = Math.Max(1, sections[i].Entries.Count); - height += rows * RowHeight; - if (i < sections.Count - 1) - height += 6f; - } - } - - cachedContentHeight = height; - return height; - } - - private void DrawCommandButton(Rect rect, string label, bool enabled, Action action) - { - GUI.enabled = enabled; - if (GUI.Button(rect, label, buttonStyle)) - { - menuOpen = false; - action?.Invoke(); - } - GUI.enabled = true; } + private void DrawPanel() + { + GUILayout.Label("阶段跳转", GUI.skin.box); + GUILayout.BeginHorizontal(); + GUILayout.Label("搜索", GUILayout.Width(36f)); + search = GUILayout.TextField(search ?? string.Empty); + GUI.enabled = !isJumping; + if (GUILayout.Button("重新扫描", GUILayout.Width(76f))) ReloadCatalog(); + if (GUILayout.Button("逐档冒烟", GUILayout.Width(76f))) StartCoroutine(SmokeTestAll()); + GUI.enabled = true; + GUILayout.EndHorizontal(); + + if (!string.IsNullOrEmpty(status)) + GUILayout.Label(status, statusStyle); + + scroll = GUILayout.BeginScrollView(scroll, GUI.skin.box); + if (sections.Count == 0) + GUILayout.Label(string.IsNullOrEmpty(DevSaveCatalog.LastError) ? "清单中没有阶段" : DevSaveCatalog.LastError, invalidStyle); + + foreach (var section in sections) + { + var visible = section.Entries.Where(MatchesSearch).ToList(); + if (visible.Count == 0 && !string.IsNullOrWhiteSpace(search)) continue; + + if (!expanded.ContainsKey(section.Id)) expanded[section.Id] = true; + var validCount = section.Entries.Count(entry => entry.IsValid); + if (GUILayout.Button($"{(expanded[section.Id] ? "▼" : "▶")} {section.Title} ({validCount}/{section.Entries.Count})")) + expanded[section.Id] = !expanded[section.Id]; + if (!expanded[section.Id]) continue; + + foreach (var entry in visible) + { + GUI.enabled = !isJumping && entry.IsValid; + if (GUILayout.Button($"{entry.Order:000} {entry.Label}")) + StartCoroutine(LoadDevSave(entry)); + GUI.enabled = true; + if (!entry.IsValid) + GUILayout.Label($" 不可用:{entry.Error}", invalidStyle); + } + GUILayout.Space(5f); + } + GUILayout.EndScrollView(); + + if (!string.IsNullOrEmpty(details)) + { + GUILayout.Label("详情"); + GUILayout.TextArea(details, GUILayout.Height(110f)); + } + } + + private bool MatchesSearch(DevSaveEntry entry) + { + if (string.IsNullOrWhiteSpace(search)) return true; + return entry.Label.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0 + || entry.AnchorNode.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0; + } + private IEnumerator LoadDevSave(DevSaveEntry entry) { - if (isJumping) - yield break; + if (isJumping || !entry.IsValid) yield break; + isJumping = true; + status = $"正在跳转:{entry.Label}"; + details = string.Empty; - if (!entry.TryLoadSnapshot(out var snapshot, out var validationError)) + RestoreResult result = null; + yield return SaveRestoreOrchestrator.RestoreFromFile( + entry.SnapshotPathWithoutExtension, + RestoreOptions.DevJump, + value => result = value); + + if (result?.Success == true) { - SetStatus($"Invalid save: {validationError}", 8f); + status = $"已到达:{entry.Label}"; + details = result.Warnings.Count == 0 ? string.Empty : string.Join("\n", result.Warnings); + menuOpen = false; + } + else + { + var errors = result?.Errors ?? Array.Empty(); + status = $"跳转失败:{entry.Label} ({result?.FailedPhase ?? "unknown"})"; + details = errors.Count > 0 ? string.Join("\n", errors) : "恢复流程未返回结果。"; + } + + isJumping = false; + } + + private IEnumerator SmokeTestAll() + { + if (isJumping) yield break; + var entries = sections.SelectMany(section => section.Entries).Where(entry => entry.IsValid).ToList(); + if (entries.Count == 0) + { + status = "没有可执行的有效阶段。"; yield break; } isJumping = true; - try + var failures = new List(); + for (var i = 0; i < entries.Count; i++) { - if (NeedsResetToMainMenu()) - { - SetStatus($"Resetting: {entry.Label}"); - GameManager.Instance.QuitGame(); - - bool mainMenuReady = false; - yield return WaitForMainMenuReady(result => mainMenuReady = result); - if (!mainMenuReady) + var entry = entries[i]; + status = $"冒烟测试 {i + 1}/{entries.Count}:{entry.Label}"; + RestoreResult result = null; + var completed = false; + var routine = StartCoroutine(SaveRestoreOrchestrator.RestoreFromFile( + entry.SnapshotPathWithoutExtension, + RestoreOptions.DevJump, + value => { - SetStatus($"Reset timed out: {entry.Label}", 8f); - yield break; - } - } + result = value; + completed = true; + })); - SetStatus($"Loading: {entry.Label}"); - yield return SaveRestoreOrchestrator.RestoreFromFile(entry.SnapshotPathWithoutExtension); - - if (!ValidateRestoredRuntime(snapshot, out var restoreError)) + const float timeoutSeconds = 60f; + var elapsed = 0f; + while (!completed && elapsed < timeoutSeconds) { - SetStatus($"Restore failed: {restoreError}", 8f); - yield break; + elapsed += Time.unscaledDeltaTime; + yield return null; } - SetStatus($"Loaded: {entry.Label}", 3f); - } - finally - { - isJumping = false; - } - } - - private static bool NeedsResetToMainMenu() - { - var gameManager = GameManager.Instance; - if (gameManager == null) - return false; - - if (gameManager.state.isInGame) - return true; - - var sceneLoader = SceneLoader.Instance; - return sceneLoader != null && !string.IsNullOrEmpty(sceneLoader.CurrentSceneName); - } - - private static IEnumerator WaitForMainMenuReady(Action onCompleted) - { - const float timeoutSeconds = 15f; - float elapsed = 0f; - - while (elapsed < timeoutSeconds) - { - var gameManager = GameManager.Instance; - var sceneLoader = SceneLoader.Instance; - bool sceneCleared = sceneLoader == null || string.IsNullOrEmpty(sceneLoader.CurrentSceneName); - bool sceneIdle = sceneLoader == null || !sceneLoader.IsLoading; - bool notInGame = gameManager == null || !gameManager.state.isInGame; - - if (sceneCleared && sceneIdle && notInGame) + if (!completed) { - onCompleted?.Invoke(true); - yield break; + StopCoroutine(routine); + failures.Add($"{entry.Label}: 超过 {timeoutSeconds:0} 秒"); + break; + } + if (result?.Success != true) + { + failures.Add($"{entry.Label}: {result?.FailedPhase ?? "unknown"} - " + + string.Join("; ", result?.Errors ?? Array.Empty())); } - - elapsed += Time.unscaledDeltaTime; - yield return null; } - Debug.LogWarning("[DevSaveJumpTool] 等待主界面超时,已取消读档。"); - onCompleted?.Invoke(false); - } - - private static bool ValidateRestoredRuntime(SaveSnapshot snapshot, out string error) - { - var sceneLoader = SceneLoader.Instance; - if (sceneLoader == null || sceneLoader.IsLoading) - { - error = "scene loader is not ready"; - return false; - } - - if (!string.Equals(sceneLoader.CurrentSceneName, snapshot.scene?.sceneName, StringComparison.Ordinal)) - { - error = $"scene mismatch ({sceneLoader.CurrentSceneName ?? "none"})"; - return false; - } - - var runner = DialogController.Instance?.DialogueRunner; - if (runner?.YarnProject == null - || !string.Equals(runner.YarnProject.name, snapshot.anchor?.yarnProjectId, StringComparison.Ordinal)) - { - error = $"YarnProject mismatch ({runner?.YarnProject?.name ?? "none"})"; - return false; - } - - if (snapshot.sections != null - && snapshot.sections.ContainsKey(SnapshotProviderIds.Fix) - && (FixSystem.FixSystemCenter.Instance == null - || !FixSystem.FixSystemCenter.Instance.IsDirectorReady)) - { - error = "FixSystem is not ready"; - return false; - } - - error = null; - return true; + status = failures.Count == 0 + ? $"冒烟测试通过:{entries.Count}/{entries.Count}" + : $"冒烟测试失败:{failures.Count} 项"; + details = failures.Count == 0 ? string.Empty : string.Join("\n", failures); + isJumping = false; } private void ReloadCatalog() { sections = DevSaveCatalog.Load(ConstRef.TestSaveFilePath); - cachedContentHeight = 0f; - } - - private void SetStatus(string message, float visibleSeconds = 30f) - { - status = message; - statusVisibleUntil = Time.unscaledTime + visibleSeconds; - Debug.Log($"[DevSaveJumpTool] {message}"); + status = sections.Count == 0 + ? "未读取到可用清单" + : $"已扫描 {sections.Sum(s => s.Entries.Count)} 个阶段,{sections.Sum(s => s.Entries.Count(e => !e.IsValid))} 个不可用"; + details = DevSaveCatalog.LastError ?? string.Empty; } private void EnsureStyles() { - if (buttonStyle != null && statusStyle != null) - return; - - buttonStyle = new GUIStyle(GUI.skin.button) - { - fontSize = 14, - alignment = TextAnchor.MiddleCenter - }; - - headerStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 13, - fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleLeft, - padding = new RectOffset(6, 0, 3, 0) - }; - - menuBoxStyle = new GUIStyle(GUI.skin.box) - { - padding = new RectOffset(6, 6, 6, 6) - }; - - statusStyle = new GUIStyle(GUI.skin.box) - { - fontSize = 13, - alignment = TextAnchor.MiddleLeft, - padding = new RectOffset(8, 8, 4, 4) - }; + invalidStyle ??= new GUIStyle(GUI.skin.label) { wordWrap = true, fontSize = 11 }; + invalidStyle.normal.textColor = new Color(1f, 0.55f, 0.45f); + statusStyle ??= new GUIStyle(GUI.skin.box) { wordWrap = true, alignment = TextAnchor.MiddleLeft }; } } - internal static class DevSaveCatalog + public static class DevSaveCatalog { + public const int CurrentVersion = 1; private const string CatalogFileName = "catalog.json"; + public static string LastError { get; private set; } public static List Load(string root) { + LastError = null; var result = new List(); - if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) - return result; - - var catalogPath = Path.Combine(root, CatalogFileName); - if (File.Exists(catalogPath)) + var catalogPath = Path.Combine(root ?? string.Empty, CatalogFileName); + if (string.IsNullOrWhiteSpace(root) || !File.Exists(catalogPath)) { - try - { - var catalog = JsonConvert.DeserializeObject(File.ReadAllText(catalogPath)); - if (catalog?.sections != null) - { - foreach (var sectionDto in catalog.sections) - { - if (string.IsNullOrWhiteSpace(sectionDto.id)) - continue; - - var sectionDir = Path.Combine(root, sectionDto.id); - var title = string.IsNullOrWhiteSpace(sectionDto.title) ? sectionDto.id : sectionDto.title; - result.Add(BuildSection( - sectionDto.id, - title, - sectionDir, - sectionDto.entries, - sectionDto.expectedSceneName, - sectionDto.expectedYarnProjectId, - sectionDto.allowedNodes)); - } - - return result; - } - } - catch (Exception ex) - { - Debug.LogWarning($"[DevSaveJumpTool] 读取 catalog.json 失败: {ex.Message}"); - } + LastError = $"找不到测试存档清单:{catalogPath}"; + return result; } - foreach (var dir in Directory.GetDirectories(root).OrderBy(d => d, StringComparer.OrdinalIgnoreCase)) + DevSaveCatalogDto catalog; + try { - var id = Path.GetFileName(dir); - result.Add(BuildSection(id, id, dir, null, null, null, null)); + catalog = JsonConvert.DeserializeObject(File.ReadAllText(catalogPath)); + } + catch (Exception ex) + { + LastError = $"清单 JSON 损坏:{ex.Message}"; + return result; + } + + if (catalog == null || catalog.catalogVersion != CurrentVersion) + { + LastError = $"不支持的清单版本:{catalog?.catalogVersion.ToString() ?? "none"}"; + return result; + } + + foreach (var sectionDto in catalog.sections ?? new List()) + { + if (string.IsNullOrWhiteSpace(sectionDto.id)) continue; + var entries = (sectionDto.entries ?? new List()) + .OrderBy(entry => entry.order) + .Select(entry => BuildEntry(root, sectionDto, entry)) + .ToList(); + result.Add(new DevSaveSection(sectionDto.id, + string.IsNullOrWhiteSpace(sectionDto.title) ? sectionDto.id : sectionDto.title, + entries)); } return result; } - private static DevSaveSection BuildSection( - string id, - string title, - string sectionDir, - List explicitEntries, - string expectedSceneName, - string expectedYarnProjectId, - List allowedNodes) + private static DevSaveEntry BuildEntry( + string root, + DevSaveCatalogSectionDto section, + DevSaveCatalogEntryDto dto) { - var entries = new List(); - var root = Directory.GetParent(sectionDir)?.FullName; + var error = ValidateRelativePath(root, dto.path, out var entryDirectory); + var snapshotPath = error == null + ? Path.Combine(entryDirectory, ConstRef.SaveSnapshotFileName) + : string.Empty; + var entry = new DevSaveEntry( + dto.order, + string.IsNullOrWhiteSpace(dto.label) ? dto.anchorNode : dto.label, + dto.anchorNode, + snapshotPath, + section.expectedSceneName, + section.expectedSceneSoName, + section.expectedYarnProjectId, + error); + entry.Validate(); + return entry; + } - if (explicitEntries != null && explicitEntries.Count > 0 && root != null) + private static string ValidateRelativePath(string root, string relativePath, out string fullPath) + { + fullPath = null; + if (string.IsNullOrWhiteSpace(relativePath)) return "清单路径为空"; + if (Path.IsPathRooted(relativePath)) return "清单路径必须是相对路径"; + try { - var latestTestArchives = BuildLatestTestArchiveIndex( - expectedSceneName, - expectedYarnProjectId, - allowedNodes); - - foreach (var entryDto in explicitEntries) - { - if (string.IsNullOrWhiteSpace(entryDto.path)) - continue; - - var relative = entryDto.path.Replace('/', Path.DirectorySeparatorChar); - var entryDir = Path.IsPathRooted(relative) - ? relative - : Path.Combine(root, relative); - var snapshot = Path.Combine(entryDir, ConstRef.SaveSnapshotFileName); - var entry = new DevSaveEntry( - label: string.IsNullOrWhiteSpace(entryDto.label) - ? FormatFolderLabel(Path.GetFileName(entryDir)) - : entryDto.label, - snapshotPathWithoutExtension: snapshot, - expectedSceneName, - expectedYarnProjectId, - allowedNodes); - if (!entry.TryLoadSnapshot(out _, out var error)) - { - if (error == "file missing" - && !string.IsNullOrWhiteSpace(entryDto.anchorNode) - && latestTestArchives.TryGetValue(entryDto.anchorNode, out var archiveSnapshot)) - { - entry = new DevSaveEntry( - label: string.IsNullOrWhiteSpace(entryDto.label) - ? entryDto.anchorNode - : entryDto.label, - snapshotPathWithoutExtension: archiveSnapshot, - expectedSceneName, - expectedYarnProjectId, - allowedNodes); - - if (entry.TryLoadSnapshot(out _, out _)) - { - entries.Add(entry); - continue; - } - } - - Debug.LogWarning($"[DevSaveJumpTool] 跳过无效存档 {entryDto.path}: {error}"); - continue; - } - - entries.Add(entry); - } + var rootFull = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + fullPath = Path.GetFullPath(Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar))); + if (!fullPath.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase)) + return "清单路径越过 TestSaveFiles 根目录"; + return null; } - else if (Directory.Exists(sectionDir)) + catch (Exception ex) { - foreach (var entryDir in Directory.GetDirectories(sectionDir) - .OrderBy(d => d, StringComparer.OrdinalIgnoreCase)) - { - var snapshot = Path.Combine(entryDir, ConstRef.SaveSnapshotFileName); - var entry = new DevSaveEntry( - FormatFolderLabel(Path.GetFileName(entryDir)), - snapshot, - expectedSceneName, - expectedYarnProjectId, - allowedNodes); - if (!entry.TryLoadSnapshot(out _, out var error)) - { - Debug.LogWarning($"[DevSaveJumpTool] 跳过无效存档 {entryDir}: {error}"); - continue; - } - - entries.Add(entry); - } + return $"清单路径无效:{ex.Message}"; } - - return new DevSaveSection(id, title, entries); - } - - private static Dictionary BuildLatestTestArchiveIndex( - string expectedSceneName, - string expectedYarnProjectId, - List allowedNodes) - { - var result = new Dictionary(StringComparer.Ordinal); - var savedAtByNode = new Dictionary(StringComparer.Ordinal); - var archiveRoot = ConstRef.TestAutoSaveArchivePath; - if (!Directory.Exists(archiveRoot) || allowedNodes == null || allowedNodes.Count == 0) - return result; - - foreach (var archiveDir in Directory.GetDirectories(archiveRoot)) - { - var snapshotPath = Path.Combine(archiveDir, ConstRef.SaveSnapshotFileName); - var candidate = new DevSaveEntry( - string.Empty, - snapshotPath, - expectedSceneName, - expectedYarnProjectId, - allowedNodes); - if (!candidate.TryLoadSnapshot(out var snapshot, out _)) - continue; - - var nodeName = snapshot.anchor.nodeName; - var savedAt = snapshot.savedAt ?? string.Empty; - if (savedAtByNode.TryGetValue(nodeName, out var currentSavedAt) - && string.CompareOrdinal(savedAt, currentSavedAt) <= 0) - { - continue; - } - - savedAtByNode[nodeName] = savedAt; - result[nodeName] = snapshotPath; - } - - return result; - } - - private static string FormatFolderLabel(string folderName) - { - if (string.IsNullOrEmpty(folderName)) - return folderName; - - return folderName.Replace('_', ' '); - } - - [Serializable] - private class CatalogDto - { - public List sections; - } - - [Serializable] - private class CatalogSectionDto - { - public string id; - public string title; - public List entries; - public string expectedSceneName; - public string expectedYarnProjectId; - public List allowedNodes; - } - - [Serializable] - private class CatalogEntryDto - { - public string label; - public string path; - public string anchorNode; } } - internal sealed class DevSaveSection + public sealed class DevSaveSection { - public readonly string Id; - public readonly string Title; - public readonly List Entries; - + public string Id { get; } + public string Title { get; } + public List Entries { get; } public DevSaveSection(string id, string title, List entries) { Id = id; @@ -605,100 +339,103 @@ namespace AibisDream } } - internal readonly struct DevSaveEntry + public sealed class DevSaveEntry { - public readonly string Label; - public readonly string SnapshotPathWithoutExtension; - private readonly string expectedSceneName; - private readonly string expectedYarnProjectId; - private readonly List allowedNodes; + public int Order { get; } + public string Label { get; } + public string AnchorNode { get; } + public string SnapshotPathWithoutExtension { get; } + public bool IsValid => string.IsNullOrEmpty(Error); + public string Error { get; private set; } + public SaveSnapshot Snapshot { get; private set; } - public DevSaveEntry( - string label, - string snapshotPathWithoutExtension, - string expectedSceneName, - string expectedYarnProjectId, - List allowedNodes) + private readonly string expectedSceneName; + private readonly string expectedSceneSoName; + private readonly string expectedYarnProjectId; + + public DevSaveEntry(int order, string label, string anchorNode, string snapshotPathWithoutExtension, + string expectedSceneName, string expectedSceneSoName, string expectedYarnProjectId, string initialError = null) { - Label = label; + Order = order; + Label = label ?? string.Empty; + AnchorNode = anchorNode ?? string.Empty; SnapshotPathWithoutExtension = snapshotPathWithoutExtension; this.expectedSceneName = expectedSceneName; + this.expectedSceneSoName = expectedSceneSoName; this.expectedYarnProjectId = expectedYarnProjectId; - this.allowedNodes = allowedNodes; + Error = initialError; } - public bool TryLoadSnapshot(out SaveSnapshot snapshot, out string error) + public void Validate() { - snapshot = null; - error = null; - - if (string.IsNullOrEmpty(SnapshotPathWithoutExtension) - || !File.Exists(SnapshotPathWithoutExtension + ".json")) + if (!string.IsNullOrEmpty(Error)) return; + if (string.IsNullOrEmpty(SnapshotPathWithoutExtension) || !File.Exists(SnapshotPathWithoutExtension + ".json")) { - error = "file missing"; - return false; + Error = "存档文件缺失"; + return; } try { if (SnapshotPersistence.IsLegacyFormat(SnapshotPathWithoutExtension)) { - error = "legacy format is not supported"; - return false; + Error = "不支持旧格式存档"; + return; } - - snapshot = SnapshotPersistence.Load(SnapshotPathWithoutExtension); + Snapshot = SnapshotPersistence.Load(SnapshotPathWithoutExtension); } catch (Exception ex) { - error = $"invalid JSON ({ex.Message})"; - return false; + Error = $"JSON 无法读取:{ex.Message}"; + return; } - if (snapshot == null || snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion) - { - error = $"unsupported schema ({snapshot?.schemaVersion.ToString() ?? "none"})"; - return false; - } - - if (snapshot.scene == null || string.IsNullOrWhiteSpace(snapshot.scene.sceneName)) - { - error = "scene is missing"; - return false; - } - - if (snapshot.anchor == null - || string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId) - || string.IsNullOrWhiteSpace(snapshot.anchor.nodeName)) - { - error = "Yarn anchor is missing"; - return false; - } - - if (!string.IsNullOrWhiteSpace(expectedSceneName) - && !string.Equals(snapshot.scene.sceneName, expectedSceneName, StringComparison.Ordinal)) - { - error = $"scene must be {expectedSceneName}"; - return false; - } - - if (!string.IsNullOrWhiteSpace(expectedYarnProjectId) - && !string.Equals(snapshot.anchor.yarnProjectId, expectedYarnProjectId, StringComparison.Ordinal)) - { - error = $"YarnProject must be {expectedYarnProjectId}"; - return false; - } - - if (allowedNodes != null - && allowedNodes.Count > 0 - && !allowedNodes.Contains(snapshot.anchor.nodeName, StringComparer.Ordinal)) - { - error = $"node is not allowed ({snapshot.anchor.nodeName})"; - return false; - } - - return true; + if (Snapshot == null || Snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion) + Error = $"存档版本不兼容:{Snapshot?.schemaVersion.ToString() ?? "none"}"; + else if (Snapshot.scene == null || string.IsNullOrWhiteSpace(Snapshot.scene.sceneName)) + Error = "存档缺少场景"; + else if (Snapshot.anchor == null || string.IsNullOrWhiteSpace(Snapshot.anchor.nodeName)) + Error = "存档缺少 Yarn 锚点"; + else if (!Matches(expectedSceneName, Snapshot.scene.sceneName)) + Error = $"场景不匹配:{Snapshot.scene.sceneName}"; + else if (!Matches(expectedSceneSoName, Snapshot.anchor.sceneSoName)) + Error = $"TalkSceneSO 不匹配:{Snapshot.anchor.sceneSoName}"; + else if (!Matches(expectedYarnProjectId, Snapshot.anchor.yarnProjectId)) + Error = $"YarnProject 不匹配:{Snapshot.anchor.yarnProjectId}"; + else if (!string.IsNullOrWhiteSpace(AnchorNode) + && !string.Equals(AnchorNode, Snapshot.anchor.nodeName, StringComparison.Ordinal)) + Error = $"节点不匹配:{Snapshot.anchor.nodeName}"; } + + private static bool Matches(string expected, string actual) => + string.IsNullOrWhiteSpace(expected) || string.Equals(expected, actual, StringComparison.Ordinal); + } + + [Serializable] + public sealed class DevSaveCatalogDto + { + public int catalogVersion = DevSaveCatalog.CurrentVersion; + public List sections = new(); + } + + [Serializable] + public sealed class DevSaveCatalogSectionDto + { + public string id; + public string title; + public string expectedSceneName; + public string expectedSceneSoName; + public string expectedYarnProjectId; + public List entries = new(); + } + + [Serializable] + public sealed class DevSaveCatalogEntryDto + { + public int order; + public string label; + public string path; + public string anchorNode; } } #endif