465 lines
16 KiB
C#
465 lines
16 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using UnityEngine;
|
||
|
||
namespace AibisDream.SaveSystem
|
||
{
|
||
public enum NodeSaveTiming
|
||
{
|
||
None,
|
||
NodeEnter,
|
||
DialogueExit
|
||
}
|
||
|
||
public enum SaveResumeMode
|
||
{
|
||
RestartNode,
|
||
StateOnly
|
||
}
|
||
|
||
public readonly struct NodeSavePolicy
|
||
{
|
||
public NodeSaveTiming Timing { get; }
|
||
public SaveResumeMode ResumeMode { get; }
|
||
|
||
public NodeSavePolicy(NodeSaveTiming timing, SaveResumeMode resumeMode)
|
||
{
|
||
Timing = timing;
|
||
ResumeMode = resumeMode;
|
||
}
|
||
|
||
public static NodeSavePolicy None =>
|
||
new(NodeSaveTiming.None, SaveResumeMode.RestartNode);
|
||
|
||
public static NodeSavePolicy Enter =>
|
||
new(NodeSaveTiming.NodeEnter, SaveResumeMode.RestartNode);
|
||
|
||
public static NodeSavePolicy Exit =>
|
||
new(NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 可存点被拒绝的原因,供日志与 UI 提示使用。
|
||
/// </summary>
|
||
public enum SavePointRejectReason
|
||
{
|
||
None,
|
||
AutoSaveSuppressed,
|
||
Restoring,
|
||
GamePaused,
|
||
|
||
// 遗留值:早期逻辑曾返回,当前实现中不再使用,保留以避免外部代码编译失败。
|
||
NoActiveNode,
|
||
StartNode,
|
||
FunctionNode,
|
||
NoAutoSave,
|
||
|
||
/// <summary>节点 tag 在默认禁止保存集合中。</summary>
|
||
DeniedByTag,
|
||
|
||
/// <summary>节点显式附加了 no_save 标记。</summary>
|
||
MarkedNoSave,
|
||
|
||
/// <summary>同一节点仍在执行中(detour 返回续跑),非 Fresh 进入。</summary>
|
||
DetourResume,
|
||
|
||
/// <summary>节点类型或保存附加标记互相冲突。</summary>
|
||
InvalidTagConfiguration,
|
||
}
|
||
|
||
/// <summary>
|
||
/// 可存点判定器。
|
||
/// <para>
|
||
/// 基于 Yarn 节点 tag 判定是否允许自动存档:
|
||
/// - 白名单 tag(hub / linear / content)默认允许;
|
||
/// - interaction 或 save_on_exit 在 Dialogue 正常结束时保存 state-only 档;
|
||
/// - 黑名单 tag(start / init / function / detour / center / performance / event / end)默认禁止;
|
||
/// - no_save 附加标记覆盖默认语义;与退出保存语义并存时视为配置冲突;
|
||
/// - 无活跃节点、或节点未声明 tag,默认允许并打 warning;
|
||
/// - 未知 tag 默认允许并打 warning。
|
||
/// </para>
|
||
/// <para>
|
||
/// OnNodeStart 自动档另按 YarnProject 维度跟踪 InProgress 节点:
|
||
/// detour 返回父节点会再次 onNodeStart,若该节点尚未 onNodeComplete 则视为 DetourResume,不自动存。
|
||
/// </para>
|
||
/// </summary>
|
||
public static class SavePointEvaluator
|
||
{
|
||
/// <summary>默认可作为自动存档边界的节点 tag。</summary>
|
||
private static readonly HashSet<string> AutoSaveAllowedTags = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
"hub", // 梦境:场景导航中枢
|
||
"linear", // 梦境:线性剧情推进
|
||
"content", // 维修:阶段内容节点
|
||
};
|
||
|
||
/// <summary>默认禁止作为自动存档边界的节点 tag。</summary>
|
||
private static readonly HashSet<string> AutoSaveDeniedTags = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
"start", // 引擎入口
|
||
"init", // 变量声明/初始化
|
||
"function", // 纯指令函数
|
||
"detour", // 梦境内容片段
|
||
"center", // 维修阶段调度中枢
|
||
"performance", // 维修原子化演出
|
||
"event", // 维修 C# 事件响应入口
|
||
"end", // 维修结束/收尾
|
||
};
|
||
|
||
private static readonly HashSet<string> PrimaryNodeTags = new(
|
||
AutoSaveAllowedTags.Concat(AutoSaveDeniedTags),
|
||
StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
"interaction",
|
||
};
|
||
|
||
private static string _activeProjectId;
|
||
private static readonly HashSet<string> InProgressNodes = new(StringComparer.Ordinal);
|
||
|
||
/// <summary>当前 YarnProject 下尚未 Complete 的节点名(只读,供调试)。</summary>
|
||
public static IReadOnlyCollection<string> InProgressNodeNames => InProgressNodes;
|
||
|
||
/// <summary>切换 YarnProject 或停止对话时重置 InProgress 跟踪。</summary>
|
||
public static void ResetForProject(string projectId)
|
||
{
|
||
InProgressNodes.Clear();
|
||
_activeProjectId = projectId;
|
||
}
|
||
|
||
/// <summary>仅在 projectId 变化时重置,避免同 Project 内误清 InProgress。</summary>
|
||
public static void ResetForProjectIfChanged(string projectId)
|
||
{
|
||
if (string.Equals(_activeProjectId, projectId, StringComparison.Ordinal))
|
||
{
|
||
return;
|
||
}
|
||
|
||
ResetForProject(projectId);
|
||
}
|
||
|
||
/// <summary>读档重进 anchor:标记 InProgress,不写盘。</summary>
|
||
public static void OnRestoreEnterNode(string projectId, string nodeName)
|
||
{
|
||
if (string.IsNullOrEmpty(nodeName))
|
||
{
|
||
return;
|
||
}
|
||
|
||
EnsureProjectScope(projectId);
|
||
InProgressNodes.Add(nodeName);
|
||
}
|
||
|
||
/// <summary>
|
||
/// OnNodeStart 自动档判定:Fresh 进入且 tag 通过则允许;detour 返回续跑则拒绝。
|
||
/// </summary>
|
||
public static bool OnNodeStartForAutoSave(
|
||
string projectId,
|
||
string nodeName,
|
||
IReadOnlyList<string> tags,
|
||
out SavePointRejectReason reason)
|
||
{
|
||
var accepted = OnNodeStartForSavePolicy(
|
||
projectId,
|
||
nodeName,
|
||
tags,
|
||
out var policy,
|
||
out reason);
|
||
return accepted && policy.Timing == NodeSaveTiming.NodeEnter;
|
||
}
|
||
|
||
/// <summary>
|
||
/// OnNodeStart 存档策略判定:Fresh 进入时返回节点的进入/退出保存策略;
|
||
/// detour 返回续跑或全局门控失败时返回 false。
|
||
/// </summary>
|
||
public static bool OnNodeStartForSavePolicy(
|
||
string projectId,
|
||
string nodeName,
|
||
IReadOnlyList<string> tags,
|
||
out NodeSavePolicy policy,
|
||
out SavePointRejectReason reason)
|
||
{
|
||
policy = NodeSavePolicy.None;
|
||
if (!TryPassGlobalSaveGuards(out reason))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
EnsureProjectScope(projectId);
|
||
|
||
if (!string.IsNullOrEmpty(nodeName) && InProgressNodes.Contains(nodeName))
|
||
{
|
||
reason = SavePointRejectReason.DetourResume;
|
||
return false;
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(nodeName))
|
||
{
|
||
InProgressNodes.Add(nodeName);
|
||
}
|
||
|
||
policy = ResolveNodePolicy(nodeName, tags, out reason);
|
||
return policy.Timing != NodeSaveTiming.None;
|
||
}
|
||
|
||
/// <summary>仅 tag / no_save 判定,不含 InProgress detour 门控(供编辑器模拟)。</summary>
|
||
public static bool EvaluateNodeTagsForAutoSave(
|
||
string nodeName,
|
||
IReadOnlyList<string> tags,
|
||
out SavePointRejectReason reason)
|
||
{
|
||
return ResolveNodePolicy(nodeName, tags, out reason, logWarnings: true).Timing
|
||
== NodeSaveTiming.NodeEnter;
|
||
}
|
||
|
||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||
/// <summary>测试存档覆盖率扫描使用;语义与正式判定一致,但不会为全量节点刷 warning。</summary>
|
||
public static bool EvaluateNodeTagsForAutoSaveSilently(
|
||
string nodeName,
|
||
IReadOnlyList<string> tags,
|
||
out SavePointRejectReason reason)
|
||
{
|
||
return ResolveNodePolicy(nodeName, tags, out reason, logWarnings: false).Timing
|
||
== NodeSaveTiming.NodeEnter;
|
||
}
|
||
#endif
|
||
|
||
/// <summary>仅根据节点 tags 解析保存策略,不检查全局门控或 detour 状态。</summary>
|
||
public static NodeSavePolicy ResolveNodePolicy(
|
||
string nodeName,
|
||
IReadOnlyList<string> tags,
|
||
out SavePointRejectReason reason,
|
||
bool logWarnings = true)
|
||
{
|
||
var hasNoSave = IsTagPresent(tags, "no_save");
|
||
var hasSaveOnExit = IsTagPresent(tags, "save_on_exit");
|
||
var primaryTags = FindPrimaryTags(tags);
|
||
var hasInteraction = primaryTags.Exists(
|
||
tag => string.Equals(tag, "interaction", StringComparison.OrdinalIgnoreCase));
|
||
|
||
if (primaryTags.Count > 1)
|
||
{
|
||
if (logWarnings)
|
||
{
|
||
Debug.LogError(
|
||
$"[SavePointEvaluator] 节点 {FormatNodeName(nodeName)} 声明了多个一级类型: " +
|
||
$"[{string.Join(", ", primaryTags)}]。为避免错误存档,已禁用该节点存档。");
|
||
}
|
||
|
||
reason = SavePointRejectReason.InvalidTagConfiguration;
|
||
return NodeSavePolicy.None;
|
||
}
|
||
|
||
if (hasNoSave && (hasInteraction || hasSaveOnExit))
|
||
{
|
||
if (logWarnings)
|
||
{
|
||
Debug.LogError(
|
||
$"[SavePointEvaluator] 节点 {FormatNodeName(nodeName)} 的 no_save 与 " +
|
||
$"{(hasInteraction ? "interaction" : "save_on_exit")} 冲突,已禁用该节点存档。");
|
||
}
|
||
|
||
reason = SavePointRejectReason.InvalidTagConfiguration;
|
||
return NodeSavePolicy.None;
|
||
}
|
||
|
||
if (hasNoSave)
|
||
{
|
||
reason = SavePointRejectReason.MarkedNoSave;
|
||
return NodeSavePolicy.None;
|
||
}
|
||
|
||
if (hasInteraction)
|
||
{
|
||
if (hasSaveOnExit && logWarnings)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[SavePointEvaluator] 节点 {FormatNodeName(nodeName)} 的 interaction 已包含 " +
|
||
"save_on_exit 语义,无需重复标记。");
|
||
}
|
||
|
||
reason = SavePointRejectReason.None;
|
||
return NodeSavePolicy.Exit;
|
||
}
|
||
|
||
if (hasSaveOnExit)
|
||
{
|
||
reason = SavePointRejectReason.None;
|
||
return NodeSavePolicy.Exit;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(nodeName) || tags == null || tags.Count == 0)
|
||
{
|
||
if (logWarnings && !string.IsNullOrEmpty(nodeName) && (tags == null || tags.Count == 0))
|
||
{
|
||
Debug.LogWarning(
|
||
$"[SavePointEvaluator] 节点 {nodeName} 未声明任何 tag,默认允许进入时自动存档。请补全节点类型标签。");
|
||
}
|
||
|
||
reason = SavePointRejectReason.None;
|
||
return NodeSavePolicy.Enter;
|
||
}
|
||
|
||
var primaryTag = primaryTags.Count == 1 ? primaryTags[0] : null;
|
||
if (primaryTag == null)
|
||
{
|
||
if (logWarnings)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[SavePointEvaluator] 节点 {nodeName} 的 tags [{string.Join(", ", tags)}] " +
|
||
"未声明保存语义,默认允许进入时自动存档。");
|
||
}
|
||
|
||
reason = SavePointRejectReason.None;
|
||
return NodeSavePolicy.Enter;
|
||
}
|
||
|
||
if (AutoSaveDeniedTags.Contains(primaryTag))
|
||
{
|
||
reason = SavePointRejectReason.DeniedByTag;
|
||
return NodeSavePolicy.None;
|
||
}
|
||
|
||
reason = SavePointRejectReason.None;
|
||
return NodeSavePolicy.Enter;
|
||
}
|
||
|
||
/// <summary>节点执行完毕,移出 InProgress。</summary>
|
||
public static void OnNodeComplete(string projectId, string nodeName)
|
||
{
|
||
if (string.IsNullOrEmpty(nodeName))
|
||
{
|
||
return;
|
||
}
|
||
|
||
EnsureProjectScope(projectId);
|
||
InProgressNodes.Remove(nodeName);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 当前是否可以执行 Yarn 显式 <c><<save>></c> 命令。
|
||
/// 仅检查全局门控,不检查节点 tag / no_save。
|
||
/// </summary>
|
||
public static bool CanExplicitSave(out SavePointRejectReason reason)
|
||
{
|
||
if (!TryPassGlobalSaveGuards(out reason))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
reason = SavePointRejectReason.None;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 当前是否可以自动存档(不含 detour resume 判定;供 TryAutoSave 等无节点上下文路径)。
|
||
/// </summary>
|
||
public static bool CanAutoSave(out SavePointRejectReason reason)
|
||
{
|
||
if (!TryPassGlobalSaveGuards(out reason))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var (nodeName, tags) = DialogController.Instance != null
|
||
? DialogController.Instance.GetCurrentNodeContext()
|
||
: (null, null);
|
||
|
||
return ResolveNodePolicy(nodeName, tags, out reason).Timing == NodeSaveTiming.NodeEnter;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 当前是否可以手动存档。
|
||
/// 手动档 = 复制最近落盘的自动档(含 OnNodeStart 与显式 <c><<save>></c>)。
|
||
/// </summary>
|
||
public static bool CanManualSave(out SavePointRejectReason reason)
|
||
{
|
||
if (!TryPassGlobalSaveGuards(out reason))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (!SlotDirectory.Exists(SlotIndex.Auto))
|
||
{
|
||
reason = SavePointRejectReason.NoAutoSave;
|
||
return false;
|
||
}
|
||
|
||
reason = SavePointRejectReason.None;
|
||
return true;
|
||
}
|
||
|
||
private static void EnsureProjectScope(string projectId)
|
||
{
|
||
if (!string.Equals(_activeProjectId, projectId, StringComparison.Ordinal))
|
||
{
|
||
ResetForProject(projectId);
|
||
}
|
||
}
|
||
|
||
private static bool TryPassGlobalSaveGuards(out SavePointRejectReason reason)
|
||
{
|
||
if (SaveRestoreOrchestrator.IsAutoSaveSuppressed)
|
||
{
|
||
reason = SavePointRejectReason.AutoSaveSuppressed;
|
||
return false;
|
||
}
|
||
|
||
if (SaveRestoreOrchestrator.IsRestoring)
|
||
{
|
||
reason = SavePointRejectReason.Restoring;
|
||
return false;
|
||
}
|
||
|
||
if (GameManager.Instance != null && GameManager.Session.IsPaused)
|
||
{
|
||
reason = SavePointRejectReason.GamePaused;
|
||
return false;
|
||
}
|
||
|
||
reason = SavePointRejectReason.None;
|
||
return true;
|
||
}
|
||
|
||
private static bool IsTagPresent(IReadOnlyList<string> tags, string tag)
|
||
{
|
||
if (tags == null) return false;
|
||
|
||
foreach (var t in tags)
|
||
{
|
||
if (string.Equals(t, tag, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static List<string> FindPrimaryTags(IReadOnlyList<string> tags)
|
||
{
|
||
var result = new List<string>();
|
||
if (tags == null)
|
||
{
|
||
return result;
|
||
}
|
||
|
||
foreach (var tag in tags)
|
||
{
|
||
if (PrimaryNodeTags.Contains(tag)
|
||
&& !result.Exists(item => string.Equals(item, tag, StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
result.Add(tag);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static string FormatNodeName(string nodeName)
|
||
{
|
||
return string.IsNullOrEmpty(nodeName) ? "(无节点)" : nodeName;
|
||
}
|
||
}
|
||
}
|