feat: 存档系统增加退出节点保存时机

This commit is contained in:
2026-07-26 19:59:56 +08:00
parent 4caf70b554
commit 82c15e8f48
18 changed files with 1195 additions and 159 deletions
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace AibisDream.SaveSystem
{
@@ -16,6 +17,7 @@ namespace AibisDream.SaveSystem
{
public TestSaveCoverageRowKind Kind { get; internal set; }
public string NodeName { get; internal set; }
public string ResumeMode { get; internal set; }
public TestSaveEntry Entry { get; internal set; }
public long SortOrder { get; internal set; }
}
@@ -49,7 +51,7 @@ namespace AibisDream.SaveSystem
var chapter = chapters[chapterIndex];
if (chapter == null) continue;
var expectedNodes = GetExpectedNodes(chapter);
var expectedCheckpoints = GetExpectedCheckpoints(chapter);
var chapterEntries = entries
.Where(item => item.Meta != null
&& string.Equals(item.Meta.sceneSoName, chapter.name, StringComparison.Ordinal)
@@ -60,33 +62,37 @@ namespace AibisDream.SaveSystem
.ToArray();
foreach (var entry in chapterEntries) consumed.Add(entry);
var validByNode = chapterEntries
var validByCheckpoint = chapterEntries
.Where(item => item.IsValid)
.GroupBy(item => item.NodeName, StringComparer.Ordinal)
.GroupBy(
item => BuildCheckpointKey(item.NodeName, item.ResumeMode),
StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group.OrderBy(item => item.Meta.firstSeenOrder).First(),
StringComparer.Ordinal);
var rows = new List<TestSaveCoverageRow>();
foreach (var entry in validByNode.Values.OrderBy(item => item.Meta.firstSeenOrder))
foreach (var entry in validByCheckpoint.Values.OrderBy(item => item.Meta.firstSeenOrder))
{
rows.Add(new TestSaveCoverageRow
{
Kind = TestSaveCoverageRowKind.Recorded,
NodeName = entry.NodeName,
ResumeMode = entry.ResumeMode,
Entry = entry,
SortOrder = entry.Meta.firstSeenOrder
});
}
foreach (var nodeName in expectedNodes
.Where(node => !validByNode.ContainsKey(node))
.OrderBy(node => node, StringComparer.OrdinalIgnoreCase))
foreach (var expectation in expectedCheckpoints.Values
.Where(item => !validByCheckpoint.ContainsKey(item.Key))
.OrderBy(item => item.NodeName, StringComparer.OrdinalIgnoreCase))
{
rows.Add(new TestSaveCoverageRow
{
Kind = TestSaveCoverageRowKind.Missing,
NodeName = nodeName,
NodeName = expectation.NodeName,
ResumeMode = expectation.ResumeMode,
SortOrder = long.MaxValue
});
}
@@ -98,6 +104,7 @@ namespace AibisDream.SaveSystem
{
Kind = TestSaveCoverageRowKind.Invalid,
NodeName = entry.NodeName ?? "(未知节点)",
ResumeMode = entry.ResumeMode,
Entry = entry,
SortOrder = entry.Meta?.firstSeenOrder ?? long.MaxValue
});
@@ -108,8 +115,8 @@ namespace AibisDream.SaveSystem
ChapterId = chapter.name,
Title = string.IsNullOrWhiteSpace(chapter.title) ? chapter.name : chapter.title,
ChapterOrder = chapterIndex,
RecordedCount = validByNode.Keys.Count(expectedNodes.Contains),
ExpectedCount = expectedNodes.Count,
RecordedCount = validByCheckpoint.Keys.Count(expectedCheckpoints.ContainsKey),
ExpectedCount = expectedCheckpoints.Count,
Rows = rows
});
}
@@ -131,6 +138,7 @@ namespace AibisDream.SaveSystem
{
Kind = TestSaveCoverageRowKind.Invalid,
NodeName = item.NodeName ?? "(未知节点)",
ResumeMode = item.ResumeMode,
Entry = item,
SortOrder = item.Meta?.firstSeenOrder ?? long.MaxValue
})
@@ -143,7 +151,14 @@ namespace AibisDream.SaveSystem
public static HashSet<string> GetExpectedNodes(TalkSceneSO chapter)
{
var result = new HashSet<string>(StringComparer.Ordinal);
return GetExpectedCheckpoints(chapter).Values
.Select(item => item.NodeName)
.ToHashSet(StringComparer.Ordinal);
}
private static Dictionary<string, ExpectedCheckpoint> GetExpectedCheckpoints(TalkSceneSO chapter)
{
var result = new Dictionary<string, ExpectedCheckpoint>(StringComparer.Ordinal);
var project = chapter?.yarnProject;
if (project?.Program?.Nodes == null) return result;
@@ -157,14 +172,65 @@ namespace AibisDream.SaveSystem
var tags = string.IsNullOrWhiteSpace(tagsValue)
? Array.Empty<string>()
: tagsValue.Split(Array.Empty<char>(), StringSplitOptions.RemoveEmptyEntries);
if (SavePointEvaluator.EvaluateNodeTagsForAutoSaveSilently(node.Name, tags, out _))
var policy = SavePointEvaluator.ResolveNodePolicy(
node.Name,
tags,
out var reason,
logWarnings: false);
if (reason == SavePointRejectReason.InvalidTagConfiguration)
{
result.Add(node.Name);
Debug.LogError(
$"[TestSaveCoverage] 节点 {node.Name} 的保存标签配置无效: " +
$"[{string.Join(", ", tags)}]");
}
else if (tags.Any(tag =>
string.Equals(tag, "interaction", StringComparison.OrdinalIgnoreCase))
&& tags.Any(tag =>
string.Equals(tag, "save_on_exit", StringComparison.OrdinalIgnoreCase)))
{
Debug.LogWarning(
$"[TestSaveCoverage] 节点 {node.Name} 的 interaction 已包含 save_on_exit 语义," +
"请移除重复标记。");
}
if (policy.Timing == NodeSaveTiming.None)
{
continue;
}
var resumeMode = policy.ResumeMode.ToString();
var key = BuildCheckpointKey(node.Name, resumeMode);
result[key] = new ExpectedCheckpoint(key, node.Name, resumeMode);
}
return result;
}
private static string BuildCheckpointKey(string nodeName, string resumeMode)
{
return $"{nodeName ?? string.Empty}\n{NormalizeResumeMode(resumeMode)}";
}
private static string NormalizeResumeMode(string resumeMode)
{
return string.IsNullOrEmpty(resumeMode)
? nameof(SaveResumeMode.RestartNode)
: resumeMode;
}
private readonly struct ExpectedCheckpoint
{
public readonly string Key;
public readonly string NodeName;
public readonly string ResumeMode;
public ExpectedCheckpoint(string key, string nodeName, string resumeMode)
{
Key = key;
NodeName = nodeName;
ResumeMode = resumeMode;
}
}
}
}
#endif
@@ -30,24 +30,42 @@ namespace AibisDream.SaveSystem
IsRecording = recording;
}
internal static TestSaveRecordRequest CreateRequest(SaveSnapshot snapshot, byte[] thumbnail)
internal static TestSaveRecordRequest CreateRequest(
SaveSnapshot snapshot,
byte[] thumbnail,
string saveTrigger,
string resumeMode,
string sourceNodeName)
{
if (!IsRecording
|| snapshot?.anchor == null
|| string.IsNullOrWhiteSpace(snapshot.anchor.sceneSoName)
|| string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId)
|| string.IsNullOrWhiteSpace(snapshot.anchor.nodeName))
|| string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId))
{
return null;
}
var anchor = snapshot.anchor;
var stateOnly = string.Equals(
resumeMode,
nameof(SaveResumeMode.StateOnly),
StringComparison.Ordinal);
var recordNodeName = stateOnly ? sourceNodeName : anchor.nodeName;
if (string.IsNullOrWhiteSpace(recordNodeName))
{
return null;
}
var chapter = GameManager.Instance?.RuntimeChapters?
.FirstOrDefault(item =>
item != null
&& string.Equals(item.name, anchor.sceneSoName, StringComparison.Ordinal)
&& string.Equals(item.yarnProject?.name, anchor.yarnProjectId, StringComparison.Ordinal));
var dedupeKey = BuildDedupeKey(anchor.sceneSoName, anchor.yarnProjectId, anchor.nodeName);
var dedupeKey = BuildDedupeKey(
anchor.sceneSoName,
anchor.yarnProjectId,
recordNodeName,
resumeMode);
return new TestSaveRecordRequest
{
Snapshot = snapshot,
@@ -58,7 +76,9 @@ namespace AibisDream.SaveSystem
ChapterTitle = string.IsNullOrWhiteSpace(chapter?.title) ? anchor.sceneSoName : chapter.title,
SceneSoName = anchor.sceneSoName,
YarnProjectId = anchor.yarnProjectId,
NodeName = anchor.nodeName,
NodeName = recordNodeName,
SaveTrigger = saveTrigger,
ResumeMode = resumeMode,
SceneName = snapshot.scene?.sceneName,
GameVersion = snapshot.gameVersion
};
@@ -81,9 +101,21 @@ namespace AibisDream.SaveSystem
});
}
public static string BuildDedupeKey(string sceneSoName, string yarnProjectId, string nodeName)
public static string BuildDedupeKey(
string sceneSoName,
string yarnProjectId,
string nodeName,
string resumeMode = null)
{
return $"{sceneSoName ?? string.Empty}\n{yarnProjectId ?? string.Empty}\n{nodeName ?? string.Empty}";
var baseKey =
$"{sceneSoName ?? string.Empty}\n{yarnProjectId ?? string.Empty}\n{nodeName ?? string.Empty}";
return string.IsNullOrEmpty(resumeMode)
|| string.Equals(
resumeMode,
nameof(SaveResumeMode.RestartNode),
StringComparison.Ordinal)
? baseKey
: $"{baseKey}\n{resumeMode}";
}
}
}
@@ -137,6 +137,8 @@ namespace AibisDream.SaveSystem
sceneSoName = request.SceneSoName,
yarnProjectId = request.YarnProjectId,
nodeName = request.NodeName,
saveTrigger = request.SaveTrigger,
resumeMode = request.ResumeMode,
sceneName = request.SceneName,
firstSeenOrder = existingMeta?.firstSeenOrder ?? NextFirstSeenOrder(),
firstRecordedAt = existingMeta?.firstRecordedAt ?? now,
@@ -215,10 +217,11 @@ namespace AibisDream.SaveSystem
return false;
}
var stateOnly = IsStateOnly(entry.Meta?.resumeMode);
if (snapshot?.anchor == null
|| string.IsNullOrWhiteSpace(snapshot.anchor.sceneSoName)
|| string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId)
|| string.IsNullOrWhiteSpace(snapshot.anchor.nodeName))
|| (!stateOnly && string.IsNullOrWhiteSpace(snapshot.anchor.nodeName)))
{
error = "快照缺少完整 Yarn anchor。";
snapshot = null;
@@ -229,7 +232,12 @@ namespace AibisDream.SaveSystem
if (entry.Meta != null
&& (!string.Equals(snapshot.anchor.sceneSoName, entry.Meta.sceneSoName, StringComparison.Ordinal)
|| !string.Equals(snapshot.anchor.yarnProjectId, entry.Meta.yarnProjectId, StringComparison.Ordinal)
|| !string.Equals(snapshot.anchor.nodeName, entry.Meta.nodeName, StringComparison.Ordinal)))
|| (!stateOnly
&& !string.Equals(
snapshot.anchor.nodeName,
entry.Meta.nodeName,
StringComparison.Ordinal))
|| (stateOnly && !string.IsNullOrEmpty(snapshot.anchor.nodeName))))
{
error = "快照 anchor 与 meta 不一致。";
snapshot = null;
@@ -457,6 +465,14 @@ namespace AibisDream.SaveSystem
}
}
private static bool IsStateOnly(string resumeMode)
{
return string.Equals(
resumeMode,
nameof(SaveResumeMode.StateOnly),
StringComparison.Ordinal);
}
private void RecoverTransactions()
{
if (!Directory.Exists(_rootPath))
@@ -28,6 +28,8 @@ namespace AibisDream.SaveSystem
public string sceneSoName;
public string yarnProjectId;
public string nodeName;
public string saveTrigger;
public string resumeMode;
public string sceneName;
public long firstSeenOrder;
public string firstRecordedAt;
@@ -52,6 +54,12 @@ namespace AibisDream.SaveSystem
public string EntryId => Meta?.entryId;
public string ChapterId => Meta?.chapterId;
public string NodeName => Meta?.nodeName;
public string SaveTrigger => string.IsNullOrEmpty(Meta?.saveTrigger)
? "NodeEnter"
: Meta.saveTrigger;
public string ResumeMode => string.IsNullOrEmpty(Meta?.resumeMode)
? "RestartNode"
: Meta.resumeMode;
}
public sealed class TestSaveScanResult
@@ -72,6 +80,8 @@ namespace AibisDream.SaveSystem
public string SceneSoName;
public string YarnProjectId;
public string NodeName;
public string SaveTrigger;
public string ResumeMode;
public string SceneName;
public string GameVersion;
}