feat: 测试存档功能

This commit is contained in:
2026-07-24 13:16:03 +08:00
parent 2ef6db54da
commit e6ae903c03
21 changed files with 6647 additions and 2850 deletions
@@ -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: