Files
aibis-dream/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs
T

379 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AibisDream.SaveSystem;
using AibisDream.Utility;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
namespace AibisDream.EditorTools
{
/// <summary>
/// 将本机 testsavs 归档提升到 StreamingAssets/TestSaveFiles,便于提交与跨机器复用。
/// </summary>
public sealed class DevSavePromoteWindow : EditorWindow
{
private const string HuoshanSectionId = "Huoshan1";
private const string HuoshanSceneName = "Scene/HuoShanFixScene";
private const string HuoshanYarnProjectId = "FP_Huoshan1";
private static readonly (string NodeName, string Label, string FolderName)[] HuoshanCheckpoints =
{
("开头对话", "Stage1 开场", "01_Stage1"),
("Stage2就绪", "Stage2 初检", "02_Stage2"),
("Stage3就绪", "Stage3 滤波器", "03_Stage3"),
("Stage4就绪", "Stage4 表达深入", "04_Stage4"),
("Stage5就绪", "Stage5 中场对话", "05_Stage5"),
("Stage6就绪", "Stage6 LOG 释放", "06_Stage6"),
("Stage7就绪", "Stage7 最终检查", "07_Stage7"),
("结束对话", "Stage8 结束", "08_Stage8"),
};
private string sectionId = "Huoshan1";
private string yarnProjectFilter = "FP_Huoshan1";
private string sceneFilter = "Scene/HuoShanFixScene";
private bool matchYarn = true;
private bool matchScene = true;
private bool keepLatestPerNode = true;
private bool clearSectionFirst;
private Vector2 scroll;
private string status = "";
private List<Candidate> preview = new();
[MenuItem("Tools/Aibis/Dev Save Promote")]
public static void Open()
{
var window = GetWindow<DevSavePromoteWindow>("Dev Save Promote");
window.minSize = new Vector2(520, 420);
window.Show();
}
private void OnGUI()
{
EditorGUILayout.LabelField("Promote testsavs → StreamingAssets/TestSaveFiles", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"从本机 TestAutoSaveArchivetestsavs)筛选存档,复制到可提交目录。\n" +
$"目标: {ConstRef.TestSaveFilePath}",
MessageType.Info);
sectionId = EditorGUILayout.TextField("Section Id", sectionId);
matchYarn = EditorGUILayout.Toggle("Filter by YarnProject", matchYarn);
using (new EditorGUI.DisabledScope(!matchYarn))
yarnProjectFilter = EditorGUILayout.TextField("YarnProject Id", yarnProjectFilter);
matchScene = EditorGUILayout.Toggle("Filter by Scene", matchScene);
using (new EditorGUI.DisabledScope(!matchScene))
sceneFilter = EditorGUILayout.TextField("Scene Name", sceneFilter);
keepLatestPerNode = EditorGUILayout.Toggle("Keep latest per nodeName", keepLatestPerNode);
clearSectionFirst = EditorGUILayout.Toggle("Clear section folder first", clearSectionFirst);
EditorGUILayout.Space(8);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Refresh Preview", GUILayout.Height(28)))
RefreshPreview();
if (GUILayout.Button("Promote Selected Filters", GUILayout.Height(28)))
Promote();
}
if (!string.IsNullOrEmpty(status))
EditorGUILayout.HelpBox(status, MessageType.None);
EditorGUILayout.Space(4);
EditorGUILayout.LabelField($"Matches: {preview.Count}", EditorStyles.miniBoldLabel);
scroll = EditorGUILayout.BeginScrollView(scroll);
foreach (var item in preview)
{
EditorGUILayout.LabelField(
$"{item.FolderName} | node={item.NodeName} | yarn={item.YarnProjectId} | scene={item.SceneName}");
}
EditorGUILayout.EndScrollView();
}
private void RefreshPreview()
{
preview = CollectCandidates();
status = $"Preview {preview.Count} entries from {ConstRef.TestAutoSaveArchivePath}";
}
private void Promote()
{
if (string.IsNullOrWhiteSpace(sectionId))
{
status = "Section Id 不能为空。";
return;
}
var candidates = CollectCandidates();
if (candidates.Count == 0)
{
status = "没有匹配的 testsavs 条目。先在 Editor 里打通流程(测试存档模式会写入 testsavs)。";
preview = candidates;
return;
}
if (IsHuoshanSection()
&& HuoshanCheckpoints.Any(checkpoint => candidates.All(c => c.NodeName != checkpoint.NodeName)))
{
var missing = HuoshanCheckpoints
.Where(checkpoint => candidates.All(c => c.NodeName != checkpoint.NodeName))
.Select(checkpoint => checkpoint.NodeName);
status = $"Huoshan1 缺少阶段存档:{string.Join(", ", missing)}。请完整跑通流程后再提升。";
preview = candidates;
return;
}
var sectionDir = Path.Combine(ConstRef.TestSaveFilePath, sectionId.Trim());
if ((clearSectionFirst || IsHuoshanSection()) && Directory.Exists(sectionDir))
Directory.Delete(sectionDir, recursive: true);
Directory.CreateDirectory(sectionDir);
EnsureCatalogSection(sectionId.Trim());
int copied = 0;
int index = 1;
foreach (var item in OrderCandidates(candidates))
{
var safeNode = SanitizeFolderName(string.IsNullOrEmpty(item.NodeName) ? "no_node" : item.NodeName);
var folderName = IsHuoshanSection()
? HuoshanCheckpoints.First(checkpoint => checkpoint.NodeName == item.NodeName).FolderName
: $"{index:00}_{safeNode}";
var destDir = Path.Combine(sectionDir, folderName);
Directory.CreateDirectory(destDir);
CopyIfExists(item.SnapshotPath, Path.Combine(destDir, $"{ConstRef.SaveSnapshotFileName}.json"));
CopyIfExists(item.MetaPath, Path.Combine(destDir, $"{ConstRef.SaveMetaFileName}.json"));
copied++;
index++;
}
preview = candidates;
AssetDatabase.Refresh();
status = $"已复制 {copied} 份到 TestSaveFiles/{sectionId.Trim()}";
}
private List<Candidate> CollectCandidates()
{
var list = new List<Candidate>();
if (!Directory.Exists(ConstRef.TestAutoSaveArchivePath))
return list;
foreach (var dir in Directory.GetDirectories(ConstRef.TestAutoSaveArchivePath))
{
var snapshotPath = Path.Combine(dir, $"{ConstRef.SaveSnapshotFileName}.json");
if (!File.Exists(snapshotPath))
continue;
var metaPath = Path.Combine(dir, $"{ConstRef.SaveMetaFileName}.json");
SlotMeta meta = null;
if (File.Exists(metaPath))
{
try
{
meta = JsonConvert.DeserializeObject<SlotMeta>(File.ReadAllText(metaPath, Encoding.UTF8));
}
catch
{
// ignore broken meta
}
}
var yarn = meta?.yarnProjectId ?? string.Empty;
var scene = meta?.sceneName ?? string.Empty;
var node = meta?.nodeName ?? string.Empty;
var savedAt = meta?.savedAt ?? string.Empty;
bool yarnOk = !matchYarn || string.Equals(yarn, yarnProjectFilter, StringComparison.OrdinalIgnoreCase);
bool sceneOk = !matchScene || string.Equals(scene, sceneFilter, StringComparison.OrdinalIgnoreCase);
if (!yarnOk || !sceneOk)
continue;
list.Add(new Candidate(
Path.GetFileName(dir),
dir,
snapshotPath,
File.Exists(metaPath) ? metaPath : null,
node,
yarn,
scene,
savedAt));
}
if (!keepLatestPerNode)
return FilterHuoshanCheckpoints(list.OrderBy(c => c.SavedAt, StringComparer.Ordinal).ToList());
var latestPerNode = list
.GroupBy(c => string.IsNullOrEmpty(c.NodeName) ? c.FolderName : c.NodeName, StringComparer.Ordinal)
.Select(g => g.OrderByDescending(c => c.SavedAt, StringComparer.Ordinal).First())
.OrderBy(c => c.SavedAt, StringComparer.Ordinal)
.ToList();
return FilterHuoshanCheckpoints(latestPerNode);
}
private bool IsHuoshanSection()
{
return string.Equals(sectionId?.Trim(), HuoshanSectionId, StringComparison.OrdinalIgnoreCase);
}
private List<Candidate> FilterHuoshanCheckpoints(List<Candidate> candidates)
{
if (!IsHuoshanSection())
return candidates;
var allowedNodes = new HashSet<string>(
HuoshanCheckpoints.Select(checkpoint => checkpoint.NodeName),
StringComparer.Ordinal);
return candidates.Where(candidate => allowedNodes.Contains(candidate.NodeName)).ToList();
}
private IEnumerable<Candidate> OrderCandidates(List<Candidate> candidates)
{
if (!IsHuoshanSection())
{
return candidates.OrderBy(c => c.SavedAt, StringComparer.Ordinal)
.ThenBy(c => c.NodeName, StringComparer.Ordinal);
}
return HuoshanCheckpoints.Select(checkpoint =>
candidates.First(candidate => candidate.NodeName == checkpoint.NodeName));
}
private static void EnsureCatalogSection(string sectionId)
{
var root = ConstRef.TestSaveFilePath;
Directory.CreateDirectory(root);
var catalogPath = Path.Combine(root, "catalog.json");
CatalogDto catalog;
if (File.Exists(catalogPath))
{
try
{
catalog = JsonConvert.DeserializeObject<CatalogDto>(File.ReadAllText(catalogPath, Encoding.UTF8))
?? new CatalogDto();
}
catch
{
catalog = new CatalogDto();
}
}
else
{
catalog = new CatalogDto();
}
catalog.sections ??= new List<CatalogSectionDto>();
var section = catalog.sections.FirstOrDefault(
s => string.Equals(s.id, sectionId, StringComparison.OrdinalIgnoreCase));
if (section == null)
{
section = new CatalogSectionDto
{
id = sectionId,
title = sectionId
};
catalog.sections.Add(section);
}
if (string.Equals(sectionId, HuoshanSectionId, StringComparison.OrdinalIgnoreCase))
{
section.title = "Huoshan 1";
section.expectedSceneName = HuoshanSceneName;
section.expectedYarnProjectId = HuoshanYarnProjectId;
section.allowedNodes = HuoshanCheckpoints.Select(checkpoint => checkpoint.NodeName).ToList();
section.entries = HuoshanCheckpoints.Select(checkpoint => new CatalogEntryDto
{
label = checkpoint.Label,
path = $"{HuoshanSectionId}/{checkpoint.FolderName}",
anchorNode = checkpoint.NodeName
}).ToList();
}
File.WriteAllText(
catalogPath,
JsonConvert.SerializeObject(catalog, Formatting.Indented),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
private static void CopyIfExists(string src, string dest)
{
if (string.IsNullOrEmpty(src) || !File.Exists(src))
return;
File.Copy(src, dest, overwrite: true);
}
private static string SanitizeFolderName(string name)
{
foreach (var c in Path.GetInvalidFileNameChars())
name = name.Replace(c, '_');
name = name.Replace(' ', '_');
if (name.Length > 48)
name = name.Substring(0, 48);
return string.IsNullOrWhiteSpace(name) ? "no_node" : name;
}
private sealed class Candidate
{
public readonly string FolderName;
public readonly string DirectoryPath;
public readonly string SnapshotPath;
public readonly string MetaPath;
public readonly string NodeName;
public readonly string YarnProjectId;
public readonly string SceneName;
public readonly string SavedAt;
public Candidate(
string folderName,
string directoryPath,
string snapshotPath,
string metaPath,
string nodeName,
string yarnProjectId,
string sceneName,
string savedAt)
{
FolderName = folderName;
DirectoryPath = directoryPath;
SnapshotPath = snapshotPath;
MetaPath = metaPath;
NodeName = nodeName;
YarnProjectId = yarnProjectId;
SceneName = sceneName;
SavedAt = savedAt;
}
}
[Serializable]
private class CatalogDto
{
public List<CatalogSectionDto> sections = new();
}
[Serializable]
private class CatalogSectionDto
{
public string id;
public string title;
public List<CatalogEntryDto> entries;
public string expectedSceneName;
public string expectedYarnProjectId;
public List<string> allowedNodes;
}
[Serializable]
private class CatalogEntryDto
{
public string label;
public string path;
public string anchorNode;
}
}
}
#endif