#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
{
///
/// 将本机 testsavs 归档提升到 StreamingAssets/TestSaveFiles,便于提交与跨机器复用。
///
public sealed class DevSavePromoteWindow : EditorWindow
{
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 preview = new();
[MenuItem("Tools/Aibis/Dev Save Promote")]
public static void Open()
{
var window = GetWindow("Dev Save Promote");
window.minSize = new Vector2(520, 420);
window.Show();
}
private void OnGUI()
{
EditorGUILayout.LabelField("Promote testsavs → StreamingAssets/TestSaveFiles", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"从本机 TestAutoSaveArchive(testsavs)筛选存档,复制到可提交目录。\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;
}
var sectionDir = Path.Combine(ConstRef.TestSaveFilePath, sectionId.Trim());
if (clearSectionFirst && Directory.Exists(sectionDir))
Directory.Delete(sectionDir, recursive: true);
Directory.CreateDirectory(sectionDir);
EnsureCatalogSection(sectionId.Trim());
int copied = 0;
int index = 1;
foreach (var item in candidates.OrderBy(c => c.SavedAt, StringComparer.Ordinal)
.ThenBy(c => c.NodeName, StringComparer.Ordinal))
{
var safeNode = SanitizeFolderName(string.IsNullOrEmpty(item.NodeName) ? "no_node" : item.NodeName);
var 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 CollectCandidates()
{
var list = new List();
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(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 list.OrderBy(c => c.SavedAt, StringComparer.Ordinal).ToList();
return 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();
}
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(File.ReadAllText(catalogPath, Encoding.UTF8))
?? new CatalogDto();
}
catch
{
catalog = new CatalogDto();
}
}
else
{
catalog = new CatalogDto();
}
catalog.sections ??= new List();
if (catalog.sections.All(s => !string.Equals(s.id, sectionId, StringComparison.OrdinalIgnoreCase)))
{
catalog.sections.Add(new CatalogSectionDto
{
id = sectionId,
title = sectionId
});
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 sections = new();
}
[Serializable]
private class CatalogSectionDto
{
public string id;
public string title;
}
}
}
#endif