feat(save): 增强开发存档跳转与严格校验
This commit is contained in:
@@ -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<string> Errors { get; internal set; } = Array.Empty<string>();
|
||||
public IReadOnlyList<string> Warnings { get; internal set; } = Array.Empty<string>();
|
||||
public IReadOnlyList<string> Log { get; internal set; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 快照提供者契约:将某一子系统的运行时状态转换为纯数据 DTO。
|
||||
/// 还原侧由同步 / 异步子接口显式声明,避免 Provider 内部 fire-and-forget。
|
||||
@@ -56,6 +80,19 @@ namespace AibisDream.SaveSystem
|
||||
public SaveSnapshot Snapshot { get; }
|
||||
public bool StrictMode { get; }
|
||||
public Action<string> LogStep { get; }
|
||||
public string CurrentPhase { get; private set; }
|
||||
public IReadOnlyList<string> Warnings => _warnings;
|
||||
public IReadOnlyList<string> Errors => _errors;
|
||||
public bool HasErrors => _errors.Count > 0;
|
||||
|
||||
private readonly List<string> _warnings = new();
|
||||
private readonly List<string> _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}");
|
||||
}
|
||||
|
||||
@@ -154,20 +154,70 @@ namespace AibisDream.SaveSystem
|
||||
/// <summary>从文件读档并还原;自动区分新快照格式与 legacy 格式。</summary>
|
||||
public static IEnumerator RestoreFromFile(string savePath)
|
||||
{
|
||||
if (SnapshotPersistence.IsLegacyFormat(savePath))
|
||||
yield return RestoreFromFile(savePath, RestoreOptions.Default, null);
|
||||
}
|
||||
|
||||
/// <summary>从文件恢复并返回结构化结果;开发跳转使用严格校验和完整会话清理。</summary>
|
||||
public static IEnumerator RestoreFromFile(
|
||||
string savePath,
|
||||
RestoreOptions options,
|
||||
Action<RestoreResult> 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<string>(_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<string>(context.Errors);
|
||||
result.Warnings = new List<string>(context.Warnings);
|
||||
result.Log = new List<string>(_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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读档前对齐「已进入游戏」的 UI 与输入状态。
|
||||
/// 编辑器测试工具等路径可能跳过 <see cref="GameLoopEnum.GameStart"/>,导致 TerminalPanel 未关闭、
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user