feat: 测试存档功能
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AibisDream.SaveSystem
|
||||
{
|
||||
public enum TestSaveCoverageRowKind
|
||||
{
|
||||
Recorded,
|
||||
Missing,
|
||||
Invalid
|
||||
}
|
||||
|
||||
public sealed class TestSaveCoverageRow
|
||||
{
|
||||
public TestSaveCoverageRowKind Kind { get; internal set; }
|
||||
public string NodeName { get; internal set; }
|
||||
public TestSaveEntry Entry { get; internal set; }
|
||||
public long SortOrder { get; internal set; }
|
||||
}
|
||||
|
||||
public sealed class TestSaveChapterCoverage
|
||||
{
|
||||
public string ChapterId { get; internal set; }
|
||||
public string Title { get; internal set; }
|
||||
public int ChapterOrder { get; internal set; }
|
||||
public int RecordedCount { get; internal set; }
|
||||
public int ExpectedCount { get; internal set; }
|
||||
public IReadOnlyList<TestSaveCoverageRow> Rows { get; internal set; } = Array.Empty<TestSaveCoverageRow>();
|
||||
public bool IsOrphanGroup { get; internal set; }
|
||||
}
|
||||
|
||||
public static class TestSaveCoverage
|
||||
{
|
||||
public const string OrphanChapterId = "__invalid_or_orphan__";
|
||||
|
||||
public static IReadOnlyList<TestSaveChapterCoverage> Build(
|
||||
IReadOnlyList<TalkSceneSO> chapters,
|
||||
IReadOnlyList<TestSaveEntry> entries)
|
||||
{
|
||||
chapters ??= Array.Empty<TalkSceneSO>();
|
||||
entries ??= Array.Empty<TestSaveEntry>();
|
||||
var result = new List<TestSaveChapterCoverage>();
|
||||
var consumed = new HashSet<TestSaveEntry>();
|
||||
|
||||
for (var chapterIndex = 0; chapterIndex < chapters.Count; chapterIndex++)
|
||||
{
|
||||
var chapter = chapters[chapterIndex];
|
||||
if (chapter == null) continue;
|
||||
|
||||
var expectedNodes = GetExpectedNodes(chapter);
|
||||
var chapterEntries = entries
|
||||
.Where(item => item.Meta != null
|
||||
&& string.Equals(item.Meta.sceneSoName, chapter.name, StringComparison.Ordinal)
|
||||
&& string.Equals(
|
||||
item.Meta.yarnProjectId,
|
||||
chapter.yarnProject?.name,
|
||||
StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
foreach (var entry in chapterEntries) consumed.Add(entry);
|
||||
|
||||
var validByNode = chapterEntries
|
||||
.Where(item => item.IsValid)
|
||||
.GroupBy(item => item.NodeName, 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))
|
||||
{
|
||||
rows.Add(new TestSaveCoverageRow
|
||||
{
|
||||
Kind = TestSaveCoverageRowKind.Recorded,
|
||||
NodeName = entry.NodeName,
|
||||
Entry = entry,
|
||||
SortOrder = entry.Meta.firstSeenOrder
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var nodeName in expectedNodes
|
||||
.Where(node => !validByNode.ContainsKey(node))
|
||||
.OrderBy(node => node, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
rows.Add(new TestSaveCoverageRow
|
||||
{
|
||||
Kind = TestSaveCoverageRowKind.Missing,
|
||||
NodeName = nodeName,
|
||||
SortOrder = long.MaxValue
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var entry in chapterEntries.Where(item => !item.IsValid)
|
||||
.OrderBy(item => item.Meta?.firstSeenOrder ?? long.MaxValue))
|
||||
{
|
||||
rows.Add(new TestSaveCoverageRow
|
||||
{
|
||||
Kind = TestSaveCoverageRowKind.Invalid,
|
||||
NodeName = entry.NodeName ?? "(未知节点)",
|
||||
Entry = entry,
|
||||
SortOrder = entry.Meta?.firstSeenOrder ?? long.MaxValue
|
||||
});
|
||||
}
|
||||
|
||||
result.Add(new TestSaveChapterCoverage
|
||||
{
|
||||
ChapterId = chapter.name,
|
||||
Title = string.IsNullOrWhiteSpace(chapter.title) ? chapter.name : chapter.title,
|
||||
ChapterOrder = chapterIndex,
|
||||
RecordedCount = validByNode.Keys.Count(expectedNodes.Contains),
|
||||
ExpectedCount = expectedNodes.Count,
|
||||
Rows = rows
|
||||
});
|
||||
}
|
||||
|
||||
var orphanEntries = entries.Where(item => !consumed.Contains(item)).ToArray();
|
||||
if (orphanEntries.Length > 0)
|
||||
{
|
||||
result.Add(new TestSaveChapterCoverage
|
||||
{
|
||||
ChapterId = OrphanChapterId,
|
||||
Title = "无效 / 已脱离当前流程",
|
||||
ChapterOrder = int.MaxValue,
|
||||
RecordedCount = 0,
|
||||
ExpectedCount = 0,
|
||||
IsOrphanGroup = true,
|
||||
Rows = orphanEntries
|
||||
.OrderBy(item => item.Meta?.firstSeenOrder ?? long.MaxValue)
|
||||
.Select(item => new TestSaveCoverageRow
|
||||
{
|
||||
Kind = TestSaveCoverageRowKind.Invalid,
|
||||
NodeName = item.NodeName ?? "(未知节点)",
|
||||
Entry = item,
|
||||
SortOrder = item.Meta?.firstSeenOrder ?? long.MaxValue
|
||||
})
|
||||
.ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static HashSet<string> GetExpectedNodes(TalkSceneSO chapter)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.Ordinal);
|
||||
var project = chapter?.yarnProject;
|
||||
if (project?.Program?.Nodes == null) return result;
|
||||
|
||||
foreach (var pair in project.Program.Nodes)
|
||||
{
|
||||
var node = pair.Value;
|
||||
if (node == null || node.Name.StartsWith("$", StringComparison.Ordinal)) continue;
|
||||
var tagsValue = node.Headers
|
||||
.FirstOrDefault(header => string.Equals(header.Key, "tags", StringComparison.OrdinalIgnoreCase))
|
||||
?.Value;
|
||||
var tags = string.IsNullOrWhiteSpace(tagsValue)
|
||||
? Array.Empty<string>()
|
||||
: tagsValue.Split(Array.Empty<char>(), StringSplitOptions.RemoveEmptyEntries);
|
||||
if (SavePointEvaluator.EvaluateNodeTagsForAutoSaveSilently(node.Name, tags, out _))
|
||||
{
|
||||
result.Add(node.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 24a6a2c8e252b364f82be36267027fa1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,90 @@
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AibisDream.Utility;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.SaveSystem
|
||||
{
|
||||
/// <summary>正式自动档成功后使用的非阻塞测试存档旁路。</summary>
|
||||
public static class TestSaveRecorder
|
||||
{
|
||||
private static TestSaveRepository _repository;
|
||||
|
||||
public static bool IsRecording { get; private set; }
|
||||
public static TestSaveRepository Repository =>
|
||||
_repository ??= new TestSaveRepository(ConstRef.TestSavePath);
|
||||
|
||||
public static event Action LibraryChanged;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetRuntimeState()
|
||||
{
|
||||
IsRecording = false;
|
||||
_repository = null;
|
||||
}
|
||||
|
||||
public static void SetRecording(bool recording)
|
||||
{
|
||||
IsRecording = recording;
|
||||
}
|
||||
|
||||
internal static TestSaveRecordRequest CreateRequest(SaveSnapshot snapshot, byte[] thumbnail)
|
||||
{
|
||||
if (!IsRecording
|
||||
|| snapshot?.anchor == null
|
||||
|| string.IsNullOrWhiteSpace(snapshot.anchor.sceneSoName)
|
||||
|| string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId)
|
||||
|| string.IsNullOrWhiteSpace(snapshot.anchor.nodeName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var anchor = snapshot.anchor;
|
||||
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);
|
||||
return new TestSaveRecordRequest
|
||||
{
|
||||
Snapshot = snapshot,
|
||||
Thumbnail = thumbnail,
|
||||
DedupeKey = dedupeKey,
|
||||
EntryId = TestSaveRepository.StableHash(dedupeKey),
|
||||
ChapterId = chapter?.name ?? anchor.sceneSoName,
|
||||
ChapterTitle = string.IsNullOrWhiteSpace(chapter?.title) ? anchor.sceneSoName : chapter.title,
|
||||
SceneSoName = anchor.sceneSoName,
|
||||
YarnProjectId = anchor.yarnProjectId,
|
||||
NodeName = anchor.nodeName,
|
||||
SceneName = snapshot.scene?.sceneName,
|
||||
GameVersion = snapshot.gameVersion
|
||||
};
|
||||
}
|
||||
|
||||
internal static void Enqueue(TestSaveRecordRequest request)
|
||||
{
|
||||
if (request == null) return;
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Repository.Record(request);
|
||||
LibraryChanged?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[TestSaveRecorder] 测试存档旁路写入失败,不影响正式存档:{ex}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static string BuildDedupeKey(string sceneSoName, string yarnProjectId, string nodeName)
|
||||
{
|
||||
return $"{sceneSoName ?? string.Empty}\n{yarnProjectId ?? string.Empty}\n{nodeName ?? string.Empty}";
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bd683e80924d641498ce0ea226d9f633
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,620 @@
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AibisDream.SaveSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// 机器本地测试存档仓库。目录级 staging/backup 交换保证替换同节点时不会留下半份快照。
|
||||
/// </summary>
|
||||
public sealed class TestSaveRepository
|
||||
{
|
||||
public const string SnapshotFileName = "snapshot.json";
|
||||
public const string MetaFileName = "meta.json";
|
||||
public const string ThumbnailFileName = "thumbnail.png";
|
||||
|
||||
private const string StagingSuffix = ".__staging";
|
||||
private const string BackupSuffix = ".__backup";
|
||||
private readonly object _gate = new();
|
||||
private readonly string _rootPath;
|
||||
private readonly string _rootPathWithSeparator;
|
||||
private readonly Dictionary<string, string> _validationFailures =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public TestSaveRepository(string rootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
throw new ArgumentException("Test save root path is required.", nameof(rootPath));
|
||||
}
|
||||
|
||||
_rootPath = Path.GetFullPath(rootPath);
|
||||
_rootPathWithSeparator = _rootPath.TrimEnd(
|
||||
Path.DirectorySeparatorChar,
|
||||
Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
public string RootPath => _rootPath;
|
||||
|
||||
public TestSaveScanResult Scan(string currentGameVersion = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RecoverTransactions();
|
||||
if (!Directory.Exists(_rootPath))
|
||||
{
|
||||
return new TestSaveScanResult();
|
||||
}
|
||||
|
||||
var entries = new List<TestSaveEntry>();
|
||||
foreach (var metaPath in Directory.EnumerateFiles(
|
||||
_rootPath,
|
||||
MetaFileName,
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
var directory = Path.GetDirectoryName(metaPath);
|
||||
if (string.IsNullOrEmpty(directory)
|
||||
|| IsTransactionDirectory(directory)
|
||||
|| !TryResolveInsideRoot(directory, out var safeDirectory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(ReadEntryMeta(safeDirectory, currentGameVersion));
|
||||
}
|
||||
|
||||
// 没有 meta 的目录也要保留为可清理的无效条目。
|
||||
foreach (var snapshotPath in Directory.EnumerateFiles(
|
||||
_rootPath,
|
||||
SnapshotFileName,
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
var directory = Path.GetDirectoryName(snapshotPath);
|
||||
if (string.IsNullOrEmpty(directory)
|
||||
|| IsTransactionDirectory(directory)
|
||||
|| File.Exists(Path.Combine(directory, MetaFileName))
|
||||
|| entries.Any(item => PathsEqual(item.DirectoryPath, directory)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(CreateInvalidEntry(
|
||||
directory,
|
||||
TestSaveEntryStatus.CorruptMeta,
|
||||
"缺少 meta.json。"));
|
||||
}
|
||||
|
||||
var ordered = entries
|
||||
.OrderBy(item => item.Meta?.firstSeenOrder ?? long.MaxValue)
|
||||
.ThenBy(item => item.DirectoryPath, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
return new TestSaveScanResult
|
||||
{
|
||||
Entries = ordered,
|
||||
ValidCount = ordered.Count(item => item.IsValid),
|
||||
InvalidCount = ordered.Count(item => !item.IsValid)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public TestSaveEntry Record(TestSaveRecordRequest request)
|
||||
{
|
||||
if (request?.Snapshot == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
Directory.CreateDirectory(_rootPath);
|
||||
RecoverTransactions();
|
||||
|
||||
var chapterDirectoryName = $"{Slug(request.ChapterId, "chapter")}-{ShortHash(request.SceneSoName)}";
|
||||
var entryDirectoryName = $"{Slug(request.NodeName, "node")}-{ShortHash(request.DedupeKey)}";
|
||||
var chapterDirectory = ResolveInsideRoot(chapterDirectoryName);
|
||||
var finalDirectory = ResolveInsideRoot(chapterDirectoryName, entryDirectoryName);
|
||||
var stagingDirectory = finalDirectory + StagingSuffix;
|
||||
var backupDirectory = finalDirectory + BackupSuffix;
|
||||
|
||||
Directory.CreateDirectory(chapterDirectory);
|
||||
DeleteDirectoryIfPresent(stagingDirectory);
|
||||
DeleteDirectoryIfPresent(backupDirectory);
|
||||
Directory.CreateDirectory(stagingDirectory);
|
||||
|
||||
var existingMeta = TryReadMeta(Path.Combine(finalDirectory, MetaFileName));
|
||||
var now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
var meta = new TestSaveMeta
|
||||
{
|
||||
entryId = request.EntryId,
|
||||
dedupeKey = request.DedupeKey,
|
||||
chapterId = request.ChapterId,
|
||||
chapterTitle = request.ChapterTitle,
|
||||
sceneSoName = request.SceneSoName,
|
||||
yarnProjectId = request.YarnProjectId,
|
||||
nodeName = request.NodeName,
|
||||
sceneName = request.SceneName,
|
||||
firstSeenOrder = existingMeta?.firstSeenOrder ?? NextFirstSeenOrder(),
|
||||
firstRecordedAt = existingMeta?.firstRecordedAt ?? now,
|
||||
lastRecordedAt = now,
|
||||
snapshotSchemaVersion = request.Snapshot.schemaVersion,
|
||||
gameVersion = request.GameVersion,
|
||||
hasThumbnail = request.Thumbnail is { Length: > 0 }
|
||||
};
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(stagingDirectory, SnapshotFileName),
|
||||
SnapshotPersistence.Serialize(request.Snapshot),
|
||||
Encoding.UTF8);
|
||||
File.WriteAllText(
|
||||
Path.Combine(stagingDirectory, MetaFileName),
|
||||
JsonConvert.SerializeObject(meta, Formatting.Indented),
|
||||
Encoding.UTF8);
|
||||
if (meta.hasThumbnail)
|
||||
{
|
||||
File.WriteAllBytes(Path.Combine(stagingDirectory, ThumbnailFileName), request.Thumbnail);
|
||||
}
|
||||
|
||||
if (Directory.Exists(finalDirectory))
|
||||
{
|
||||
Directory.Move(finalDirectory, backupDirectory);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Move(stagingDirectory, finalDirectory);
|
||||
DeleteDirectoryIfPresent(backupDirectory);
|
||||
_validationFailures.Remove(finalDirectory);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!Directory.Exists(finalDirectory) && Directory.Exists(backupDirectory))
|
||||
{
|
||||
Directory.Move(backupDirectory, finalDirectory);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
return ReadEntryMeta(finalDirectory, request.GameVersion);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryLoad(TestSaveEntry entry, out SaveSnapshot snapshot, out string error)
|
||||
{
|
||||
snapshot = null;
|
||||
error = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (entry == null || !TryResolveInsideRoot(entry.DirectoryPath, out var directory))
|
||||
{
|
||||
error = "测试存档路径不在仓库目录内。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var snapshotPath = Path.Combine(directory, SnapshotFileName);
|
||||
if (!File.Exists(snapshotPath))
|
||||
{
|
||||
error = "缺少 snapshot.json。";
|
||||
RememberValidationFailure(directory, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
snapshot = SnapshotPersistence.Deserialize(File.ReadAllText(snapshotPath, Encoding.UTF8));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"snapshot.json 无法解析:{ex.Message}";
|
||||
RememberValidationFailure(directory, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (snapshot?.anchor == null
|
||||
|| string.IsNullOrWhiteSpace(snapshot.anchor.sceneSoName)
|
||||
|| string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId)
|
||||
|| string.IsNullOrWhiteSpace(snapshot.anchor.nodeName))
|
||||
{
|
||||
error = "快照缺少完整 Yarn anchor。";
|
||||
snapshot = null;
|
||||
RememberValidationFailure(directory, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
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)))
|
||||
{
|
||||
error = "快照 anchor 与 meta 不一致。";
|
||||
snapshot = null;
|
||||
RememberValidationFailure(directory, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
_validationFailures.Remove(directory);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Validate(TestSaveEntry entry, out string error)
|
||||
{
|
||||
return TryLoad(entry, out _, out error);
|
||||
}
|
||||
|
||||
public bool Delete(TestSaveEntry entry, out string error)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (entry == null || !TryResolveInsideRoot(entry.DirectoryPath, out var directory))
|
||||
{
|
||||
error = "拒绝删除仓库目录外的路径。";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
DeleteDirectoryIfPresent(directory);
|
||||
_validationFailures.Remove(directory);
|
||||
RemoveEmptyParents(Path.GetDirectoryName(directory));
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int DeleteInvalid(out string error)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var invalid = Scan().Entries.Where(item => !item.IsValid).ToArray();
|
||||
foreach (var entry in invalid)
|
||||
{
|
||||
if (TryResolveInsideRoot(entry.DirectoryPath, out var directory))
|
||||
{
|
||||
DeleteDirectoryIfPresent(directory);
|
||||
_validationFailures.Remove(directory);
|
||||
}
|
||||
}
|
||||
|
||||
RemoveEmptyDirectories();
|
||||
error = null;
|
||||
return invalid.Length;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool Clear(out string error)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_rootPath))
|
||||
{
|
||||
Directory.Delete(_rootPath, true);
|
||||
}
|
||||
|
||||
_validationFailures.Clear();
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TestSaveEntry ReadEntryMeta(string directory, string currentGameVersion)
|
||||
{
|
||||
if (!TryResolveInsideRoot(directory, out var safeDirectory))
|
||||
{
|
||||
return CreateInvalidEntry(directory, TestSaveEntryStatus.UnsafePath, "路径越过测试存档根目录。");
|
||||
}
|
||||
|
||||
TestSaveMeta meta;
|
||||
try
|
||||
{
|
||||
meta = JsonConvert.DeserializeObject<TestSaveMeta>(
|
||||
File.ReadAllText(Path.Combine(safeDirectory, MetaFileName), Encoding.UTF8));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CreateInvalidEntry(
|
||||
safeDirectory,
|
||||
TestSaveEntryStatus.CorruptMeta,
|
||||
$"meta.json 无法解析:{ex.Message}");
|
||||
}
|
||||
|
||||
if (meta == null)
|
||||
{
|
||||
return CreateInvalidEntry(safeDirectory, TestSaveEntryStatus.CorruptMeta, "meta.json 内容为空。");
|
||||
}
|
||||
|
||||
var entry = CreateEntry(safeDirectory, meta);
|
||||
if (meta.libraryVersion != TestSaveMeta.CurrentLibraryVersion)
|
||||
{
|
||||
entry.Status = TestSaveEntryStatus.CorruptMeta;
|
||||
entry.StatusMessage = $"测试存档库版本 {meta.libraryVersion} 不受支持。";
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(meta.sceneSoName)
|
||||
|| string.IsNullOrWhiteSpace(meta.yarnProjectId)
|
||||
|| string.IsNullOrWhiteSpace(meta.nodeName)
|
||||
|| string.IsNullOrWhiteSpace(meta.dedupeKey))
|
||||
{
|
||||
entry.Status = TestSaveEntryStatus.InvalidAnchor;
|
||||
entry.StatusMessage = "meta 缺少完整 Yarn anchor。";
|
||||
}
|
||||
else if (!File.Exists(entry.SnapshotPath))
|
||||
{
|
||||
entry.Status = TestSaveEntryStatus.MissingSnapshot;
|
||||
entry.StatusMessage = "缺少 snapshot.json。";
|
||||
}
|
||||
else if (meta.snapshotSchemaVersion != SaveSnapshotSchema.CurrentVersion)
|
||||
{
|
||||
entry.Status = TestSaveEntryStatus.SchemaMismatch;
|
||||
entry.StatusMessage =
|
||||
$"快照结构版本 {meta.snapshotSchemaVersion},当前版本 {SaveSnapshotSchema.CurrentVersion}。";
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.Status = TestSaveEntryStatus.Valid;
|
||||
entry.StatusMessage = string.Empty;
|
||||
}
|
||||
|
||||
entry.HasVersionWarning = entry.IsValid
|
||||
&& !string.IsNullOrWhiteSpace(currentGameVersion)
|
||||
&& !string.Equals(meta.gameVersion, currentGameVersion, StringComparison.Ordinal);
|
||||
if (entry.IsValid && _validationFailures.TryGetValue(safeDirectory, out var validationError))
|
||||
{
|
||||
entry.Status = TestSaveEntryStatus.CorruptSnapshot;
|
||||
entry.StatusMessage = validationError;
|
||||
entry.HasVersionWarning = false;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private void RememberValidationFailure(string directory, string error)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
_validationFailures[directory] = error ?? "快照验证失败。";
|
||||
}
|
||||
}
|
||||
|
||||
private static TestSaveEntry CreateEntry(string directory, TestSaveMeta meta)
|
||||
{
|
||||
return new TestSaveEntry
|
||||
{
|
||||
Meta = meta,
|
||||
DirectoryPath = directory,
|
||||
SnapshotPath = Path.Combine(directory, SnapshotFileName),
|
||||
MetaPath = Path.Combine(directory, MetaFileName),
|
||||
ThumbnailPath = Path.Combine(directory, ThumbnailFileName)
|
||||
};
|
||||
}
|
||||
|
||||
private static TestSaveEntry CreateInvalidEntry(
|
||||
string directory,
|
||||
TestSaveEntryStatus status,
|
||||
string message)
|
||||
{
|
||||
var entry = CreateEntry(directory, null);
|
||||
entry.Status = status;
|
||||
entry.StatusMessage = message;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private long NextFirstSeenOrder()
|
||||
{
|
||||
var max = 0L;
|
||||
foreach (var metaPath in Directory.EnumerateFiles(
|
||||
_rootPath,
|
||||
MetaFileName,
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
var meta = TryReadMeta(metaPath);
|
||||
if (meta != null && meta.firstSeenOrder > max)
|
||||
{
|
||||
max = meta.firstSeenOrder;
|
||||
}
|
||||
}
|
||||
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
private static TestSaveMeta TryReadMeta(string metaPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
return !File.Exists(metaPath)
|
||||
? null
|
||||
: JsonConvert.DeserializeObject<TestSaveMeta>(
|
||||
File.ReadAllText(metaPath, Encoding.UTF8));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecoverTransactions()
|
||||
{
|
||||
if (!Directory.Exists(_rootPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var staging in Directory.EnumerateDirectories(
|
||||
_rootPath,
|
||||
"*" + StagingSuffix,
|
||||
SearchOption.AllDirectories).ToArray())
|
||||
{
|
||||
var final = staging.Substring(0, staging.Length - StagingSuffix.Length);
|
||||
if (!Directory.Exists(final)
|
||||
&& File.Exists(Path.Combine(staging, MetaFileName))
|
||||
&& File.Exists(Path.Combine(staging, SnapshotFileName)))
|
||||
{
|
||||
Directory.Move(staging, final);
|
||||
}
|
||||
else
|
||||
{
|
||||
DeleteDirectoryIfPresent(staging);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var backup in Directory.EnumerateDirectories(
|
||||
_rootPath,
|
||||
"*" + BackupSuffix,
|
||||
SearchOption.AllDirectories).ToArray())
|
||||
{
|
||||
var final = backup.Substring(0, backup.Length - BackupSuffix.Length);
|
||||
if (!Directory.Exists(final))
|
||||
{
|
||||
Directory.Move(backup, final);
|
||||
}
|
||||
else
|
||||
{
|
||||
DeleteDirectoryIfPresent(backup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveInsideRoot(params string[] segments)
|
||||
{
|
||||
var parts = new List<string> { _rootPath };
|
||||
parts.AddRange(segments);
|
||||
var candidate = Path.GetFullPath(Path.Combine(parts.ToArray()));
|
||||
if (!IsInsideRoot(candidate))
|
||||
{
|
||||
throw new InvalidOperationException("Resolved path escaped the test save root.");
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private bool TryResolveInsideRoot(string path, out string safePath)
|
||||
{
|
||||
safePath = null;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var candidate = Path.GetFullPath(path);
|
||||
if (!IsInsideRoot(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
safePath = candidate;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsInsideRoot(string candidate)
|
||||
{
|
||||
return candidate.StartsWith(_rootPathWithSeparator, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsTransactionDirectory(string path)
|
||||
{
|
||||
return path.EndsWith(StagingSuffix, StringComparison.Ordinal)
|
||||
|| path.EndsWith(BackupSuffix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string Slug(string value, string fallback)
|
||||
{
|
||||
var source = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var builder = new StringBuilder(Math.Min(source.Length, 48));
|
||||
foreach (var character in source)
|
||||
{
|
||||
if (builder.Length >= 48) break;
|
||||
builder.Append(invalid.Contains(character) || char.IsControl(character) ? '_' : character);
|
||||
}
|
||||
|
||||
var result = builder.ToString().Trim('.', ' ');
|
||||
return string.IsNullOrWhiteSpace(result) ? fallback : result;
|
||||
}
|
||||
|
||||
public static string StableHash(string value)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
return string.Concat(sha.ComputeHash(Encoding.UTF8.GetBytes(value ?? string.Empty))
|
||||
.Select(valueByte => valueByte.ToString("x2")));
|
||||
}
|
||||
|
||||
private static string ShortHash(string value) => StableHash(value).Substring(0, 12);
|
||||
|
||||
private static bool PathsEqual(string left, string right)
|
||||
{
|
||||
return string.Equals(
|
||||
Path.GetFullPath(left),
|
||||
Path.GetFullPath(right),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void DeleteDirectoryIfPresent(string path)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveEmptyParents(string directory)
|
||||
{
|
||||
while (!string.IsNullOrEmpty(directory)
|
||||
&& IsInsideRoot(directory)
|
||||
&& Directory.Exists(directory)
|
||||
&& !Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveEmptyDirectories()
|
||||
{
|
||||
if (!Directory.Exists(_rootPath)) return;
|
||||
foreach (var directory in Directory.EnumerateDirectories(
|
||||
_rootPath,
|
||||
"*",
|
||||
SearchOption.AllDirectories)
|
||||
.OrderByDescending(path => path.Length))
|
||||
{
|
||||
if (!Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eafe695a1f6422b4584a2eb8d284ac77
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,79 @@
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AibisDream.SaveSystem
|
||||
{
|
||||
public enum TestSaveEntryStatus
|
||||
{
|
||||
Valid,
|
||||
MissingSnapshot,
|
||||
CorruptMeta,
|
||||
CorruptSnapshot,
|
||||
InvalidAnchor,
|
||||
SchemaMismatch,
|
||||
UnsafePath
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class TestSaveMeta
|
||||
{
|
||||
public const int CurrentLibraryVersion = 1;
|
||||
|
||||
public int libraryVersion = CurrentLibraryVersion;
|
||||
public string entryId;
|
||||
public string dedupeKey;
|
||||
public string chapterId;
|
||||
public string chapterTitle;
|
||||
public string sceneSoName;
|
||||
public string yarnProjectId;
|
||||
public string nodeName;
|
||||
public string sceneName;
|
||||
public long firstSeenOrder;
|
||||
public string firstRecordedAt;
|
||||
public string lastRecordedAt;
|
||||
public int snapshotSchemaVersion;
|
||||
public string gameVersion;
|
||||
public bool hasThumbnail;
|
||||
}
|
||||
|
||||
public sealed class TestSaveEntry
|
||||
{
|
||||
public TestSaveMeta Meta { get; internal set; }
|
||||
public string DirectoryPath { get; internal set; }
|
||||
public string SnapshotPath { get; internal set; }
|
||||
public string MetaPath { get; internal set; }
|
||||
public string ThumbnailPath { get; internal set; }
|
||||
public TestSaveEntryStatus Status { get; internal set; }
|
||||
public string StatusMessage { get; internal set; }
|
||||
public bool HasVersionWarning { get; internal set; }
|
||||
|
||||
public bool IsValid => Status == TestSaveEntryStatus.Valid;
|
||||
public string EntryId => Meta?.entryId;
|
||||
public string ChapterId => Meta?.chapterId;
|
||||
public string NodeName => Meta?.nodeName;
|
||||
}
|
||||
|
||||
public sealed class TestSaveScanResult
|
||||
{
|
||||
public IReadOnlyList<TestSaveEntry> Entries { get; internal set; } = Array.Empty<TestSaveEntry>();
|
||||
public int ValidCount { get; internal set; }
|
||||
public int InvalidCount { get; internal set; }
|
||||
}
|
||||
|
||||
public sealed class TestSaveRecordRequest
|
||||
{
|
||||
public SaveSnapshot Snapshot;
|
||||
public byte[] Thumbnail;
|
||||
public string DedupeKey;
|
||||
public string EntryId;
|
||||
public string ChapterId;
|
||||
public string ChapterTitle;
|
||||
public string SceneSoName;
|
||||
public string YarnProjectId;
|
||||
public string NodeName;
|
||||
public string SceneName;
|
||||
public string GameVersion;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2fbe0cca75895624e9aa00e1338b85ac
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user