feat: 测试存档功能
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user