diff --git a/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs b/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs index c6a3e283d..c7a6b9d94 100644 --- a/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs +++ b/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs @@ -17,6 +17,22 @@ namespace AibisDream.EditorTools /// 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"; @@ -100,8 +116,19 @@ namespace AibisDream.EditorTools 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 && Directory.Exists(sectionDir)) + if ((clearSectionFirst || IsHuoshanSection()) && Directory.Exists(sectionDir)) Directory.Delete(sectionDir, recursive: true); Directory.CreateDirectory(sectionDir); @@ -109,11 +136,12 @@ namespace AibisDream.EditorTools int copied = 0; int index = 1; - foreach (var item in candidates.OrderBy(c => c.SavedAt, StringComparer.Ordinal) - .ThenBy(c => c.NodeName, StringComparer.Ordinal)) + foreach (var item in OrderCandidates(candidates)) { var safeNode = SanitizeFolderName(string.IsNullOrEmpty(item.NodeName) ? "no_node" : item.NodeName); - var folderName = $"{index:00}_{safeNode}"; + var folderName = IsHuoshanSection() + ? HuoshanCheckpoints.First(checkpoint => checkpoint.NodeName == item.NodeName).FolderName + : $"{index:00}_{safeNode}"; var destDir = Path.Combine(sectionDir, folderName); Directory.CreateDirectory(destDir); @@ -176,13 +204,42 @@ namespace AibisDream.EditorTools } if (!keepLatestPerNode) - return list.OrderBy(c => c.SavedAt, StringComparer.Ordinal).ToList(); + return FilterHuoshanCheckpoints(list.OrderBy(c => c.SavedAt, StringComparer.Ordinal).ToList()); - return list + 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) @@ -210,18 +267,35 @@ namespace AibisDream.EditorTools } catalog.sections ??= new List(); - if (catalog.sections.All(s => !string.Equals(s.id, sectionId, StringComparison.OrdinalIgnoreCase))) + var section = catalog.sections.FirstOrDefault( + s => string.Equals(s.id, sectionId, StringComparison.OrdinalIgnoreCase)); + if (section == null) { - catalog.sections.Add(new CatalogSectionDto + section = new CatalogSectionDto { id = sectionId, title = sectionId - }); - File.WriteAllText( - catalogPath, - JsonConvert.SerializeObject(catalog, Formatting.Indented), - new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + }; + catalog.sections.Add(section); } + + if (string.Equals(sectionId, HuoshanSectionId, StringComparison.OrdinalIgnoreCase)) + { + 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 + { + label = checkpoint.Label, + path = $"{HuoshanSectionId}/{checkpoint.FolderName}" + }).ToList(); + } + + File.WriteAllText( + catalogPath, + JsonConvert.SerializeObject(catalog, Formatting.Indented), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); } private static void CopyIfExists(string src, string dest) @@ -285,6 +359,17 @@ namespace AibisDream.EditorTools { 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; } } } diff --git a/Assets/Scripts/Game Loop/SceneLoader.cs b/Assets/Scripts/Game Loop/SceneLoader.cs index 3e47e94a7..b70253c2d 100644 --- a/Assets/Scripts/Game Loop/SceneLoader.cs +++ b/Assets/Scripts/Game Loop/SceneLoader.cs @@ -21,6 +21,7 @@ namespace AibisDream private string _currentSceneName; public string CurrentSceneName => _currentSceneName; + public bool IsLoading => _isLoading; public override void OnSingletonInit() { @@ -102,6 +103,7 @@ namespace AibisDream if (_loadHandle.IsValid()) { + DOTween.KillAll(); ResourceSystem.ReleaseSceneLoader(); yield return Addressables.UnloadSceneAsync(_loadHandle); diff --git a/Assets/Scripts/Utility/DevSaveJumpTool.cs b/Assets/Scripts/Utility/DevSaveJumpTool.cs index d1a4ebaee..3e4864298 100644 --- a/Assets/Scripts/Utility/DevSaveJumpTool.cs +++ b/Assets/Scripts/Utility/DevSaveJumpTool.cs @@ -213,26 +213,44 @@ namespace AibisDream if (isJumping) yield break; - string savePath = entry.SnapshotPathWithoutExtension; - if (string.IsNullOrEmpty(savePath) || !File.Exists(savePath + ".json")) + if (!entry.TryLoadSnapshot(out var snapshot, out var validationError)) { - SetStatus($"Save missing: {entry.Label}", 5f); + SetStatus($"Invalid save: {validationError}", 8f); yield break; } isJumping = true; - - if (NeedsResetToMainMenu()) + try { - SetStatus($"Resetting: {entry.Label}"); - GameManager.Instance.QuitGame(); - yield return WaitForMainMenuReady(); - } + if (NeedsResetToMainMenu()) + { + SetStatus($"Resetting: {entry.Label}"); + GameManager.Instance.QuitGame(); - SetStatus($"Loading: {entry.Label}"); - yield return SaveRestoreOrchestrator.RestoreFromFile(savePath); - SetStatus($"Loaded: {entry.Label}", 3f); - isJumping = false; + bool mainMenuReady = false; + yield return WaitForMainMenuReady(result => mainMenuReady = result); + if (!mainMenuReady) + { + SetStatus($"Reset timed out: {entry.Label}", 8f); + yield break; + } + } + + SetStatus($"Loading: {entry.Label}"); + yield return SaveRestoreOrchestrator.RestoreFromFile(entry.SnapshotPathWithoutExtension); + + if (!ValidateRestoredRuntime(snapshot, out var restoreError)) + { + SetStatus($"Restore failed: {restoreError}", 8f); + yield break; + } + + SetStatus($"Loaded: {entry.Label}", 3f); + } + finally + { + isJumping = false; + } } private static bool NeedsResetToMainMenu() @@ -248,7 +266,7 @@ namespace AibisDream return sceneLoader != null && !string.IsNullOrEmpty(sceneLoader.CurrentSceneName); } - private static IEnumerator WaitForMainMenuReady() + private static IEnumerator WaitForMainMenuReady(Action onCompleted) { const float timeoutSeconds = 15f; float elapsed = 0f; @@ -258,16 +276,57 @@ namespace AibisDream 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 && notInGame) + if (sceneCleared && sceneIdle && notInGame) + { + onCompleted?.Invoke(true); yield break; + } elapsed += Time.unscaledDeltaTime; yield return null; } - Debug.LogWarning("[DevSaveJumpTool] 等待主界面超时,继续读档。"); + 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; } private void ReloadCatalog() @@ -341,7 +400,14 @@ namespace AibisDream 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)); + result.Add(BuildSection( + sectionDto.id, + title, + sectionDir, + sectionDto.entries, + sectionDto.expectedSceneName, + sectionDto.expectedYarnProjectId, + sectionDto.allowedNodes)); } return result; @@ -356,7 +422,7 @@ namespace AibisDream foreach (var dir in Directory.GetDirectories(root).OrderBy(d => d, StringComparer.OrdinalIgnoreCase)) { var id = Path.GetFileName(dir); - result.Add(BuildSection(id, id, dir, null)); + result.Add(BuildSection(id, id, dir, null, null, null, null)); } return result; @@ -366,7 +432,10 @@ namespace AibisDream string id, string title, string sectionDir, - List explicitEntries) + List explicitEntries, + string expectedSceneName, + string expectedYarnProjectId, + List allowedNodes) { var entries = new List(); var root = Directory.GetParent(sectionDir)?.FullName; @@ -383,13 +452,21 @@ namespace AibisDream ? relative : Path.Combine(root, relative); var snapshot = Path.Combine(entryDir, ConstRef.SaveSnapshotFileName); - if (!File.Exists(snapshot + ".json")) + 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)) + { + Debug.LogWarning($"[DevSaveJumpTool] 跳过无效存档 {entryDto.path}: {error}"); continue; + } - var label = string.IsNullOrWhiteSpace(entryDto.label) - ? FormatFolderLabel(Path.GetFileName(entryDir)) - : entryDto.label; - entries.Add(new DevSaveEntry(label, snapshot)); + entries.Add(entry); } } else if (Directory.Exists(sectionDir)) @@ -398,10 +475,19 @@ namespace AibisDream .OrderBy(d => d, StringComparer.OrdinalIgnoreCase)) { var snapshot = Path.Combine(entryDir, ConstRef.SaveSnapshotFileName); - if (!File.Exists(snapshot + ".json")) + 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(new DevSaveEntry(FormatFolderLabel(Path.GetFileName(entryDir)), snapshot)); + entries.Add(entry); } } @@ -428,6 +514,9 @@ namespace AibisDream public string id; public string title; public List entries; + public string expectedSceneName; + public string expectedYarnProjectId; + public List allowedNodes; } [Serializable] @@ -456,11 +545,95 @@ namespace AibisDream { public readonly string Label; public readonly string SnapshotPathWithoutExtension; + private readonly string expectedSceneName; + private readonly string expectedYarnProjectId; + private readonly List allowedNodes; - public DevSaveEntry(string label, string snapshotPathWithoutExtension) + public DevSaveEntry( + string label, + string snapshotPathWithoutExtension, + string expectedSceneName, + string expectedYarnProjectId, + List allowedNodes) { Label = label; SnapshotPathWithoutExtension = snapshotPathWithoutExtension; + this.expectedSceneName = expectedSceneName; + this.expectedYarnProjectId = expectedYarnProjectId; + this.allowedNodes = allowedNodes; + } + + public bool TryLoadSnapshot(out SaveSnapshot snapshot, out string error) + { + snapshot = null; + error = null; + + if (string.IsNullOrEmpty(SnapshotPathWithoutExtension) + || !File.Exists(SnapshotPathWithoutExtension + ".json")) + { + error = "file missing"; + return false; + } + + try + { + if (SnapshotPersistence.IsLegacyFormat(SnapshotPathWithoutExtension)) + { + error = "legacy format is not supported"; + return false; + } + + snapshot = SnapshotPersistence.Load(SnapshotPathWithoutExtension); + } + catch (Exception ex) + { + error = $"invalid JSON ({ex.Message})"; + return false; + } + + 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; } } }