TH1/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs
2026-06-08 19:31:16 +08:00

755 lines
32 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.

/*
* @Author: Codex
* @Description: 新版游戏存档索引与读写
* @Date: 2026年06月02日 星期二 00:00:00
* @Modify:
*/
using System;
using System.Collections.Generic;
using System.IO;
using Logic.CrashSight;
using RuntimeData;
using TH1_Logic.Core;
using TH1_Logic.MatchConfig;
using TH1_Logic.Tools;
using UnityEngine;
namespace TH1_Logic.GameArchive
{
// 新存档系统的文件类型。
// 注意:这里说的是“磁盘文件归类”,不是 GameRecord 的记录分类。
// GameRecordKind 负责 UI/数据层看到的三类记录:结束记录、手动存档、快速存档。
public enum GameArchiveFileKind
{
// 开局快照。每条新版 GameRecord 都会索引一条 begin用来说明这条记录属于哪一局。
Begin,
// 自动快速继续存档。全局只保留 quick.dat一局一局覆盖。
QuickContinue,
// 玩家手动创建的通用继续存档。可以有多条,每条有自己的 archiveId。
Continue,
// 结束快照。游戏结束时写入,用于完整战绩记录索引。
End
}
// 新版存档系统的统一入口。
//
// 设计关系:
// - MapData 文件是真正的大存档内容,存在 Config/GameArchives 下。
// - GameRecord 只是索引和展示信息,不直接存完整 MapData。
// - 一条 record 一定指向 BeginArchiveId可能指向 ContinueArchiveId 或 EndArchiveId。
// - 继续游戏只允许从 Quick/Manual record 读取 ContinueArchiveIdEnded record 不能继续。
public class GameArchiveManager
{
// 快速存档只有一份,所以 recordId 和文件名都使用固定 id。
// 对应磁盘文件是 Config/GameArchives/quick_continue/quick.dat。
public const string QuickArchiveId = "quick";
public static GameArchiveManager Instance = new GameArchiveManager();
private const string ArchiveRootFolderName = "GameArchives";
// 当前正在游玩的这一局对应的 begin archive id。
// 后续每回合 quick、玩家手动 manual、最终 end 都会挂到这个 begin 上。
// 从旧存档入口继续时没有 record 可用,会自动创建一个新的 begin。
// 从 record 入口继续时会沿用 record.BeginArchiveId。
private string _currentBeginArchiveId;
public string CurrentBeginArchiveId => _currentBeginArchiveId;
// 开始一局游戏时调用:写入 begin 文件,并把当前会话绑定到这个 begin。
//
// 调用时机:
// - 单机新开局:地图生成完成后。
// - 联机房主新开局GameStart 广播成功后,避免网络开始失败却留下 begin。
// - 存档兜底:如果当前局缺少 begin会先补一条 begin避免 quick/manual/end 没有索引。
public bool SaveBeginArchive(MapData map)
{
// 先清空旧会话 id避免本次 begin 写入失败时误把后续存档挂到上一局。
_currentBeginArchiveId = string.Empty;
if (!IsMapSaveable(map)) return false;
var beginArchiveId = NewArchiveId();
if (!SaveArchive(map, GameArchiveFileKind.Begin, beginArchiveId)) return false;
_currentBeginArchiveId = beginArchiveId;
return true;
}
// 回合轮转时调用:写入/覆盖 quick_continue/quick.dat并更新唯一的快速存档 record。
//
// 快速存档规则:
// - 只维护一条记录UpsertQuickRecord 会先删旧 quick record 再写新 record。
// - 文件也只维护一份 quick.dat每次保存覆盖。
// - record.BeginArchiveId 说明 quick 属于哪一局。
// - record.ContinueArchiveId 固定为 quick用于继续时找到 quick.dat。
public bool SaveQuickContinueRecord(MapData map)
{
if (!EnsureCurrentBeginArchive(map)) return false;
if (!SaveArchive(map, GameArchiveFileKind.QuickContinue, QuickArchiveId)) return false;
var record = CreateRecord(
map,
GameRecordKind.Quick,
"快速存档",
_currentBeginArchiveId,
QuickArchiveId,
string.Empty);
record.RecordId = QuickArchiveId;
GameRecordManager.Instance.UpsertQuickRecord(record);
return true;
}
// 玩家主动创建通用存档的上层接口。
// UI 调这个就够了:当前 MapData、文件 id、record 索引都会由存档系统内部处理。
public bool SaveManualGameRecord(string recordName)
{
return SaveManualContinueRecord(Main.MapData, recordName);
}
// 玩家主动手动保存时调用:创建一条通用 continue 文件和一条 Manual record。
//
// 通用存档规则:
// - 可以有任意多条,所以每条 continue 文件都使用新的 archiveId。
// - recordName 是玩家自定义名字;空名字会归一成“手动存档”。
// - 这里只创建,不自动删除;清理必须走 CleanupGameRecord。
public bool SaveManualContinueRecord(MapData map, string recordName)
{
if (!EnsureCurrentBeginArchive(map)) return false;
var continueArchiveId = NewArchiveId();
if (!SaveArchive(map, GameArchiveFileKind.Continue, continueArchiveId)) return false;
var record = CreateRecord(
map,
GameRecordKind.Manual,
NormalizeManualRecordName(recordName),
_currentBeginArchiveId,
continueArchiveId,
string.Empty);
GameRecordManager.Instance.AddRecord(record);
return true;
}
// 游戏结束时调用:写入 end 文件,并创建一条 Ended record。
//
// 结束规则:
// - begin/end 文件都会保留Ended record 通过 EndArchiveId 指向 end。
// - 教程不进历史战绩,所以仍然只写 end 文件,不创建 Ended record。
// - 当前局结束后,当前 begin 对应的快速存档已经没有继续意义,因此一起清掉。
// - 手动通用存档不在这里删除,必须由玩家主动删。
public bool SaveEndRecord(MapData map)
{
if (!EnsureCurrentBeginArchive(map)) return false;
var endArchiveId = NewArchiveId();
if (!SaveArchive(map, GameArchiveFileKind.End, endArchiveId)) return false;
if (map.MapConfig?.MatchSettlement != MatchSettlementType.Tutor)
{
var record = CreateRecord(
map,
GameRecordKind.Ended,
string.Empty,
_currentBeginArchiveId,
string.Empty,
endArchiveId);
GameRecordManager.Instance.AddRecord(record);
}
DeleteQuickContinueRecord(_currentBeginArchiveId);
return true;
}
// 给外部 UI/逻辑做“这条 record 现在还能不能继续”的轻量检查。
// 这里不会扫描所有存档,只验证传入这一条 record 指向的 continue 文件。
public bool HasUsableResumeArchive(GameRecord record)
{
return TryLoadContinueArchive(record, out _);
}
// 当前是否存在可用的快速继续记录。
// 单机菜单传 NetMode.Single联机房主继续传 NetMode.Multi。
public bool HasQuickResumeArchive(NetMode netMode)
{
return GetQuickResumeRecord(netMode) != null;
}
// 获取当前唯一 quick record并确认它属于指定单/联机模式且文件可读。
public GameRecord GetQuickResumeRecord(NetMode netMode)
{
var record = GameRecordManager.Instance.GetQuickRecord();
if (record == null || record.NetMode != netMode) return null;
return HasUsableResumeArchive(record) ? record : null;
}
// 通过一条 Quick/Manual record 读取可继续的 MapData。
//
// 返回 true 的含义:
// - record 类型允许继续。
// - record 有 begin/continue 索引。
// - continue 文件存在,并且反序列化后核心数据完整。
// - 存档里的 NetMode 与 record.NetMode 一致,单机/联机不会串。
public bool TryLoadContinueArchive(GameRecord record, out MapData mapData)
{
mapData = null;
if (!CanRecordResume(record)) return false;
var archiveKind = record.RecordKind == GameRecordKind.Quick
? GameArchiveFileKind.QuickContinue
: GameArchiveFileKind.Continue;
mapData = ReadArchive(archiveKind, record.ContinueArchiveId, record.NetMode);
return mapData != null;
}
// 读取 record 对应的 begin 快照供回放、OSS 上传等需要“开局状态”的系统使用。
public bool TryLoadBeginArchive(GameRecord record, out MapData mapData)
{
mapData = null;
if (record == null) return false;
// Ended 清理后 record 仍可作为历史展示行存在,但不再拥有 begin/end 文件索引。
if (record.RecordKind == GameRecordKind.Ended && record.DiscardArchiveIndex) return false;
if (record.NetMode != NetMode.Single && record.NetMode != NetMode.Multi) return false;
if (string.IsNullOrEmpty(record.BeginArchiveId)) return false;
mapData = ReadArchive(GameArchiveFileKind.Begin, record.BeginArchiveId, record.NetMode);
return mapData != null;
}
// 读取 record 对应的 end 快照。只有 Ended record 通常会有 EndArchiveId。
public bool TryLoadEndArchive(GameRecord record, out MapData mapData)
{
mapData = null;
if (record == null) return false;
// 与 map 清理规则保持一致:丢弃索引的 Ended record 不能再读取 end 文件。
if (record.RecordKind == GameRecordKind.Ended && record.DiscardArchiveIndex) return false;
if (record.NetMode != NetMode.Single && record.NetMode != NetMode.Multi) return false;
if (string.IsNullOrEmpty(record.EndArchiveId)) return false;
mapData = ReadArchive(GameArchiveFileKind.End, record.EndArchiveId, record.NetMode);
return mapData != null;
}
// 读取当前会话的 begin。游戏结束上传 OSS 时endMap 是当前局begin id 仍保存在这里。
public bool TryLoadCurrentBeginArchive(NetMode expectedMode, out MapData mapData)
{
mapData = null;
if (string.IsNullOrEmpty(_currentBeginArchiveId)) return false;
if (expectedMode != NetMode.Single && expectedMode != NetMode.Multi) return false;
mapData = ReadArchive(GameArchiveFileKind.Begin, _currentBeginArchiveId, expectedMode);
return mapData != null;
}
// 根据 archiveId 获取实际文件路径,优先主文件,主文件不存在时返回 .bak。
// 这个接口用于 BugReport/回放编辑器打包和展示文件名,不负责反序列化。
public bool TryGetArchivePath(GameArchiveFileKind archiveKind, string archiveId, out string path)
{
path = GetArchivePath(archiveKind, archiveId);
if (path == null) return false;
if (File.Exists(path)) return true;
var backupPath = path + ".bak";
if (!File.Exists(backupPath)) return false;
path = backupPath;
return true;
}
// record 继续成功前设置当前会话 begin。
// 后续回合 quick、玩家 manual、最终 end 都会继续挂到原 begin 下。
public void SetActiveRecord(GameRecord record)
{
if (record == null) return;
_currentBeginArchiveId = record.BeginArchiveId;
}
// 用于继续流程失败时恢复旧会话 id。
// 正常业务一般不要直接调用。
public void SetCurrentBeginArchiveId(string beginArchiveId)
{
_currentBeginArchiveId = beginArchiveId;
}
// 删除快速存档 record 和 quick.dat。
//
// beginArchiveId 不为空时,只允许删除同一局的 quick。
// 这样某局结束时不会误删另一局刚写下的快速存档。
public bool DeleteQuickContinueRecord(string beginArchiveId = null)
{
var quickRecord = GameRecordManager.Instance.GetQuickRecord();
if (!string.IsNullOrEmpty(beginArchiveId)
&& quickRecord != null
&& quickRecord.BeginArchiveId != beginArchiveId)
{
return false;
}
DeleteArchive(GameArchiveFileKind.QuickContinue, QuickArchiveId);
return GameRecordManager.Instance.RemoveQuickRecord(beginArchiveId);
}
// 用户主动清理指定 record。这里只动 record 状态/索引,不清理 MapData 文件。
//
// - Quick自动快速存档由回合保存和游戏结束流程维护这个接口不动它。
// - Manual直接删除 record对应 continue/begin 文件等下一阶段无引用清理再回收。
// - Ended保留历史展示行只丢弃 begin/end 索引,等待 map 清理回收文件。
public bool CleanupGameRecord(GameRecord record)
{
if (record == null) return false;
switch (record.RecordKind)
{
case GameRecordKind.Quick:
// Quick 是唯一自动存档,用户清理 record 时不删除、不标记。
return false;
case GameRecordKind.Manual:
// Manual 本身就是可继续入口;删除 record 后它自然不再保护任何 map 文件。
return GameRecordManager.Instance.RemoveRecord(record);
case GameRecordKind.Ended:
// Ended 需要继续展示战绩,所以只让它停止保护 begin/end 文件。
return GameRecordManager.Instance.DiscardEndedRecordArchiveIndex(record);
default:
return false;
}
}
// 第二阶段 map 清理:删除所有没有有效 record 索引保护的本地 MapData 文件。
//
// Manual 清理会先删除 record所以这里天然不会再看到它
// Ended 清理只标记 DiscardArchiveIndex所以 BuildReferencedArchiveKeySet 会显式跳过它。
// 覆盖新版 GameArchives以及旧版 Config/map_archive_* 和早期 persistentDataPath/map_archive_*。
public int CleanupUnlinkedLocalMapData()
{
var referencedArchiveKeys = BuildReferencedArchiveKeySet();
var deletedCount = 0;
foreach (GameArchiveFileKind archiveKind in Enum.GetValues(typeof(GameArchiveFileKind)))
{
deletedCount += DeleteUnlinkedArchiveFiles(archiveKind, referencedArchiveKeys);
}
deletedCount += DeleteLegacyLocalMapData();
return deletedCount;
}
// 确保当前局已经有 begin。
// 正常新开局或 record 继续都会先绑定 begin这里主要兜底异常调用顺序。
private bool EnsureCurrentBeginArchive(MapData map)
{
return !string.IsNullOrEmpty(_currentBeginArchiveId) || SaveBeginArchive(map);
}
// 从当前 MapData 导出一条 GameRecord并填充新版存档索引字段。
//
// 重点record 不是完整存档,它只是:
// - 展示信息:模式、回合、时间、玩家、分数等。
// - 索引信息BeginArchiveId / ContinueArchiveId / EndArchiveId。
private GameRecord CreateRecord(
MapData map,
GameRecordKind recordKind,
string recordName,
string beginArchiveId,
string continueArchiveId,
string endArchiveId)
{
var record = map.ExportGameRecord();
record.RecordKind = recordKind;
record.RecordId = recordKind == GameRecordKind.Quick ? QuickArchiveId : NewArchiveId();
record.RecordName = recordName ?? string.Empty;
record.BeginArchiveId = beginArchiveId ?? string.Empty;
record.ContinueArchiveId = continueArchiveId ?? string.Empty;
record.EndArchiveId = endArchiveId ?? string.Empty;
return record;
}
// 真正写 MapData 文件的底层方法。
// 这里复用 MapData.SerializeArchiveBytes保证新旧存档格式的压缩/兼容编码一致。
private bool SaveArchive(MapData map, GameArchiveFileKind archiveKind, string archiveId)
{
if (!IsMapSaveable(map)) return false;
var path = GetArchivePath(archiveKind, archiveId);
if (path == null) return false;
try
{
byte[] bytes = MapData.SerializeArchiveBytes(map);
return FileTools.SafeWriteFile(path, bytes);
}
catch (Exception ex)
{
LogSystem.LogError($"[GameArchive] 保存存档失败: {archiveKind}/{archiveId} | {ex.Message}");
return false;
}
}
// 从磁盘读取某个 archiveId 对应的 MapData并做基本可用性校验。
// SafeWriteFile 会保留 .bak所以主文件坏了时这里会尝试读备份。
private MapData ReadArchive(GameArchiveFileKind archiveKind, string archiveId, NetMode expectedMode)
{
var path = GetArchivePath(archiveKind, archiveId);
if (path == null) return null;
var mapData = ReadArchiveFile(path);
if (mapData == null && !path.EndsWith(".bak", StringComparison.OrdinalIgnoreCase))
{
var backupPath = path + ".bak";
if (File.Exists(backupPath))
{
LogSystem.LogWarning($"[GameArchive] 读取存档失败,尝试读取备份: {backupPath}");
mapData = ReadArchiveFile(backupPath);
}
}
if (!IsMapReadable(mapData, expectedMode)) return null;
// 投降后的 continue 不允许继续end 文件不走继续入口,所以不受这个限制。
if (archiveKind != GameArchiveFileKind.End && mapData.PlayerMap.SelfPlayerData?.IsSurrender == true)
return null;
return mapData;
}
// 只负责读文件和反序列化,不做业务校验。
// 业务校验统一在 ReadArchive / IsMapReadable 里做。
private MapData ReadArchiveFile(string path)
{
if (!File.Exists(path)) return null;
try
{
byte[] bytes = File.ReadAllBytes(path);
return MapData.DeserializeArchiveBytes(bytes);
}
catch (Exception ex)
{
LogSystem.LogError($"[GameArchive] 读取存档失败: {path} | {ex.Message}");
return null;
}
}
// 判断一条 record 是否“理论上可以继续”。
// 这里只检查 record 自身字段;文件是否存在、内容是否完整,由 TryLoadContinueArchive 继续验证。
private bool CanRecordResume(GameRecord record)
{
if (record == null) return false;
if (record.RecordKind != GameRecordKind.Quick && record.RecordKind != GameRecordKind.Manual)
return false;
if (record.NetMode != NetMode.Single && record.NetMode != NetMode.Multi) return false;
if (string.IsNullOrEmpty(record.BeginArchiveId)) return false;
if (string.IsNullOrEmpty(record.ContinueArchiveId)) return false;
return true;
}
// 判断 MapData 是否适合写入新版存档。
// 这里排除旁观者和反序列化缺核心数据的坏档,避免把不可用内容写进新系统。
private bool IsMapSaveable(MapData map)
{
return map != null
&& map.Net != null
&& map.Net.Mode != NetMode.Spectator
&& !map.DeserializedMissingCriticalData
&& map.MapConfig != null
&& map.GridMap != null
&& map.PlayerMap != null
&& map.CityMap != null
&& map.UnitMap != null;
}
// 读档后校验:除完整性外,还要确认单机/联机模式没有串档。
private bool IsMapReadable(MapData map, NetMode expectedMode)
{
if (!IsMapSaveable(map)) return false;
if (map.Net.Mode != expectedMode)
{
LogSystem.LogWarning($"[GameArchive] 存档模式不匹配: expect={expectedMode}, actual={map.Net.Mode}");
return false;
}
return true;
}
// 删除一个 archive 文件及 SafeWriteFile 可能留下的备份/临时文件。
private void DeleteArchive(GameArchiveFileKind archiveKind, string archiveId)
{
var path = GetArchivePath(archiveKind, archiveId);
if (path == null) return;
DeleteFileIfExists(path);
DeleteFileIfExists(path + ".bak");
DeleteFileIfExists(path + ".tmp");
}
private bool DeleteFileIfExists(string path)
{
try
{
if (!File.Exists(path)) return false;
File.Delete(path);
return true;
}
catch (Exception ex)
{
LogSystem.LogError($"[GameArchive] 删除存档失败: {path} | {ex.Message}");
return false;
}
}
// 建立“仍被 record 保护”的 archive 集合。
//
// 只有进入这个集合的 begin/quick_continue/continue/end 文件才会被保留;
// 被清理过的 Ended record 虽然还在 Records 里,但已经主动放弃索引保护,必须跳过。
private HashSet<string> BuildReferencedArchiveKeySet()
{
var archiveKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var records = GameRecordManager.Instance.GameRecordData?.Records;
if (records != null)
{
foreach (var record in records)
{
if (record == null) continue;
if (record.RecordKind == GameRecordKind.Ended && record.DiscardArchiveIndex) continue;
AddArchiveKey(archiveKeys, GameArchiveFileKind.Begin, record.BeginArchiveId);
// Quick 的 continue 文件固定在 quick_continue/quick.datManual 的 continue 文件在 continue/{id}.dat。
if (record.RecordKind == GameRecordKind.Quick)
AddArchiveKey(archiveKeys, GameArchiveFileKind.QuickContinue, record.ContinueArchiveId);
else
AddArchiveKey(archiveKeys, GameArchiveFileKind.Continue, record.ContinueArchiveId);
AddArchiveKey(archiveKeys, GameArchiveFileKind.End, record.EndArchiveId);
}
}
AddArchiveKey(archiveKeys, GameArchiveFileKind.Begin, _currentBeginArchiveId);
return archiveKeys;
}
private void AddArchiveKey(HashSet<string> archiveKeys, GameArchiveFileKind archiveKind, string archiveId)
{
if (!IsValidArchiveId(archiveId)) return;
archiveKeys.Add(GetArchiveKey(archiveKind, archiveId));
}
private string GetArchiveKey(GameArchiveFileKind archiveKind, string archiveId)
{
return $"{archiveKind}:{archiveId}";
}
private int DeleteUnlinkedArchiveFiles(GameArchiveFileKind archiveKind, HashSet<string> referencedArchiveKeys)
{
var directory = GetArchiveDirectory(archiveKind);
if (!Directory.Exists(directory)) return 0;
var deletedCount = 0;
try
{
foreach (var path in Directory.EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly))
{
if (!TryGetArchiveIdFromFileName(path, out var archiveId)) continue;
if (referencedArchiveKeys.Contains(GetArchiveKey(archiveKind, archiveId))) continue;
if (DeleteFileIfExists(path)) deletedCount++;
}
}
catch (Exception ex)
{
LogSystem.LogError($"[GameArchive] 清理存档目录失败: {directory} | {ex.Message}");
}
return deletedCount;
}
private bool TryGetArchiveIdFromFileName(string path, out string archiveId)
{
archiveId = string.Empty;
var fileName = Path.GetFileName(path);
if (string.IsNullOrEmpty(fileName)) return false;
fileName = StripArchiveSidecarSuffix(fileName);
if (!fileName.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) return false;
archiveId = fileName.Substring(0, fileName.Length - ".dat".Length);
return IsValidArchiveId(archiveId);
}
private string StripArchiveSidecarSuffix(string fileName)
{
if (fileName.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase))
fileName = fileName.Substring(0, fileName.Length - ".tmp".Length);
if (fileName.EndsWith(".bak", StringComparison.OrdinalIgnoreCase))
fileName = fileName.Substring(0, fileName.Length - ".bak".Length);
return fileName;
}
private int DeleteLegacyLocalMapData()
{
var deletedCount = 0;
var configRoot = GetConfigRootDirectory();
var persistentRoot = Path.GetFullPath(Application.persistentDataPath);
deletedCount += DeleteLegacyMapArchiveFilesInDirectory(configRoot, true);
if (!string.Equals(configRoot, persistentRoot, StringComparison.OrdinalIgnoreCase))
deletedCount += DeleteLegacyMapArchiveFilesInDirectory(persistentRoot, false);
deletedCount += DeleteLegacyArchiveSubDirectoryFiles(configRoot);
return deletedCount;
}
private int DeleteLegacyMapArchiveFilesInDirectory(string directory, bool includeLooseArchiveNames)
{
if (!Directory.Exists(directory)) return 0;
var deletedCount = 0;
try
{
foreach (var path in Directory.EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly))
{
if (!IsLegacyMapArchiveFile(path, includeLooseArchiveNames)) continue;
if (DeleteFileIfExists(path)) deletedCount++;
}
}
catch (Exception ex)
{
LogSystem.LogError($"[GameArchive] 清理旧存档目录失败: {directory} | {ex.Message}");
}
return deletedCount;
}
private int DeleteLegacyArchiveSubDirectoryFiles(string configRoot)
{
var deletedCount = 0;
var legacyFolders = new[]
{
"begin",
"continue",
"end",
"begincontinue",
"begin_continue",
"quick_continue"
};
foreach (var folder in legacyFolders)
{
var directory = Path.Combine(configRoot, folder);
if (!Directory.Exists(directory)) continue;
try
{
foreach (var path in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories))
{
if (!IsArchivePayloadFile(path)) continue;
if (DeleteFileIfExists(path)) deletedCount++;
}
TryDeleteDirectoryIfEmpty(directory);
}
catch (Exception ex)
{
LogSystem.LogError($"[GameArchive] 清理旧存档子目录失败: {directory} | {ex.Message}");
}
}
return deletedCount;
}
private bool IsLegacyMapArchiveFile(string path, bool includeLooseArchiveNames)
{
var stem = GetArchivePayloadStem(path);
if (string.IsNullOrEmpty(stem)) return false;
if (stem.StartsWith("map_archive_", StringComparison.OrdinalIgnoreCase)) return true;
return includeLooseArchiveNames && IsLegacyLooseArchiveStem(stem);
}
private bool IsArchivePayloadFile(string path)
{
return !string.IsNullOrEmpty(GetArchivePayloadStem(path));
}
private string GetArchivePayloadStem(string path)
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrEmpty(fileName)) return string.Empty;
fileName = StripArchiveSidecarSuffix(fileName);
if (!fileName.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) return string.Empty;
return fileName.Substring(0, fileName.Length - ".dat".Length);
}
private bool IsLegacyLooseArchiveStem(string stem)
{
return IsLegacyStem(stem, "begin")
|| IsLegacyStem(stem, "continue")
|| IsLegacyStem(stem, "end")
|| IsLegacyStem(stem, "begincontinue")
|| IsLegacyStem(stem, "begin_continue")
|| IsLegacyStem(stem, "quick_continue");
}
private bool IsLegacyStem(string stem, string prefix)
{
return string.Equals(stem, prefix, StringComparison.OrdinalIgnoreCase)
|| stem.StartsWith(prefix + "_", StringComparison.OrdinalIgnoreCase);
}
private void TryDeleteDirectoryIfEmpty(string directory)
{
try
{
if (Directory.Exists(directory) && Directory.GetFileSystemEntries(directory).Length == 0)
Directory.Delete(directory);
}
catch
{
// ignored
}
}
// archiveId 来自 record所以拼路径前先校验避免 .. 或路径分隔符造成越界访问。
private string GetArchivePath(GameArchiveFileKind archiveKind, string archiveId)
{
if (!IsValidArchiveId(archiveId)) return null;
return Path.Combine(GetArchiveDirectory(archiveKind), archiveId + ".dat");
}
// 新系统全部放在 Config/GameArchives 下,和旧的散落存档文件路径隔离。
private string GetArchiveDirectory(GameArchiveFileKind archiveKind)
{
var root = Path.Combine(GetConfigRootDirectory(), ArchiveRootFolderName);
var subFolder = archiveKind switch
{
GameArchiveFileKind.Begin => "begin",
GameArchiveFileKind.QuickContinue => "quick_continue",
GameArchiveFileKind.Continue => "continue",
GameArchiveFileKind.End => "end",
_ => "unknown"
};
return Path.Combine(root, subFolder);
}
private string GetConfigRootDirectory()
{
return Path.GetFullPath(Path.Combine(Application.persistentDataPath, "../Config"));
}
// 只允许简单文件名作为 archiveId。
// 正常 id 是 Guid.NewGuid().ToString("N"),快速存档固定是 quick。
private bool IsValidArchiveId(string archiveId)
{
if (string.IsNullOrWhiteSpace(archiveId)) return false;
if (archiveId.Contains("..")) return false;
if (archiveId.Contains("/") || archiveId.Contains("\\")) return false;
return archiveId.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
}
private string NewArchiveId()
{
return Guid.NewGuid().ToString("N");
}
// 手动存档名只影响 record 展示,不参与文件路径。
private string NormalizeManualRecordName(string recordName)
{
return string.IsNullOrWhiteSpace(recordName) ? "手动存档" : recordName.Trim();
}
}
}