Files

465 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 判定是否允许自动存档:
/// - 白名单 taghub / linear / content)默认允许;
/// - interaction 或 save_on_exit 在 Dialogue 正常结束时保存 state-only 档;
/// - 黑名单 tagstart / 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>&lt;&lt;save&gt;&gt;</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>&lt;&lt;save&gt;&gt;</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;
}
}
}