feat(dev-save): 强化 Huoshan 阶段存档提升与跳转校验
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,22 @@ namespace AibisDream.EditorTools
|
||||
/// </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";
|
||||
@@ -100,8 +116,19 @@ namespace AibisDream.EditorTools
|
||||
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 && Directory.Exists(sectionDir))
|
||||
if ((clearSectionFirst || IsHuoshanSection()) && Directory.Exists(sectionDir))
|
||||
Directory.Delete(sectionDir, recursive: true);
|
||||
|
||||
Directory.CreateDirectory(sectionDir);
|
||||
@@ -109,11 +136,12 @@ namespace AibisDream.EditorTools
|
||||
|
||||
int copied = 0;
|
||||
int index = 1;
|
||||
foreach (var item in candidates.OrderBy(c => c.SavedAt, StringComparer.Ordinal)
|
||||
.ThenBy(c => c.NodeName, StringComparer.Ordinal))
|
||||
foreach (var item in OrderCandidates(candidates))
|
||||
{
|
||||
var safeNode = SanitizeFolderName(string.IsNullOrEmpty(item.NodeName) ? "no_node" : item.NodeName);
|
||||
var folderName = $"{index:00}_{safeNode}";
|
||||
var folderName = IsHuoshanSection()
|
||||
? HuoshanCheckpoints.First(checkpoint => checkpoint.NodeName == item.NodeName).FolderName
|
||||
: $"{index:00}_{safeNode}";
|
||||
var destDir = Path.Combine(sectionDir, folderName);
|
||||
Directory.CreateDirectory(destDir);
|
||||
|
||||
@@ -176,13 +204,42 @@ namespace AibisDream.EditorTools
|
||||
}
|
||||
|
||||
if (!keepLatestPerNode)
|
||||
return list.OrderBy(c => c.SavedAt, StringComparer.Ordinal).ToList();
|
||||
return FilterHuoshanCheckpoints(list.OrderBy(c => c.SavedAt, StringComparer.Ordinal).ToList());
|
||||
|
||||
return list
|
||||
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)
|
||||
@@ -210,18 +267,35 @@ namespace AibisDream.EditorTools
|
||||
}
|
||||
|
||||
catalog.sections ??= new List<CatalogSectionDto>();
|
||||
if (catalog.sections.All(s => !string.Equals(s.id, sectionId, StringComparison.OrdinalIgnoreCase)))
|
||||
var section = catalog.sections.FirstOrDefault(
|
||||
s => string.Equals(s.id, sectionId, StringComparison.OrdinalIgnoreCase));
|
||||
if (section == null)
|
||||
{
|
||||
catalog.sections.Add(new CatalogSectionDto
|
||||
section = new CatalogSectionDto
|
||||
{
|
||||
id = sectionId,
|
||||
title = sectionId
|
||||
});
|
||||
File.WriteAllText(
|
||||
catalogPath,
|
||||
JsonConvert.SerializeObject(catalog, Formatting.Indented),
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
};
|
||||
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}"
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
catalogPath,
|
||||
JsonConvert.SerializeObject(catalog, Formatting.Indented),
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
}
|
||||
|
||||
private static void CopyIfExists(string src, string dest)
|
||||
@@ -285,6 +359,17 @@ namespace AibisDream.EditorTools
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace AibisDream
|
||||
private string _currentSceneName;
|
||||
|
||||
public string CurrentSceneName => _currentSceneName;
|
||||
public bool IsLoading => _isLoading;
|
||||
|
||||
public override void OnSingletonInit()
|
||||
{
|
||||
@@ -102,6 +103,7 @@ namespace AibisDream
|
||||
|
||||
if (_loadHandle.IsValid())
|
||||
{
|
||||
DOTween.KillAll();
|
||||
ResourceSystem.ReleaseSceneLoader();
|
||||
|
||||
yield return Addressables.UnloadSceneAsync(_loadHandle);
|
||||
|
||||
@@ -213,26 +213,44 @@ namespace AibisDream
|
||||
if (isJumping)
|
||||
yield break;
|
||||
|
||||
string savePath = entry.SnapshotPathWithoutExtension;
|
||||
if (string.IsNullOrEmpty(savePath) || !File.Exists(savePath + ".json"))
|
||||
if (!entry.TryLoadSnapshot(out var snapshot, out var validationError))
|
||||
{
|
||||
SetStatus($"Save missing: {entry.Label}", 5f);
|
||||
SetStatus($"Invalid save: {validationError}", 8f);
|
||||
yield break;
|
||||
}
|
||||
|
||||
isJumping = true;
|
||||
|
||||
if (NeedsResetToMainMenu())
|
||||
try
|
||||
{
|
||||
SetStatus($"Resetting: {entry.Label}");
|
||||
GameManager.Instance.QuitGame();
|
||||
yield return WaitForMainMenuReady();
|
||||
}
|
||||
if (NeedsResetToMainMenu())
|
||||
{
|
||||
SetStatus($"Resetting: {entry.Label}");
|
||||
GameManager.Instance.QuitGame();
|
||||
|
||||
SetStatus($"Loading: {entry.Label}");
|
||||
yield return SaveRestoreOrchestrator.RestoreFromFile(savePath);
|
||||
SetStatus($"Loaded: {entry.Label}", 3f);
|
||||
isJumping = false;
|
||||
bool mainMenuReady = false;
|
||||
yield return WaitForMainMenuReady(result => mainMenuReady = result);
|
||||
if (!mainMenuReady)
|
||||
{
|
||||
SetStatus($"Reset timed out: {entry.Label}", 8f);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
SetStatus($"Loading: {entry.Label}");
|
||||
yield return SaveRestoreOrchestrator.RestoreFromFile(entry.SnapshotPathWithoutExtension);
|
||||
|
||||
if (!ValidateRestoredRuntime(snapshot, out var restoreError))
|
||||
{
|
||||
SetStatus($"Restore failed: {restoreError}", 8f);
|
||||
yield break;
|
||||
}
|
||||
|
||||
SetStatus($"Loaded: {entry.Label}", 3f);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isJumping = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool NeedsResetToMainMenu()
|
||||
@@ -248,7 +266,7 @@ namespace AibisDream
|
||||
return sceneLoader != null && !string.IsNullOrEmpty(sceneLoader.CurrentSceneName);
|
||||
}
|
||||
|
||||
private static IEnumerator WaitForMainMenuReady()
|
||||
private static IEnumerator WaitForMainMenuReady(Action<bool> onCompleted)
|
||||
{
|
||||
const float timeoutSeconds = 15f;
|
||||
float elapsed = 0f;
|
||||
@@ -258,16 +276,57 @@ namespace AibisDream
|
||||
var gameManager = GameManager.Instance;
|
||||
var sceneLoader = SceneLoader.Instance;
|
||||
bool sceneCleared = sceneLoader == null || string.IsNullOrEmpty(sceneLoader.CurrentSceneName);
|
||||
bool sceneIdle = sceneLoader == null || !sceneLoader.IsLoading;
|
||||
bool notInGame = gameManager == null || !gameManager.state.isInGame;
|
||||
|
||||
if (sceneCleared && notInGame)
|
||||
if (sceneCleared && sceneIdle && notInGame)
|
||||
{
|
||||
onCompleted?.Invoke(true);
|
||||
yield break;
|
||||
}
|
||||
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Debug.LogWarning("[DevSaveJumpTool] 等待主界面超时,继续读档。");
|
||||
Debug.LogWarning("[DevSaveJumpTool] 等待主界面超时,已取消读档。");
|
||||
onCompleted?.Invoke(false);
|
||||
}
|
||||
|
||||
private static bool ValidateRestoredRuntime(SaveSnapshot snapshot, out string error)
|
||||
{
|
||||
var sceneLoader = SceneLoader.Instance;
|
||||
if (sceneLoader == null || sceneLoader.IsLoading)
|
||||
{
|
||||
error = "scene loader is not ready";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(sceneLoader.CurrentSceneName, snapshot.scene?.sceneName, StringComparison.Ordinal))
|
||||
{
|
||||
error = $"scene mismatch ({sceneLoader.CurrentSceneName ?? "none"})";
|
||||
return false;
|
||||
}
|
||||
|
||||
var runner = DialogController.Instance?.DialogueRunner;
|
||||
if (runner?.YarnProject == null
|
||||
|| !string.Equals(runner.YarnProject.name, snapshot.anchor?.yarnProjectId, StringComparison.Ordinal))
|
||||
{
|
||||
error = $"YarnProject mismatch ({runner?.YarnProject?.name ?? "none"})";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (snapshot.sections != null
|
||||
&& snapshot.sections.ContainsKey(SnapshotProviderIds.Fix)
|
||||
&& (FixSystem.FixSystemCenter.Instance == null
|
||||
|| !FixSystem.FixSystemCenter.Instance.IsDirectorReady))
|
||||
{
|
||||
error = "FixSystem is not ready";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ReloadCatalog()
|
||||
@@ -341,7 +400,14 @@ namespace AibisDream
|
||||
|
||||
var sectionDir = Path.Combine(root, sectionDto.id);
|
||||
var title = string.IsNullOrWhiteSpace(sectionDto.title) ? sectionDto.id : sectionDto.title;
|
||||
result.Add(BuildSection(sectionDto.id, title, sectionDir, sectionDto.entries));
|
||||
result.Add(BuildSection(
|
||||
sectionDto.id,
|
||||
title,
|
||||
sectionDir,
|
||||
sectionDto.entries,
|
||||
sectionDto.expectedSceneName,
|
||||
sectionDto.expectedYarnProjectId,
|
||||
sectionDto.allowedNodes));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -356,7 +422,7 @@ namespace AibisDream
|
||||
foreach (var dir in Directory.GetDirectories(root).OrderBy(d => d, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var id = Path.GetFileName(dir);
|
||||
result.Add(BuildSection(id, id, dir, null));
|
||||
result.Add(BuildSection(id, id, dir, null, null, null, null));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -366,7 +432,10 @@ namespace AibisDream
|
||||
string id,
|
||||
string title,
|
||||
string sectionDir,
|
||||
List<CatalogEntryDto> explicitEntries)
|
||||
List<CatalogEntryDto> explicitEntries,
|
||||
string expectedSceneName,
|
||||
string expectedYarnProjectId,
|
||||
List<string> allowedNodes)
|
||||
{
|
||||
var entries = new List<DevSaveEntry>();
|
||||
var root = Directory.GetParent(sectionDir)?.FullName;
|
||||
@@ -383,13 +452,21 @@ namespace AibisDream
|
||||
? relative
|
||||
: Path.Combine(root, relative);
|
||||
var snapshot = Path.Combine(entryDir, ConstRef.SaveSnapshotFileName);
|
||||
if (!File.Exists(snapshot + ".json"))
|
||||
var entry = new DevSaveEntry(
|
||||
label: string.IsNullOrWhiteSpace(entryDto.label)
|
||||
? FormatFolderLabel(Path.GetFileName(entryDir))
|
||||
: entryDto.label,
|
||||
snapshotPathWithoutExtension: snapshot,
|
||||
expectedSceneName,
|
||||
expectedYarnProjectId,
|
||||
allowedNodes);
|
||||
if (!entry.TryLoadSnapshot(out _, out var error))
|
||||
{
|
||||
Debug.LogWarning($"[DevSaveJumpTool] 跳过无效存档 {entryDto.path}: {error}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var label = string.IsNullOrWhiteSpace(entryDto.label)
|
||||
? FormatFolderLabel(Path.GetFileName(entryDir))
|
||||
: entryDto.label;
|
||||
entries.Add(new DevSaveEntry(label, snapshot));
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
else if (Directory.Exists(sectionDir))
|
||||
@@ -398,10 +475,19 @@ namespace AibisDream
|
||||
.OrderBy(d => d, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var snapshot = Path.Combine(entryDir, ConstRef.SaveSnapshotFileName);
|
||||
if (!File.Exists(snapshot + ".json"))
|
||||
var entry = new DevSaveEntry(
|
||||
FormatFolderLabel(Path.GetFileName(entryDir)),
|
||||
snapshot,
|
||||
expectedSceneName,
|
||||
expectedYarnProjectId,
|
||||
allowedNodes);
|
||||
if (!entry.TryLoadSnapshot(out _, out var error))
|
||||
{
|
||||
Debug.LogWarning($"[DevSaveJumpTool] 跳过无效存档 {entryDir}: {error}");
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(new DevSaveEntry(FormatFolderLabel(Path.GetFileName(entryDir)), snapshot));
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,6 +514,9 @@ namespace AibisDream
|
||||
public string id;
|
||||
public string title;
|
||||
public List<CatalogEntryDto> entries;
|
||||
public string expectedSceneName;
|
||||
public string expectedYarnProjectId;
|
||||
public List<string> allowedNodes;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -456,11 +545,95 @@ namespace AibisDream
|
||||
{
|
||||
public readonly string Label;
|
||||
public readonly string SnapshotPathWithoutExtension;
|
||||
private readonly string expectedSceneName;
|
||||
private readonly string expectedYarnProjectId;
|
||||
private readonly List<string> allowedNodes;
|
||||
|
||||
public DevSaveEntry(string label, string snapshotPathWithoutExtension)
|
||||
public DevSaveEntry(
|
||||
string label,
|
||||
string snapshotPathWithoutExtension,
|
||||
string expectedSceneName,
|
||||
string expectedYarnProjectId,
|
||||
List<string> allowedNodes)
|
||||
{
|
||||
Label = label;
|
||||
SnapshotPathWithoutExtension = snapshotPathWithoutExtension;
|
||||
this.expectedSceneName = expectedSceneName;
|
||||
this.expectedYarnProjectId = expectedYarnProjectId;
|
||||
this.allowedNodes = allowedNodes;
|
||||
}
|
||||
|
||||
public bool TryLoadSnapshot(out SaveSnapshot snapshot, out string error)
|
||||
{
|
||||
snapshot = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrEmpty(SnapshotPathWithoutExtension)
|
||||
|| !File.Exists(SnapshotPathWithoutExtension + ".json"))
|
||||
{
|
||||
error = "file missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (SnapshotPersistence.IsLegacyFormat(SnapshotPathWithoutExtension))
|
||||
{
|
||||
error = "legacy format is not supported";
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = SnapshotPersistence.Load(SnapshotPathWithoutExtension);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"invalid JSON ({ex.Message})";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (snapshot == null || snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion)
|
||||
{
|
||||
error = $"unsupported schema ({snapshot?.schemaVersion.ToString() ?? "none"})";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (snapshot.scene == null || string.IsNullOrWhiteSpace(snapshot.scene.sceneName))
|
||||
{
|
||||
error = "scene is missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (snapshot.anchor == null
|
||||
|| string.IsNullOrWhiteSpace(snapshot.anchor.yarnProjectId)
|
||||
|| string.IsNullOrWhiteSpace(snapshot.anchor.nodeName))
|
||||
{
|
||||
error = "Yarn anchor is missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedSceneName)
|
||||
&& !string.Equals(snapshot.scene.sceneName, expectedSceneName, StringComparison.Ordinal))
|
||||
{
|
||||
error = $"scene must be {expectedSceneName}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedYarnProjectId)
|
||||
&& !string.Equals(snapshot.anchor.yarnProjectId, expectedYarnProjectId, StringComparison.Ordinal))
|
||||
{
|
||||
error = $"YarnProject must be {expectedYarnProjectId}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allowedNodes != null
|
||||
&& allowedNodes.Count > 0
|
||||
&& !allowedNodes.Contains(snapshot.anchor.nodeName, StringComparer.Ordinal))
|
||||
{
|
||||
error = $"node is not allowed ({snapshot.anchor.nodeName})";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user