feat: 存档系统增加退出节点保存时机
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user