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,460 @@
#if UNITY_EDITOR
using System;
using System.Linq;
using AibisDream.UI;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
namespace AibisDream.DeveloperMode.Editor
{
/// <summary>只重建 DeveloperModePanel 的测试存档页,避免覆盖其他页的人工调整。</summary>
public static class DeveloperModeTestSavePanelBuilder
{
private const string PrefabPath =
"Assets/GameContent/Feature_MainUI/Prefabs/DeveloperModePanel.prefab";
private const string FontAssetPath =
"Assets/Font/Assets/WenQuanYi Bitmap Song 14px SDF.asset";
private static readonly Color Surface = new(0.055f, 0.085f, 0.11f, 0.96f);
private static readonly Color Accent = new(0.16f, 0.75f, 0.88f, 1f);
private static readonly Color TextColor = new(0.88f, 0.96f, 1f, 1f);
[MenuItem("Tools/AIBIS/Developer Mode/Rebuild Test Save Page")]
public static void BuildFromMenu()
{
Build();
Selection.activeObject = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
}
public static void BuildFromCommandLine()
{
try
{
Build();
Debug.Log("[DeveloperModeTestSavePanelBuilder] Build completed.");
}
catch (Exception ex)
{
Debug.LogException(ex);
EditorApplication.Exit(1);
}
}
private static void Build()
{
var root = PrefabUtility.LoadPrefabContents(PrefabPath);
try
{
var panel = root.GetComponent<DeveloperModePanel>()
?? throw new InvalidOperationException("DeveloperModePanel component not found.");
var serialized = new SerializedObject(panel);
var pages = serialized.FindProperty("tabPages")
?? throw new MissingFieldException(nameof(DeveloperModePanel), "tabPages");
if (pages.arraySize < 4)
{
throw new InvalidOperationException("DeveloperModePanel requires five tab pages.");
}
var savePage = pages.GetArrayElementAtIndex(3).objectReferenceValue as GameObject
?? throw new InvalidOperationException("Save page reference is missing.");
ClearChildren(savePage.transform);
ConfigurePageLayout(savePage);
BuildSavePage(savePage.transform, serialized);
RenameSaveTab(serialized);
var font = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(FontAssetPath)
?? throw new InvalidOperationException($"Font not found: {FontAssetPath}");
foreach (var text in root.GetComponentsInChildren<TMP_Text>(true))
{
text.font = font;
}
serialized.ApplyModifiedPropertiesWithoutUndo();
PrefabUtility.SaveAsPrefabAsset(root, PrefabPath);
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
AssetDatabase.SaveAssets();
}
private static void BuildSavePage(Transform page, SerializedObject serialized)
{
var recording = CreateHorizontal("Recording Toolbar", page, 6f);
AddLayout(recording, 38f);
var recordingToggle = CreateToggle("Record Test Saves", recording.transform, "录制测试存档", 145f);
var recordingState = CreateText("Recording State", recording.transform, "○ OFF", 14f, FontStyles.Bold);
AddLayout(recordingState.gameObject, 34f);
recordingState.rectTransform.sizeDelta = new Vector2(105f, 0f);
var search = CreateInput("Test Save Search", recording.transform, "搜索章节、节点或场景...", 250f);
var reload = CreateButton("Reload Test Saves", recording.transform, "重新扫描", 92f, 34f);
var open = CreateButton("Open Test Saves", recording.transform, "打开目录", 92f, 34f);
var columns = CreateHorizontal("Library Columns", page, 7f);
var columnsLayout = columns.AddComponent<LayoutElement>();
columnsLayout.flexibleHeight = 1f;
columnsLayout.minHeight = 420f;
var chapters = CreateVertical("Chapter Column", columns.transform, 4f);
var chapterLayout = chapters.AddComponent<LayoutElement>();
chapterLayout.preferredWidth = 245f;
chapterLayout.flexibleWidth = 0f;
chapterLayout.flexibleHeight = 1f;
var chapterTitle = CreateText("Title", chapters.transform, "章节 / 覆盖率", 16f, FontStyles.Bold);
AddLayout(chapterTitle.gameObject, 28f);
CreateList(
"Chapter List",
chapters.transform,
out var chapterContent,
out var chapterTemplate);
var entries = CreateVertical("Entry Column", columns.transform, 4f);
var entryLayout = entries.AddComponent<LayoutElement>();
entryLayout.flexibleWidth = 1f;
entryLayout.flexibleHeight = 1f;
var entryTitle = CreateText(
"Title",
entries.transform,
"存档点(● 已录制 / ○ 缺失 / ✕ 无效)",
16f,
FontStyles.Bold);
AddLayout(entryTitle.gameObject, 28f);
CreateList(
"Entry List",
entries.transform,
out var entryContent,
out var entryTemplate);
var actions = CreateHorizontal("Actions", page, 6f);
AddLayout(actions, 36f);
var jump = CreateButton("Jump", actions.transform, "跳转到选中", 118f, 32f);
var deleteSelected = CreateButton("Delete Selected", actions.transform, "删除选中", 105f, 32f);
var deleteInvalid = CreateButton("Delete Invalid", actions.transform, "清理无效", 105f, 32f);
var clear = CreateButton("Clear Library", actions.transform, "清空存档库", 118f, 32f);
var status = CreateText(
"Save Status",
page,
"测试存档录制默认关闭;开启后仅复制正式节点自动档。",
14f);
AddLayout(status.gameObject, 46f);
status.color = new Color(0.45f, 1f, 0.65f);
status.alignment = TextAlignmentOptions.TopLeft;
Set(serialized, "testSaveRecordingToggle", recordingToggle);
Set(serialized, "testSaveRecordingStateText", recordingState);
Set(serialized, "testSaveSearchInput", search);
Set(serialized, "reloadSavesButton", reload.GetComponent<Button>());
Set(serialized, "openTestSaveFolderButton", open.GetComponent<Button>());
Set(serialized, "jumpToTestSaveButton", jump.GetComponent<Button>());
Set(serialized, "deleteSelectedTestSaveButton", deleteSelected.GetComponent<Button>());
Set(serialized, "deleteInvalidTestSavesButton", deleteInvalid.GetComponent<Button>());
Set(serialized, "clearTestSaveLibraryButton", clear.GetComponent<Button>());
Set(serialized, "saveStatusText", status);
Set(serialized, "testSaveChapterContent", chapterContent);
Set(serialized, "testSaveChapterRowTemplate", chapterTemplate);
Set(serialized, "testSaveEntryContent", entryContent);
Set(serialized, "testSaveEntryRowTemplate", entryTemplate);
}
private static void RenameSaveTab(SerializedObject serialized)
{
var buttons = serialized.FindProperty("tabButtons");
if (buttons == null || buttons.arraySize < 4) return;
var button = buttons.GetArrayElementAtIndex(3).objectReferenceValue as Button;
var label = button?.GetComponentInChildren<TMP_Text>(true);
if (label != null) label.text = "测试存档";
}
private static void ConfigurePageLayout(GameObject page)
{
var layout = page.GetComponent<VerticalLayoutGroup>() ?? page.AddComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(8, 8, 8, 8);
layout.spacing = 6f;
layout.childAlignment = TextAnchor.UpperLeft;
layout.childControlWidth = true;
layout.childForceExpandWidth = true;
layout.childControlHeight = true;
layout.childForceExpandHeight = false;
}
private static void ClearChildren(Transform parent)
{
for (var index = parent.childCount - 1; index >= 0; index--)
{
UnityEngine.Object.DestroyImmediate(parent.GetChild(index).gameObject);
}
}
private static GameObject CreateUiObject(string name, params Type[] components)
{
var types = components.Contains(typeof(RectTransform))
? components
: new[] { typeof(RectTransform) }.Concat(components).ToArray();
var result = new GameObject(name, types);
result.layer = LayerMask.NameToLayer("UI");
return result;
}
private static GameObject CreatePanel(string name, Transform parent, Color color)
{
var result = CreateUiObject(name, typeof(CanvasRenderer), typeof(Image));
result.transform.SetParent(parent, false);
result.GetComponent<Image>().color = color;
return result;
}
private static GameObject CreateVertical(string name, Transform parent, float spacing)
{
var result = CreateUiObject(name, typeof(VerticalLayoutGroup));
result.transform.SetParent(parent, false);
var layout = result.GetComponent<VerticalLayoutGroup>();
layout.spacing = spacing;
layout.childAlignment = TextAnchor.UpperLeft;
layout.childControlWidth = true;
layout.childForceExpandWidth = true;
layout.childControlHeight = true;
layout.childForceExpandHeight = false;
return result;
}
private static GameObject CreateHorizontal(string name, Transform parent, float spacing)
{
var result = CreateUiObject(name, typeof(HorizontalLayoutGroup));
result.transform.SetParent(parent, false);
var layout = result.GetComponent<HorizontalLayoutGroup>();
layout.spacing = spacing;
layout.childAlignment = TextAnchor.MiddleLeft;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = true;
return result;
}
private static TMP_Text CreateText(
string name,
Transform parent,
string value,
float size,
FontStyles style = FontStyles.Normal)
{
var result = CreateUiObject(name, typeof(CanvasRenderer), typeof(TextMeshProUGUI));
result.transform.SetParent(parent, false);
var text = result.GetComponent<TextMeshProUGUI>();
text.font = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(FontAssetPath);
text.text = value;
text.fontSize = size;
text.fontStyle = style;
text.color = TextColor;
text.enableWordWrapping = true;
text.overflowMode = TextOverflowModes.Ellipsis;
text.raycastTarget = false;
return text;
}
private static GameObject CreateButton(
string name,
Transform parent,
string label,
float width,
float height)
{
var result = CreateUiObject(
name,
typeof(CanvasRenderer),
typeof(Image),
typeof(Button),
typeof(LayoutElement));
result.transform.SetParent(parent, false);
var image = result.GetComponent<Image>();
image.color = new Color(0.09f, 0.18f, 0.23f, 1f);
var button = result.GetComponent<Button>();
button.targetGraphic = image;
var layout = result.GetComponent<LayoutElement>();
layout.preferredWidth = width;
layout.preferredHeight = height;
var text = CreateText("Label", result.transform, label, 14f, FontStyles.Bold);
Stretch(text.rectTransform, new Vector2(5f, 2f), new Vector2(-5f, -2f));
text.alignment = TextAlignmentOptions.Center;
return result;
}
private static TMP_InputField CreateInput(
string name,
Transform parent,
string placeholder,
float width)
{
var root = CreateUiObject(
name,
typeof(CanvasRenderer),
typeof(Image),
typeof(TMP_InputField),
typeof(LayoutElement));
root.transform.SetParent(parent, false);
root.GetComponent<Image>().color = new Color(0.035f, 0.065f, 0.085f, 1f);
var layout = root.GetComponent<LayoutElement>();
layout.preferredWidth = width;
layout.preferredHeight = 34f;
var viewport = CreateUiObject("Text Area", typeof(RectMask2D));
viewport.transform.SetParent(root.transform, false);
Stretch(viewport.GetComponent<RectTransform>(), new Vector2(8f, 3f), new Vector2(-8f, -3f));
var placeholderText = CreateText("Placeholder", viewport.transform, placeholder, 14f);
Stretch(placeholderText.rectTransform);
placeholderText.color = new Color(0.45f, 0.58f, 0.63f, 1f);
placeholderText.fontStyle = FontStyles.Italic;
placeholderText.alignment = TextAlignmentOptions.MidlineLeft;
var inputText = CreateText("Text", viewport.transform, string.Empty, 14f);
Stretch(inputText.rectTransform);
inputText.alignment = TextAlignmentOptions.MidlineLeft;
var input = root.GetComponent<TMP_InputField>();
input.textViewport = viewport.GetComponent<RectTransform>();
input.textComponent = inputText;
input.placeholder = placeholderText;
input.lineType = TMP_InputField.LineType.SingleLine;
return input;
}
private static Toggle CreateToggle(string name, Transform parent, string label, float width)
{
var root = CreateUiObject(name, typeof(Toggle), typeof(LayoutElement));
root.transform.SetParent(parent, false);
var layout = root.GetComponent<LayoutElement>();
layout.preferredWidth = width;
layout.preferredHeight = 34f;
var box = CreatePanel("Background", root.transform, new Color(0.08f, 0.15f, 0.19f, 1f));
var boxRect = box.GetComponent<RectTransform>();
boxRect.anchorMin = boxRect.anchorMax = new Vector2(0f, 0.5f);
boxRect.pivot = new Vector2(0f, 0.5f);
boxRect.sizeDelta = new Vector2(24f, 24f);
var check = CreatePanel("Checkmark", box.transform, Accent);
Stretch(check.GetComponent<RectTransform>(), new Vector2(5f, 5f), new Vector2(-5f, -5f));
var text = CreateText("Label", root.transform, label, 15f);
SetAnchors(text.rectTransform, Vector2.zero, Vector2.one, new Vector2(34f, 0f), Vector2.zero);
text.alignment = TextAlignmentOptions.MidlineLeft;
var toggle = root.GetComponent<Toggle>();
toggle.targetGraphic = box.GetComponent<Image>();
toggle.graphic = check.GetComponent<Image>();
toggle.isOn = false;
return toggle;
}
private static void CreateList(
string name,
Transform parent,
out RectTransform content,
out DeveloperModeListRow template)
{
var root = CreatePanel(name, parent, new Color(0.02f, 0.035f, 0.05f, 1f));
var rootLayout = root.AddComponent<LayoutElement>();
rootLayout.flexibleHeight = 1f;
rootLayout.minHeight = 300f;
var scroll = root.AddComponent<ScrollRect>();
scroll.horizontal = false;
scroll.vertical = true;
scroll.movementType = ScrollRect.MovementType.Clamped;
scroll.scrollSensitivity = 24f;
var viewport = CreateUiObject("Viewport", typeof(RectMask2D));
viewport.transform.SetParent(root.transform, false);
Stretch(viewport.GetComponent<RectTransform>(), new Vector2(3f, 3f), new Vector2(-3f, -3f));
var contentObject = CreateUiObject(
"Content",
typeof(VerticalLayoutGroup),
typeof(ContentSizeFitter));
contentObject.transform.SetParent(viewport.transform, false);
content = contentObject.GetComponent<RectTransform>();
content.anchorMin = new Vector2(0f, 1f);
content.anchorMax = new Vector2(1f, 1f);
content.pivot = new Vector2(0.5f, 1f);
content.sizeDelta = Vector2.zero;
var vertical = contentObject.GetComponent<VerticalLayoutGroup>();
vertical.spacing = 2f;
vertical.childControlWidth = true;
vertical.childForceExpandWidth = true;
vertical.childControlHeight = true;
vertical.childForceExpandHeight = false;
contentObject.GetComponent<ContentSizeFitter>().verticalFit =
ContentSizeFitter.FitMode.PreferredSize;
var row = CreateUiObject(
"Row Template",
typeof(CanvasRenderer),
typeof(Image),
typeof(Button),
typeof(LayoutElement),
typeof(DeveloperModeListRow));
row.transform.SetParent(content, false);
var rowImage = row.GetComponent<Image>();
rowImage.color = Surface;
var rowButton = row.GetComponent<Button>();
rowButton.targetGraphic = rowImage;
var rowLayout = row.GetComponent<LayoutElement>();
rowLayout.preferredHeight = 30f;
rowLayout.minHeight = 30f;
var rowText = CreateText("Label", row.transform, "Template", 13f);
Stretch(rowText.rectTransform, new Vector2(7f, 2f), new Vector2(-7f, -2f));
rowText.alignment = TextAlignmentOptions.MidlineLeft;
rowText.enableWordWrapping = false;
var serialized = new SerializedObject(row.GetComponent<DeveloperModeListRow>());
Set(serialized, "button", rowButton);
Set(serialized, "label", rowText);
Set(serialized, "background", rowImage);
serialized.ApplyModifiedPropertiesWithoutUndo();
row.SetActive(false);
template = row.GetComponent<DeveloperModeListRow>();
scroll.viewport = viewport.GetComponent<RectTransform>();
scroll.content = content;
}
private static void SetAnchors(
RectTransform rect,
Vector2 min,
Vector2 max,
Vector2 offsetMin,
Vector2 offsetMax)
{
rect.anchorMin = min;
rect.anchorMax = max;
rect.offsetMin = offsetMin;
rect.offsetMax = offsetMax;
}
private static void Stretch(
RectTransform rect,
Vector2 offsetMin,
Vector2 offsetMax)
{
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = offsetMin;
rect.offsetMax = offsetMax;
}
private static void Stretch(RectTransform rect)
{
Stretch(rect, Vector2.zero, Vector2.zero);
}
private static void AddLayout(GameObject target, float height)
{
var layout = target.GetComponent<LayoutElement>() ?? target.AddComponent<LayoutElement>();
layout.preferredHeight = height;
}
private static void Set(SerializedObject serialized, string propertyName, UnityEngine.Object value)
{
var property = serialized.FindProperty(propertyName)
?? throw new MissingFieldException(
serialized.targetObject.GetType().Name,
propertyName);
property.objectReferenceValue = value;
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c2aea902eb4326b499d698ff7b3b3c14
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,235 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AibisDream.SaveSystem;
using Newtonsoft.Json;
using NUnit.Framework;
namespace AibisDream.DeveloperMode.Editor.Tests
{
public sealed class TestSaveSystemTests
{
private string _root;
private TestSaveRepository _repository;
[SetUp]
public void SetUp()
{
_root = Path.Combine(
Path.GetTempPath(),
"AibisDream-TestSaveTests",
Guid.NewGuid().ToString("N"));
_repository = new TestSaveRepository(_root);
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, true);
}
}
[Test]
public void Record_SameAnchor_ReplacesSnapshotAndKeepsFirstSeenOrder()
{
var first = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneOne"));
var second = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneTwo"));
var scan = _repository.Scan("1.0");
Assert.That(scan.Entries.Count, Is.EqualTo(1));
Assert.That(second.Meta.firstSeenOrder, Is.EqualTo(first.Meta.firstSeenOrder));
Assert.That(scan.Entries[0].Meta.sceneName, Is.EqualTo("SceneTwo"));
Assert.That(_repository.TryLoad(scan.Entries[0], out var snapshot, out var error), Is.True, error);
Assert.That(snapshot.scene.sceneName, Is.EqualTo("SceneTwo"));
}
[Test]
public void Record_ReplacingEarlierEntry_DoesNotMoveItsOrder()
{
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeB", "SceneB"));
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA2"));
var entries = _repository.Scan().Entries.ToArray();
Assert.That(entries.Select(item => item.NodeName), Is.EqualTo(new[] { "NodeA", "NodeB" }));
Assert.That(entries[0].Meta.firstSeenOrder, Is.LessThan(entries[1].Meta.firstSeenOrder));
}
[Test]
public void Scan_RetainsMissingSnapshotAsInvalid()
{
var entry = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
File.Delete(entry.SnapshotPath);
var scan = _repository.Scan();
Assert.That(scan.ValidCount, Is.Zero);
Assert.That(scan.InvalidCount, Is.EqualTo(1));
Assert.That(scan.Entries[0].Status, Is.EqualTo(TestSaveEntryStatus.MissingSnapshot));
}
[Test]
public void Scan_RetainsOrphanSnapshotWithBrokenMeta()
{
var directory = Path.Combine(_root, "chapter", "broken");
Directory.CreateDirectory(directory);
File.WriteAllText(
Path.Combine(directory, TestSaveRepository.SnapshotFileName),
"{}",
Encoding.UTF8);
var scan = _repository.Scan();
Assert.That(scan.InvalidCount, Is.EqualTo(1));
Assert.That(scan.Entries[0].Status, Is.EqualTo(TestSaveEntryStatus.CorruptMeta));
}
[Test]
public void Validate_CorruptSnapshot_IsRetainedAndDisabledOnNextScan()
{
var entry = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
File.WriteAllText(entry.SnapshotPath, "{broken", Encoding.UTF8);
Assert.That(_repository.Validate(entry, out var error), Is.False);
var rescanned = _repository.Scan();
Assert.That(error, Does.Contain("无法解析"));
Assert.That(rescanned.InvalidCount, Is.EqualTo(1));
Assert.That(rescanned.Entries[0].Status, Is.EqualTo(TestSaveEntryStatus.CorruptSnapshot));
}
[Test]
public void Scan_RecoversCompleteStagingDirectory()
{
var final = Path.Combine(_root, "chapter", "node");
var staging = final + ".__staging";
Directory.CreateDirectory(staging);
var request = CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA");
var meta = CreateMeta(request, 1);
File.WriteAllText(
Path.Combine(staging, TestSaveRepository.MetaFileName),
JsonConvert.SerializeObject(meta),
Encoding.UTF8);
File.WriteAllText(
Path.Combine(staging, TestSaveRepository.SnapshotFileName),
SnapshotPersistence.Serialize(request.Snapshot),
Encoding.UTF8);
var scan = _repository.Scan();
Assert.That(Directory.Exists(final), Is.True);
Assert.That(Directory.Exists(staging), Is.False);
Assert.That(scan.ValidCount, Is.EqualTo(1));
}
[Test]
public void Record_ConcurrentRequests_ProducesCompleteUniqueEntries()
{
var tasks = Enumerable.Range(0, 12)
.Select(index => Task.Run(() =>
_repository.Record(CreateRequest(
"ChapterA",
"ProjectA",
$"Node{index:00}",
$"Scene{index:00}"))))
.ToArray();
Task.WaitAll(tasks);
var scan = _repository.Scan();
Assert.That(scan.ValidCount, Is.EqualTo(12));
Assert.That(scan.InvalidCount, Is.Zero);
Assert.That(scan.Entries.Select(item => item.Meta.firstSeenOrder).Distinct().Count(), Is.EqualTo(12));
}
[Test]
public void DeleteInvalid_RemovesOnlyInvalidEntries()
{
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
var invalid = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeB", "SceneB"));
File.Delete(invalid.SnapshotPath);
var removed = _repository.DeleteInvalid(out var error);
var scan = _repository.Scan();
Assert.That(error, Is.Null);
Assert.That(removed, Is.EqualTo(1));
Assert.That(scan.ValidCount, Is.EqualTo(1));
Assert.That(scan.InvalidCount, Is.Zero);
}
[TestCase(new[] { "hub" }, true)]
[TestCase(new[] { "linear" }, true)]
[TestCase(new[] { "content" }, true)]
[TestCase(new[] { "event" }, false)]
[TestCase(new[] { "hub", "no_save" }, false)]
[TestCase(new string[0], true)]
public void SilentCoverageEvaluator_MatchesSavePointTagSemantics(string[] tags, bool expected)
{
var actual = SavePointEvaluator.EvaluateNodeTagsForAutoSaveSilently(
"Node",
tags,
out _);
Assert.That(actual, Is.EqualTo(expected));
}
private static TestSaveRecordRequest CreateRequest(
string sceneSoName,
string yarnProject,
string nodeName,
string sceneName)
{
var key = TestSaveRecorder.BuildDedupeKey(sceneSoName, yarnProject, nodeName);
return new TestSaveRecordRequest
{
Snapshot = new SaveSnapshot
{
gameVersion = "1.0",
savedAt = "2026-01-01 00:00:00",
scene = new SceneSnapshotDto { sceneName = sceneName },
anchor = new AnchorSnapshot
{
sceneSoName = sceneSoName,
yarnProjectId = yarnProject,
nodeName = nodeName
}
},
DedupeKey = key,
EntryId = TestSaveRepository.StableHash(key),
ChapterId = sceneSoName,
ChapterTitle = sceneSoName,
SceneSoName = sceneSoName,
YarnProjectId = yarnProject,
NodeName = nodeName,
SceneName = sceneName,
GameVersion = "1.0"
};
}
private static TestSaveMeta CreateMeta(TestSaveRecordRequest request, long order)
{
return 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 = order,
firstRecordedAt = "2026-01-01 00:00:00",
lastRecordedAt = "2026-01-01 00:00:00",
snapshotSchemaVersion = SaveSnapshotSchema.CurrentVersion,
gameVersion = request.GameVersion
};
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cbbba0c3621cf204483f453bad2d75a4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -20,6 +20,9 @@ namespace AibisDream.Utility
public const string WishlistURL = "https://store.steampowered.com/app/3473430/_All_Our_Broken_Parts?utm_source=playtest";
public static readonly string SaveFilePath = Path.Combine(Application.persistentDataPath, "AllOurBrokenParts", "demo_saves");
#if UNITY_EDITOR || DEVELOPMENT_BUILD
public static readonly string TestSavePath = Path.Combine(Application.persistentDataPath, "AllOurBrokenParts", "test_saves");
#endif
/// <summary>P2 槽位相关常量。</summary>
public const int SaveSlotCount = 8;
+21
View File
@@ -113,6 +113,27 @@ namespace AibisDream
return true;
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
public bool TryRestoreTestSave(TestSaveEntry entry, Action<RestoreResult> completed = null)
{
if (entry == null
|| !entry.IsValid
|| !CanBeginRestore()
|| !TryAcquireCommand(SessionCommandKind.Restore))
{
return false;
}
StartCoroutine(RestoreRoutine(
() => SaveRestoreOrchestrator.PrepareRestoreFromTestSave(
entry,
_talkSceneIndex,
RestoreOptions.Default),
completed));
return true;
}
#endif
public bool TryPause()
{
if (!_session.CanPause)
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 23d7cf345400ed8409429819edcfcbc1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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:
@@ -140,9 +140,20 @@ namespace AibisDream.SaveSystem
IReadOnlyList<string> tags,
out SavePointRejectReason reason)
{
return EvaluateTagsForAutoSave(nodeName, tags, out reason);
return EvaluateTagsForAutoSave(nodeName, tags, out reason, logWarnings: true);
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
/// <summary>测试存档覆盖率扫描使用;语义与正式判定一致,但不会为全量节点刷 warning。</summary>
public static bool EvaluateNodeTagsForAutoSaveSilently(
string nodeName,
IReadOnlyList<string> tags,
out SavePointRejectReason reason)
{
return EvaluateTagsForAutoSave(nodeName, tags, out reason, logWarnings: false);
}
#endif
/// <summary>节点执行完毕,移出 InProgress。</summary>
public static void OnNodeComplete(string projectId, string nodeName)
{
@@ -243,7 +254,8 @@ namespace AibisDream.SaveSystem
private static bool EvaluateTagsForAutoSave(
string nodeName,
IReadOnlyList<string> tags,
out SavePointRejectReason reason)
out SavePointRejectReason reason,
bool logWarnings = true)
{
if (tags != null && IsTagPresent(tags, "no_save"))
{
@@ -253,7 +265,7 @@ namespace AibisDream.SaveSystem
if (string.IsNullOrEmpty(nodeName) || tags == null || tags.Count == 0)
{
if (!string.IsNullOrEmpty(nodeName) && (tags == null || tags.Count == 0))
if (logWarnings && !string.IsNullOrEmpty(nodeName) && (tags == null || tags.Count == 0))
{
Debug.LogWarning(
$"[SavePointEvaluator] 节点 {nodeName} 未声明任何 tag,默认允许自动存档。请补全节点类型标签。");
@@ -267,8 +279,11 @@ namespace AibisDream.SaveSystem
if (primaryTag == null)
{
Debug.LogWarning(
$"[SavePointEvaluator] 节点 {nodeName} 的 tags [{string.Join(", ", tags)}] 未声明保存语义,默认允许自动存档。");
if (logWarnings)
{
Debug.LogWarning(
$"[SavePointEvaluator] 节点 {nodeName} 的 tags [{string.Join(", ", tags)}] 未声明保存语义,默认允许自动存档。");
}
reason = SavePointRejectReason.None;
return true;
}
@@ -285,8 +300,11 @@ namespace AibisDream.SaveSystem
return true;
}
Debug.LogWarning(
$"[SavePointEvaluator] 节点 {nodeName} 的 tag '{primaryTag}' 未声明保存语义,默认允许自动存档。");
if (logWarnings)
{
Debug.LogWarning(
$"[SavePointEvaluator] 节点 {nodeName} 的 tag '{primaryTag}' 未声明保存语义,默认允许自动存档。");
}
reason = SavePointRejectReason.None;
return true;
}
@@ -91,6 +91,21 @@ namespace AibisDream.SaveSystem
infoPanel?.HideSaveLoading();
#if UNITY_EDITOR || DEVELOPMENT_BUILD
TestSaveRecordRequest testSaveRequest = null;
if (!omitAnchor)
{
try
{
testSaveRequest = TestSaveRecorder.CreateRequest(snapshot, thumbnail);
}
catch (Exception ex)
{
Debug.LogError($"[SaveRestoreOrchestrator] 创建测试存档旁路请求失败,不影响正式存档:{ex}");
}
}
#endif
_ = SlotManager.SaveToAutoSlotAsync(snapshot, thumbnail).ContinueWith(
writeTask =>
{
@@ -99,6 +114,12 @@ namespace AibisDream.SaveSystem
Debug.LogError(
$"[SaveRestoreOrchestrator] 自动存档写盘失败: {writeTask.Exception?.GetBaseException()}");
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
else if (!writeTask.IsCanceled)
{
TestSaveRecorder.Enqueue(testSaveRequest);
}
#endif
_isAutoSaving = false;
},
@@ -158,6 +179,22 @@ namespace AibisDream.SaveSystem
return PrepareSnapshot(snapshot, sourceLabel, talkSceneIndex, options);
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
internal static RestorePreparation PrepareRestoreFromTestSave(
TestSaveEntry entry,
TalkSceneGraphIndex talkSceneIndex,
RestoreOptions options)
{
var sourceLabel = $"test:{entry?.Meta?.sceneSoName ?? "unknown"}/{entry?.NodeName ?? "unknown"}";
if (!TestSaveRecorder.Repository.TryLoad(entry, out var snapshot, out var error))
{
return FailedPreparation(sourceLabel, "LoadTestSnapshot", error, options);
}
return PrepareSnapshot(snapshot, sourceLabel, talkSceneIndex, options);
}
#endif
internal static IEnumerator ExecutePreparedRestore(
RestorePreparation preparation,
Func<bool> isCancellationRequested)
@@ -11,7 +11,13 @@ namespace AibisDream.UI
[SerializeField] private TMP_Text label;
[SerializeField] private Image background;
public void Bind(string text, Action clicked, bool interactable, Color color, bool isError = false)
public void Bind(
string text,
Action clicked,
bool interactable,
Color color,
bool isError = false,
bool isSelected = false)
{
label.text = text ?? string.Empty;
label.color = color;
@@ -24,9 +30,11 @@ namespace AibisDream.UI
button.interactable = interactable;
if (background != null)
{
background.color = isError
? new Color(0.20f, 0.07f, 0.07f, 0.92f)
: new Color(0.08f, 0.12f, 0.16f, 0.92f);
background.color = isSelected
? new Color(0.06f, 0.28f, 0.34f, 0.96f)
: isError
? new Color(0.20f, 0.07f, 0.07f, 0.92f)
: new Color(0.08f, 0.12f, 0.16f, 0.92f);
}
gameObject.SetActive(true);
+362 -76
View File
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AibisDream.Framework;
@@ -61,18 +60,20 @@ namespace AibisDream.UI
[SerializeField] private DeveloperModeListRow variableRowTemplate;
[Header("Saves")]
[SerializeField] private Toggle testSaveRecordingToggle;
[SerializeField] private TMP_Text testSaveRecordingStateText;
[SerializeField] private TMP_InputField testSaveSearchInput;
[SerializeField] private Button reloadSavesButton;
[SerializeField] private Button openTestSaveFolderButton;
[SerializeField] private Button jumpToTestSaveButton;
[SerializeField] private Button deleteSelectedTestSaveButton;
[SerializeField] private Button deleteInvalidTestSavesButton;
[SerializeField] private Button clearTestSaveLibraryButton;
[SerializeField] private TMP_Text saveStatusText;
[SerializeField] private RectTransform slotContent;
[SerializeField] private DeveloperModeListRow slotRowTemplate;
[Header("Paths")]
[SerializeField] private Button openSaveFolderButton;
[SerializeField] private Button copySaveFolderButton;
[SerializeField] private Button openLogFolderButton;
[SerializeField] private Button copyLogFolderButton;
[SerializeField] private Button openCurrentLogButton;
[SerializeField] private Button copyCurrentLogButton;
[SerializeField] private RectTransform testSaveChapterContent;
[SerializeField] private DeveloperModeListRow testSaveChapterRowTemplate;
[SerializeField] private RectTransform testSaveEntryContent;
[SerializeField] private DeveloperModeListRow testSaveEntryRowTemplate;
[Header("Display")]
[SerializeField] private Toggle showCursorToggle;
@@ -88,7 +89,17 @@ namespace AibisDream.UI
private readonly List<DeveloperModeListRow> _logRows = new();
private readonly List<DeveloperModeListRow> _variableRows = new();
private readonly List<DeveloperModeListRow> _slotRows = new();
#if UNITY_EDITOR || DEVELOPMENT_BUILD
private readonly List<DeveloperModeListRow> _testSaveChapterRows = new();
private readonly List<DeveloperModeListRow> _testSaveEntryRows = new();
private IReadOnlyList<TestSaveChapterCoverage> _testSaveCoverage = Array.Empty<TestSaveChapterCoverage>();
private TestSaveEntry _selectedTestSave;
private bool _selectedTestSaveCanJump;
private string _selectedTestSaveChapterId;
private string _pendingDestructiveAction;
private float _pendingDestructiveActionDeadline;
private bool _validateTestSavesOnRefresh;
#endif
private VerText _verText;
private RuntimeLogRecord? _selectedLog;
private int _currentTab;
@@ -251,11 +262,39 @@ namespace AibisDream.UI
reloadSavesButton?.onClick.AddListener(() =>
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
_validateTestSavesOnRefresh = true;
#endif
_savesDirty = true;
RefreshSaves();
});
BindPathButtons();
#if UNITY_EDITOR || DEVELOPMENT_BUILD
testSaveRecordingToggle?.SetIsOnWithoutNotify(false);
UpdateTestSaveRecordingState(false);
testSaveRecordingToggle?.onValueChanged.AddListener(value =>
{
TestSaveRecorder.SetRecording(value);
UpdateTestSaveRecordingState(value);
SetSaveStatus(value
? "录制已开启:正式节点自动档成功后会复制到测试存档库。"
: "录制已关闭。已有测试存档不受影响。", false);
});
testSaveSearchInput?.onValueChanged.AddListener(_ =>
{
_savesDirty = true;
RefreshSaves();
});
openTestSaveFolderButton?.onClick.AddListener(OpenTestSaveFolder);
jumpToTestSaveButton?.onClick.AddListener(JumpToSelectedTestSave);
deleteSelectedTestSaveButton?.onClick.AddListener(DeleteSelectedTestSave);
deleteInvalidTestSavesButton?.onClick.AddListener(DeleteInvalidTestSaves);
clearTestSaveLibraryButton?.onClick.AddListener(ClearTestSaveLibrary);
if (!DeveloperPathUtility.CanReveal && openTestSaveFolderButton != null)
{
openTestSaveFolderButton.interactable = false;
}
#endif
BindDisplayControls();
UpdateFilterLabels();
}
@@ -265,6 +304,9 @@ namespace AibisDream.UI
EnumEventSystem.Global.Register<StorageEvent, VariableItem>(StorageEvent.VariableSet, OnVariableSet);
EnumEventSystem.Global.Register(StorageEvent.VariablesCleared, OnVariablesCleared);
EnumEventSystem.Global.Register(GameLifecycleEvent.SessionEnded, OnSessionEnded);
#if UNITY_EDITOR || DEVELOPMENT_BUILD
TestSaveRecorder.LibraryChanged += OnTestSaveLibraryChanged;
#endif
}
private void UnsubscribeEvents()
@@ -272,6 +314,9 @@ namespace AibisDream.UI
EnumEventSystem.Global.UnRegister<StorageEvent, VariableItem>(StorageEvent.VariableSet, OnVariableSet);
EnumEventSystem.Global.UnRegister(StorageEvent.VariablesCleared, OnVariablesCleared);
EnumEventSystem.Global.UnRegister(GameLifecycleEvent.SessionEnded, OnSessionEnded);
#if UNITY_EDITOR || DEVELOPMENT_BUILD
TestSaveRecorder.LibraryChanged -= OnTestSaveLibraryChanged;
#endif
}
private void ShowTab(int index)
@@ -549,35 +594,9 @@ namespace AibisDream.UI
private void RefreshSaves()
{
_savesDirty = false;
RefreshSlotRows();
}
private void RefreshSlotRows()
{
if (slotContent == null || slotRowTemplate == null) return;
var latest = SlotManager.GetLatestSlotIndex();
var viewModels = SlotManager.GetSlotViewModels();
for (var i = 0; i < viewModels.Length; i++)
{
var view = viewModels[i];
var slotIndex = view.SlotIndex;
var meta = SlotManager.LoadMeta(slotIndex);
var valid = !view.IsEmpty && meta != null && File.Exists(SlotDirectory.GetSnapshotPath(slotIndex));
var prefix = latest == slotIndex ? "★" : " ";
var kind = view.IsAutoSlot ? "AUTO" : "MANUAL";
var text = valid
? $"{prefix} slot_{slotIndex} [{kind}] {meta.savedAt} | {meta.sceneName} | {meta.sceneSoName} | {meta.yarnProjectId}/{meta.nodeName} | schema {meta.schemaVersion} | game {meta.gameVersion}"
: $"{prefix} slot_{slotIndex} [{kind}] {(view.IsEmpty ? "" : " snapshot")}";
var row = GetRow(_slotRows, slotRowTemplate, slotContent, i);
row.Bind(
text,
() => RestoreSlot(slotIndex),
valid && CanStartRestore(),
valid ? Color.white : new Color(1f, 0.5f, 0.45f),
isError: !valid && !view.IsEmpty);
}
HideRows(_slotRows, viewModels.Length);
#if UNITY_EDITOR || DEVELOPMENT_BUILD
RefreshTestSaveRows();
#endif
}
private bool CanStartRestore()
@@ -587,22 +606,200 @@ namespace AibisDream.UI
return !session.IsBusy && session.Phase is GameSessionPhase.MainMenu or GameSessionPhase.Playing;
}
private void RestoreSlot(int slotIndex)
#if UNITY_EDITOR || DEVELOPMENT_BUILD
private void RefreshTestSaveRows()
{
if (testSaveChapterContent == null
|| testSaveChapterRowTemplate == null
|| testSaveEntryContent == null
|| testSaveEntryRowTemplate == null)
{
return;
}
TestSaveScanResult scan;
try
{
scan = TestSaveRecorder.Repository.Scan(Application.version);
if (_validateTestSavesOnRefresh)
{
_validateTestSavesOnRefresh = false;
foreach (var entry in scan.Entries.Where(item => item.IsValid))
{
TestSaveRecorder.Repository.Validate(entry, out _);
}
scan = TestSaveRecorder.Repository.Scan(Application.version);
}
_testSaveCoverage = TestSaveCoverage.Build(
GameManager.Instance?.RuntimeChapters,
scan.Entries);
}
catch (Exception ex)
{
SetSaveStatus($"扫描测试存档失败:{ex.Message}", true);
return;
}
var search = testSaveSearchInput?.text?.Trim();
var visibleChapters = _testSaveCoverage
.Where(chapter => string.IsNullOrWhiteSpace(search)
|| Contains(chapter.Title, search)
|| Contains(chapter.ChapterId, search)
|| chapter.Rows.Any(row => Contains(row.NodeName, search)))
.ToArray();
if (visibleChapters.Length > 0
&& visibleChapters.All(chapter =>
!string.Equals(chapter.ChapterId, _selectedTestSaveChapterId, StringComparison.Ordinal)))
{
_selectedTestSaveChapterId = visibleChapters[0].ChapterId;
_selectedTestSave = null;
_selectedTestSaveCanJump = false;
}
for (var i = 0; i < visibleChapters.Length; i++)
{
var chapter = visibleChapters[i];
var selected = string.Equals(
chapter.ChapterId,
_selectedTestSaveChapterId,
StringComparison.Ordinal);
var label = chapter.IsOrphanGroup
? $"{chapter.Title} ({chapter.Rows.Count})"
: $"{chapter.Title} {chapter.RecordedCount}/{chapter.ExpectedCount}";
var row = GetRow(
_testSaveChapterRows,
testSaveChapterRowTemplate,
testSaveChapterContent,
i);
row.Bind(
label,
() => SelectTestSaveChapter(chapter.ChapterId),
true,
selected ? new Color(0.25f, 0.95f, 1f) : Color.white,
isError: chapter.IsOrphanGroup,
isSelected: selected);
}
HideRows(_testSaveChapterRows, visibleChapters.Length);
var selectedChapter = visibleChapters.FirstOrDefault(chapter =>
string.Equals(chapter.ChapterId, _selectedTestSaveChapterId, StringComparison.Ordinal));
var visibleRows = selectedChapter?.Rows
.Where(row => string.IsNullOrWhiteSpace(search)
|| Contains(row.NodeName, search)
|| Contains(row.Entry?.Meta?.sceneName, search))
.ToArray() ?? Array.Empty<TestSaveCoverageRow>();
for (var i = 0; i < visibleRows.Length; i++)
{
var coverageRow = visibleRows[i];
var entry = coverageRow.Entry;
var selected = entry != null
&& string.Equals(
entry.EntryId ?? entry.DirectoryPath,
_selectedTestSave?.EntryId ?? _selectedTestSave?.DirectoryPath,
StringComparison.Ordinal);
var (label, color, isError) = FormatTestSaveRow(coverageRow);
var row = GetRow(
_testSaveEntryRows,
testSaveEntryRowTemplate,
testSaveEntryContent,
i);
row.Bind(
label,
entry == null
? null
: () => SelectTestSave(
entry,
coverageRow.Kind == TestSaveCoverageRowKind.Recorded),
entry != null,
color,
isError,
selected);
}
HideRows(_testSaveEntryRows, visibleRows.Length);
UpdateTestSaveActions();
var recorded = _testSaveCoverage.Sum(chapter => chapter.RecordedCount);
var expected = _testSaveCoverage.Sum(chapter => chapter.ExpectedCount);
SetSaveStatus(
$"测试存档 {scan.ValidCount} 个有效 / {scan.InvalidCount} 个无效;当前流程覆盖 {recorded}/{expected}。",
false);
}
private static (string Label, Color Color, bool IsError) FormatTestSaveRow(
TestSaveCoverageRow row)
{
if (row.Kind == TestSaveCoverageRowKind.Missing)
{
return ($"○ 缺失 {row.NodeName}", new Color(0.6f, 0.68f, 0.72f), false);
}
var entry = row.Entry;
if (row.Kind == TestSaveCoverageRowKind.Invalid || entry == null || !entry.IsValid)
{
return (
$"✕ 无效 {row.NodeName} | "
+ (entry?.IsValid == true
? "当前流程找不到对应章节/YarnProject。"
: entry?.StatusMessage ?? "未知错误"),
new Color(1f, 0.5f, 0.45f),
true);
}
var warning = entry.HasVersionWarning ? $" ⚠ game {entry.Meta.gameVersion}" : string.Empty;
return (
$"● {row.NodeName} | {entry.Meta.lastRecordedAt} | {entry.Meta.sceneName}{warning}",
entry.HasVersionWarning ? new Color(1f, 0.82f, 0.35f) : Color.white,
false);
}
private void SelectTestSaveChapter(string chapterId)
{
_selectedTestSaveChapterId = chapterId;
_selectedTestSave = null;
_selectedTestSaveCanJump = false;
_savesDirty = true;
RefreshSaves();
}
private void SelectTestSave(TestSaveEntry entry, bool canJump)
{
_selectedTestSave = entry;
_selectedTestSaveCanJump = canJump && entry?.IsValid == true;
_savesDirty = true;
RefreshSaves();
}
private void JumpToSelectedTestSave()
{
if (_selectedTestSave?.IsValid != true || !_selectedTestSaveCanJump)
{
SetSaveStatus("请先选择一个有效测试存档。", true);
return;
}
if (!TestSaveRecorder.Repository.Validate(_selectedTestSave, out var validationError))
{
SetSaveStatus($"测试存档验证失败:{validationError}", true);
_savesDirty = true;
return;
}
if (!CanStartRestore())
{
SetSaveStatus("当前会话忙碌,无法读档。", true);
SetSaveStatus("当前会话忙碌,无法跳转。", true);
return;
}
_isRestorePending = true;
SetSaveStatus($"正在读取 slot_{slotIndex}...", false);
if (!GameManager.Instance.TryRestoreSlot(slotIndex, OnRestoreCompleted))
SetSaveStatus($"正在跳转到 {_selectedTestSave.NodeName}...", false);
if (!GameManager.Instance.TryRestoreTestSave(_selectedTestSave, OnRestoreCompleted))
{
_isRestorePending = false;
SetSaveStatus("读档请求被 GameManager 拒绝。", true);
SetSaveStatus("跳转请求被 GameManager 拒绝。", true);
}
_savesDirty = true;
}
private void OnRestoreCompleted(RestoreResult result)
@@ -611,54 +808,143 @@ namespace AibisDream.UI
_savesDirty = true;
if (result?.Success == true)
{
var warnings = result.Warnings.Count == 0 ? string.Empty : $"\n{string.Join("\n", result.Warnings)}";
SetSaveStatus($"读档成功。{warnings}", false);
var warnings = result.Warnings.Count == 0
? string.Empty
: $"\n{string.Join("\n", result.Warnings)}";
SetSaveStatus($"测试存档跳转成功。{warnings}", false);
return;
}
var errors = result?.Errors == null || result.Errors.Count == 0
? "未返回错误详情。"
: string.Join("\n", result.Errors);
SetSaveStatus($"读档失败 [{result?.FailedPhase ?? "unknown"}]\n{errors}", true);
SetSaveStatus($"跳转失败 [{result?.FailedPhase ?? "unknown"}]\n{errors}", true);
}
private void BindPathButtons()
private void OpenTestSaveFolder()
{
openSaveFolderButton?.onClick.AddListener(() => RevealPath(ConstRef.SaveFilePath, true));
copySaveFolderButton?.onClick.AddListener(() => CopyPath(ConstRef.SaveFilePath));
openLogFolderButton?.onClick.AddListener(() => RevealPath(ConstRef.LogFilePath, true));
copyLogFolderButton?.onClick.AddListener(() => CopyPath(ConstRef.LogFilePath));
openCurrentLogButton?.onClick.AddListener(() => RevealPath(LogKit.CurrentLogFilePath, false));
copyCurrentLogButton?.onClick.AddListener(() => CopyPath(LogKit.CurrentLogFilePath));
if (!DeveloperPathUtility.CanReveal)
{
if (openSaveFolderButton != null) openSaveFolderButton.interactable = false;
if (openLogFolderButton != null) openLogFolderButton.interactable = false;
if (openCurrentLogButton != null) openCurrentLogButton.interactable = false;
}
}
private void RevealPath(string path, bool createDirectory)
{
if (DeveloperPathUtility.TryReveal(path, createDirectory, out var error))
SetSaveStatus($"已打开:{path}", false);
if (DeveloperPathUtility.TryReveal(ConstRef.TestSavePath, true, out var error))
SetSaveStatus($"已打开:{ConstRef.TestSavePath}", false);
else
SetSaveStatus(error, true);
}
private void CopyPath(string path)
private void DeleteSelectedTestSave()
{
if (string.IsNullOrWhiteSpace(path))
if (_selectedTestSave == null)
{
SetSaveStatus("当前没有日志文件;Editor 文件日志配置为关闭。", true);
SetSaveStatus("请先选择要删除的测试存档。", true);
return;
}
GUIUtility.systemCopyBuffer = Path.GetFullPath(path);
SetSaveStatus($"已复制路径:{path}", false);
if (!ConfirmDestructiveAction(
"delete-selected",
$"再次点击“删除选中”以确认删除 {_selectedTestSave.NodeName}。"))
{
return;
}
if (TestSaveRecorder.Repository.Delete(_selectedTestSave, out var error))
{
_selectedTestSave = null;
_selectedTestSaveCanJump = false;
_savesDirty = true;
RefreshSaves();
}
else
{
SetSaveStatus($"删除失败:{error}", true);
}
}
private void DeleteInvalidTestSaves()
{
if (!ConfirmDestructiveAction("delete-invalid", "再次点击“清理无效”以确认批量删除所有无效条目。"))
{
return;
}
var count = TestSaveRecorder.Repository.DeleteInvalid(out var error);
if (error == null)
{
_selectedTestSave = null;
_selectedTestSaveCanJump = false;
_savesDirty = true;
RefreshSaves();
SetSaveStatus($"已清理 {count} 个无效测试存档。", false);
}
else
{
SetSaveStatus($"清理失败:{error}", true);
}
}
private void ClearTestSaveLibrary()
{
if (!ConfirmDestructiveAction("clear-library", "再次点击“清空存档库”以确认删除全部测试存档。"))
{
return;
}
if (TestSaveRecorder.Repository.Clear(out var error))
{
_selectedTestSave = null;
_selectedTestSaveCanJump = false;
_selectedTestSaveChapterId = null;
_savesDirty = true;
RefreshSaves();
SetSaveStatus("测试存档库已清空。", false);
}
else
{
SetSaveStatus($"清空失败:{error}", true);
}
}
private bool ConfirmDestructiveAction(string action, string prompt)
{
var now = Time.unscaledTime;
if (!string.Equals(_pendingDestructiveAction, action, StringComparison.Ordinal)
|| now > _pendingDestructiveActionDeadline)
{
_pendingDestructiveAction = action;
_pendingDestructiveActionDeadline = now + 5f;
SetSaveStatus(prompt + "5 秒内有效)", true);
return false;
}
_pendingDestructiveAction = null;
_pendingDestructiveActionDeadline = 0f;
return true;
}
private void UpdateTestSaveActions()
{
var hasSelection = _selectedTestSave != null;
if (jumpToTestSaveButton != null)
jumpToTestSaveButton.interactable = hasSelection
&& _selectedTestSaveCanJump
&& _selectedTestSave.IsValid
&& CanStartRestore();
if (deleteSelectedTestSaveButton != null)
deleteSelectedTestSaveButton.interactable = hasSelection;
}
private void UpdateTestSaveRecordingState(bool recording)
{
if (testSaveRecordingStateText == null) return;
testSaveRecordingStateText.text = recording ? "● RECORDING" : "○ OFF";
testSaveRecordingStateText.color = recording
? new Color(1f, 0.35f, 0.3f)
: new Color(0.55f, 0.65f, 0.7f);
}
private void OnTestSaveLibraryChanged()
{
_savesDirty = true;
}
#endif
private void BindDisplayControls()
{
showCursorToggle?.SetIsOnWithoutNotify(_showCursor);