diff --git a/.codex/skills/th1-game-archive/SKILL.md b/.codex/skills/th1-game-archive/SKILL.md new file mode 100644 index 000000000..38ec66698 --- /dev/null +++ b/.codex/skills/th1-game-archive/SKILL.md @@ -0,0 +1,144 @@ +--- +name: th1-game-archive +description: TH1 project-specific guide for the new local save/archive system: GameRecord, GameRecordKind Ended/Manual/Quick, GameArchiveManager, begin/quick_continue/continue/end files, single-player and multiplayer resume from records, manual saves, quick saves, end records, archive validation, spectator/OSS/bug-report archive packaging, and removal of legacy map_archive/PlayerPrefs continue flows. Use whenever Codex works on TH1 continue game, save/load, game records, local archives, surrender/end save behavior, bug report save attachments, spectator archive loading, or any change that might affect single/multiplayer resume reliability. +--- + +# TH1 Game Archive + +## Core Rule + +The current save system is `GameRecord`-indexed. Continue/resume must start from a `GameRecord`, not by scanning files or using `MapID`. + +`GameRecord` is only an index and display row. The real `MapData` bytes live under `Config/GameArchives`. + +## First Files To Read + +- `Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs` +- `Unity/Assets/Scripts/TH1_Logic/GameRecord/GameRecordManager.cs` +- `Unity/Assets/Scripts/TH1_Logic/Core/Main.cs` +- `Unity/Assets/Scripts/TH1_Data/MapData.cs` +- `Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs` +- `Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMenuView.cs` +- `Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMultiplayView.cs` +- `Unity/Assets/Scripts/TH1_Logic/Editor/SpectatorEditorWindow.cs` +- `Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs` +- `Tools/PlayerBugViewer/player_bug_viewer.py` when changing player bug report archive restore. + +If the change touches multiplayer host resume or `GameStart`, also use `th1-network-sync`. +If the change touches surrender or other action execution side effects, also use `th1-action-logic`. + +## Data Model + +`GameRecordKind` has three business categories: + +- `Ended`: complete finished match record. It indexes `BeginArchiveId` and `EndArchiveId`; it is not resumable. +- `Manual`: player-created general save. It indexes `BeginArchiveId` and `ContinueArchiveId`; it is resumable and can have many records. +- `Quick`: automatic quick save. It indexes `BeginArchiveId` and `ContinueArchiveId`; it is resumable, but only one exists globally. + +Archive files are grouped by folder: + +- `Config/GameArchives/begin/{beginArchiveId}.dat` +- `Config/GameArchives/quick_continue/quick.dat` +- `Config/GameArchives/continue/{continueArchiveId}.dat` +- `Config/GameArchives/end/{endArchiveId}.dat` + +`Quick` uses fixed record id and file id `quick`. Manual/end/begin ids are GUID-like strings from `GameArchiveManager`. + +## Write Flow + +- New single-player game: write begin after map generation, before first turn refresh. +- New multiplayer host game: write begin only after `GameStart` broadcast succeeds. +- Ended game: `FinishState.Enter` calls `GameArchiveManager.SaveEndRecord(Main.MapData)`. +- Per-turn quick save: `MapData.RefreshTurn` calls `SaveQuickContinueRecord`. +- Manual save: UI or upper layer should call `GameArchiveManager.SaveManualGameRecord(recordName)`. +- Manual delete: call `GameArchiveManager.DeleteManualGameRecord(record)`; do not delete quick/end records through this path. + +Do not save archives directly from surrender action. Surrender should mutate game state through the action flow; `AfterExecute` refreshes settlement, `GameLogic.Update` enters `Finished`, and `FinishState.Enter` writes end and clears quick. + +`SaveEndRecord` writes end, creates an `Ended` record except for Tutor, and deletes the current begin's quick record/file. Manual saves survive game end until the player deletes them. + +## Resume Flow + +Public resume should go through: + +```csharp +Main.Instance.ResumeMatch(GameRecord record, MapData prereadMap = null) +``` + +That method: + +- validates/loads the `Quick` or `Manual` continue archive via `GameArchiveManager.TryLoadContinueArchive`; +- rejects null records, bad archive files, wrong `NetMode`, and surrendered continue saves; +- calls `GameArchiveManager.SetActiveRecord(record)` so future quick/manual/end saves keep using `record.BeginArchiveId`; +- dispatches by `record.NetMode` to single-player resume or host multiplayer resume. + +Resume methods must not create a new begin. New games create begin; record resume reuses the record's begin. + +For UI availability: + +- single-player quick resume button: `GameArchiveManager.HasQuickResumeArchive(NetMode.Single)` +- multiplayer host resume toggle: `GameArchiveManager.HasQuickResumeArchive(NetMode.Multi)` +- per-record validation: `GameArchiveManager.HasUsableResumeArchive(record)` + +These checks validate only the relevant record/file, not all local archives. + +## Read/Export Flows + +Use `GameArchiveManager` helpers: + +- Continue: `TryLoadContinueArchive(record, out map)` +- Begin: `TryLoadBeginArchive(record, out map)` +- End: `TryLoadEndArchive(record, out map)` +- Current begin for OSS end upload: `TryLoadCurrentBeginArchive(expectedMode, out map)` +- File path for packaging: `TryGetArchivePath(kind, archiveId, out path)` + +Spectator tools, OSS collect upload, and player bug reports should build archive pairs from `GameRecordData.Records`, then use the helper APIs above. Do not parse archive filenames. + +Player bug report zip manifests include `archiveFolder`, and the viewer restores files under `Config/GameArchives/{archiveFolder}`. The Python viewer cannot safely synthesize `game_record.dat` because it is MemoryPack data. + +## Compatibility + +`GameRecord` and `GameRecordData` are MemoryPack types. Do not reorder existing fields. New fields must be appended at the end, and compatibility-sensitive MemoryPack changes require explicit user confirmation. + +`MapID` is legacy. Keep it for old record display/compatibility if needed, but do not use it to locate current archives or resume games. + +The legacy flow is removed: + +- no `MapData.SaveMapData` +- no `MapData.GetMapData` +- no `MapData.HasMapArchive` +- no `PlayerPrefs` keys `Archive` / `MultiArchive` +- no `map_archive_*.dat` scanning or filename parsing +- no resume by `mapId` + +## Checks Before Finishing + +Run: + +```powershell +dotnet build Unity/Assembly-CSharp.csproj --no-restore +``` + +Also run editor build if editor windows/tools changed: + +```powershell +dotnet build Unity/Assembly-CSharp-Editor.csproj --no-restore +``` + +Search for legacy regressions: + +```powershell +rg "SaveMapData|GetMapData\\(|HasMapArchive|HasArchive\\(|HasMultiArchive\\(" Unity/Assets/Scripts -n +rg "map_archive|PlayerPrefs\\.(SetInt|GetInt)\\(\"(Archive|MultiArchive)\"" Unity/Assets/Scripts Tools MD -n +rg "useCurrentArchiveSession|ResumeMatch\\(uint|MainMemberResumeMatch\\(" Unity/Assets/Scripts -n +``` + +## Pitfalls + +- Do not scan all local saves to build a continue list; use GameRecord lists and validate one selected record. +- Do not show a record as resumable until `TryLoadContinueArchive` or `HasUsableResumeArchive` passes. +- Do not let single-player and multiplayer quick resumes share UI state; always filter by `NetMode`. +- Do not close multiplayer room UI or advance local host state until `GameStart` succeeds. +- Do not let a failed resume leave `GameArchiveManager.CurrentBeginArchiveId` bound to the attempted record. +- Do not delete manual saves on game end. +- Do not write end from action execution before settlement refresh; let `FinishState.Enter` own end saving. diff --git a/.codex/skills/th1-game-archive/agents/openai.yaml b/.codex/skills/th1-game-archive/agents/openai.yaml new file mode 100644 index 000000000..4802ca62f --- /dev/null +++ b/.codex/skills/th1-game-archive/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "TH1 Game Archive" + short_description: "TH1 GameRecord-indexed saves, resume, quick/manual/end archives" + default_prompt: "Use $th1-game-archive when working on TH1 GameRecord, GameArchiveManager, continue/resume, quick/manual/end saves, archive validation, or bug-report/spectator save packaging." diff --git a/MD/GameMDFramework/15-服务端-GameUploadFunction.md b/MD/GameMDFramework/15-服务端-GameUploadFunction.md index 44073bceb..2a8237286 100644 --- a/MD/GameMDFramework/15-服务端-GameUploadFunction.md +++ b/MD/GameMDFramework/15-服务端-GameUploadFunction.md @@ -357,7 +357,7 @@ Unity 菜单:`Tools/玩家 Bug 汇报` - 玩家自述文本输入。 - 版本号默认读取当前 `VersionConfig`,可手动修改。 -- 默认附带最近一局存档,根据本地 `map_archive_begin + map_archive_continue/end` 自动识别。 +- 默认附带最近一局存档,根据 `GameRecord` 索引到本地 `GameArchives/begin + quick_continue/continue/end` 自动识别。 - 可勾选单机存档、联机存档;打包为一个 zip 后走 `type=bugreport` 上传。 Zip 内容: @@ -365,10 +365,10 @@ Zip 内容: ``` manifest.json description.txt -saves/single/map_archive_begin_{mapId}.dat -saves/single/map_archive_continue_{mapId}.dat 或 map_archive_end_{mapId}.dat -saves/multi/map_archive_begin_multi_{mapId}.dat -saves/multi/map_archive_continue_multi_{mapId}.dat 或 map_archive_end_multi_{mapId}.dat +saves/single/begin/{beginArchiveId}.dat +saves/single/quick_continue/quick.dat 或 saves/single/continue/{continueArchiveId}.dat 或 saves/single/end/{endArchiveId}.dat +saves/multi/begin/{beginArchiveId}.dat +saves/multi/quick_continue/quick.dat 或 saves/multi/continue/{continueArchiveId}.dat 或 saves/multi/end/{endArchiveId}.dat ``` `manifest.json` 目前包含: @@ -377,7 +377,7 @@ saves/multi/map_archive_continue_multi_{mapId}.dat 或 map_archive_end_multi_{ma - 玩家与版本:`steamId`、`version`、`unityVersion`、`platform`。 - CrashSight 对齐字段:`crashSightDeviceId`。项目初始化 CrashSight 时也会显式设置同一个设备 ID,便于后台检索对应玩家。来源优先使用 `SystemInfo.deviceUniqueIdentifier`;如果 Unity 返回空值或 unsupported,则生成并持久化一个 `th1-{guid}` 作为本机安装级 fallback;极端情况下 PlayerPrefs 不可用时,使用进程内 `th1-device-id-unavailable-{guid}` fallback。 - 设备信息:`deviceName`、`deviceModel`、`operatingSystem`、`processorType`、`processorCount`、`systemMemorySizeMb`、`graphicsDeviceName`、`graphicsMemorySizeMb`。 -- 玩家自述与存档:`description`、`archiveCount`、`archives[]`。每个存档条目包含模式、MapID、begin/continue/end 类型、源文件名、zip 路径、文件大小和最后写入时间。 +- 玩家自述与存档:`description`、`archiveCount`、`archives[]`。每个存档条目包含模式、MapID、begin/continue/end 类型、`archiveFolder` 子目录、源文件名、zip 路径、文件大小和最后写入时间。 ### 12.2 开发者查看器 @@ -388,7 +388,7 @@ saves/multi/map_archive_continue_multi_{mapId}.dat 或 map_archive_end_multi_{ma - 使用 OSS AccessKey 更新 `bugreport/` 内容到本地缓存。 - 按版本和 SteamID 筛选条目。 - 预览玩家自述、上传时间、版本、SteamID、CrashSight 设备 ID、设备信息、附带存档。 -- 一键清理本地 `map_archive_*.dat/.bak` 并替换为该汇报内的存档。 +- 一键清理本地 `Config/GameArchives` 下的新存档文件,并按汇报内的 `archiveFolder` 子目录写回。 --- diff --git a/Tools/PlayerBugViewer/README.md b/Tools/PlayerBugViewer/README.md index 4ece8c76d..10ed5bc46 100644 --- a/Tools/PlayerBugViewer/README.md +++ b/Tools/PlayerBugViewer/README.md @@ -8,6 +8,6 @@ 2. 工具会自动读取 Unity OSS 编辑器保存的 `AccessKey ID` / `AccessKey Secret`,确认 `Endpoint` 和 `Bucket`。 3. 点击「更新内容」拉取 `bugreport/` 下的 zip。 4. 选择条目查看玩家自述、版本、SteamID、CrashSight 设备 ID、设备信息、附带存档。 -5. 点击「一键替换到本地存档」会删除目标目录下所有 `map_archive_*.dat` / `.bak`,再写入该汇报中的 `start + continue/end` 存档。 +5. 点击「一键替换到本地存档」会删除目标目录下 `GameArchives/begin`、`quick_continue`、`continue`、`end` 内的存档文件,再按汇报中的子目录写回 `begin + continue/end` 存档。 也可以用 `config.local.json` 或环境变量覆盖 OSS 配置,该文件应只保留在本机。 diff --git a/Tools/PlayerBugViewer/player_bug_viewer.py b/Tools/PlayerBugViewer/player_bug_viewer.py index 4a261e102..177aa8d7d 100644 --- a/Tools/PlayerBugViewer/player_bug_viewer.py +++ b/Tools/PlayerBugViewer/player_bug_viewer.py @@ -27,6 +27,8 @@ from oss_viewer_config import merge_default_oss_config, merge_local_oss_config DATA_DIR = APP_DIR / "Data" CONFIG_PATH = APP_DIR / "config.local.json" OSS_PREFIX = "bugreport/" +ARCHIVE_ROOT_FOLDER = "GameArchives" +ARCHIVE_FOLDERS = {"begin", "quick_continue", "continue", "end"} def default_save_config_dir() -> str: @@ -45,6 +47,21 @@ def default_config() -> dict: } +def archive_folder_for_manifest(archive: dict, source_file_name: str) -> str | None: + folder = Path(archive.get("archiveFolder") or "").name + if folder in ARCHIVE_FOLDERS: + return folder + + kind = archive.get("kind") + if kind == "begin": + return "begin" + if kind == "end": + return "end" + if kind == "continue": + return "quick_continue" if source_file_name == "quick.dat" else "continue" + return None + + def load_config() -> dict: cfg = merge_default_oss_config(default_config()) if CONFIG_PATH.exists(): @@ -222,14 +239,19 @@ def download_reports(cfg: dict) -> tuple[int, int, int]: def replace_local_saves(report: ReportEntry, save_config_dir: str) -> tuple[int, int]: target_dir = Path(os.path.expandvars(save_config_dir)).expanduser().resolve() - target_dir.mkdir(parents=True, exist_ok=True) + archive_root = target_dir / ARCHIVE_ROOT_FOLDER + archive_root.mkdir(parents=True, exist_ok=True) deleted = 0 - for pattern in ("map_archive_*.dat", "map_archive_*.dat.bak"): - for path in target_dir.glob(pattern): - if path.is_file(): - path.unlink() - deleted += 1 + for folder in ARCHIVE_FOLDERS: + folder_path = archive_root / folder + if not folder_path.exists(): + continue + for pattern in ("*.dat", "*.dat.bak"): + for path in folder_path.glob(pattern): + if path.is_file(): + path.unlink() + deleted += 1 copied = 0 archives = report.manifest.get("archives") or [] @@ -239,7 +261,12 @@ def replace_local_saves(report: ReportEntry, save_config_dir: str) -> tuple[int, source_file_name = Path(archive.get("sourceFileName") or "").name if not entry_name or not source_file_name: continue - destination = target_dir / source_file_name + archive_folder = archive_folder_for_manifest(archive, source_file_name) + if not archive_folder: + continue + destination_dir = archive_root / archive_folder + destination_dir.mkdir(parents=True, exist_ok=True) + destination = destination_dir / source_file_name with zf.open(entry_name, "r") as source, destination.open("wb") as target: shutil.copyfileobj(source, target) copied += 1 @@ -480,7 +507,7 @@ class PlayerBugViewer(Tk): for archive in report.manifest.get("archives") or []: rows.append(( "存档", - f"{archive.get('mode')} map={archive.get('mapId')} {archive.get('kind')} {archive.get('sourceFileName')} {human_bytes(int(archive.get('fileSize') or 0))}", + f"{archive.get('mode')} map={archive.get('mapId')} {archive.get('kind')} {archive.get('archiveFolder') or ''}/{archive.get('sourceFileName')} {human_bytes(int(archive.get('fileSize') or 0))}", )) for field, value in rows: self.preview.insert("", END, values=(field, value)) @@ -505,7 +532,7 @@ class PlayerBugViewer(Tk): confirmed = messagebox.askyesno( "确认替换", - f"将清理本地所有 map_archive 存档,并替换为当前汇报内的存档。\n\n目标目录:\n{target_dir}", + f"将清理本地 GameArchives 存档,并替换为当前汇报内的存档文件。\n\n目标目录:\n{target_dir}", ) if not confirmed: return diff --git a/Unity/Assets/Scripts/TH1_Data/MapData.cs b/Unity/Assets/Scripts/TH1_Data/MapData.cs index d5bf6ad01..2361c7f64 100644 --- a/Unity/Assets/Scripts/TH1_Data/MapData.cs +++ b/Unity/Assets/Scripts/TH1_Data/MapData.cs @@ -2228,371 +2228,8 @@ namespace RuntimeData return null; } - public static bool SaveMapData(MapData map, bool isBegin=false, bool isEnd=false) - { - if (map == null) return false; - if (ShouldSkipMapArchive(map)) return false; - - // 改为二进制文件扩展名 - string path = Application.persistentDataPath + "/../Config/map_archive"; - if (isBegin) path += "_begin"; - else if (isEnd) path += "_end"; - else path += "_continue"; - if (map.Net.Mode == NetMode.Multi) path += "_multi"; - path += $"_{map.MapID}.dat"; - int retryCount = 3; - - while (retryCount > 0) - { - try - { - byte[] bytes = SerializeMapArchive(map); - if (FileTools.SafeWriteFile(path, bytes)) - { - InvalidateMapArchiveAvailabilityCache(); - return true; - } - retryCount--; - if (retryCount <= 0) - { - LogSystem.LogError($"保存地图数据失败: 安全写入失败"); - } - } - catch (Exception ex) - { - retryCount--; - if (retryCount <= 0) - { - LogSystem.LogError($"保存地图数据失败: {ex.Message}"); - } - } - } - - return false; - } - - private enum MapArchiveKind - { - Begin, - Continue, - End - } - - private static readonly TimeSpan MapArchiveAvailabilityCacheRefreshInterval = TimeSpan.FromSeconds(1); - private static readonly Dictionary MapArchiveAvailabilityCaches = - new Dictionary(); - - private sealed class MapArchiveAvailabilityCache - { - public DateTime CheckedAtUtc; - public string Fingerprint; - public bool HasArchive; - } - - public static bool HasMapArchive(bool isMulti = false) - { - var now = DateTime.UtcNow; - if (MapArchiveAvailabilityCaches.TryGetValue(isMulti, out var cache) - && now - cache.CheckedAtUtc < MapArchiveAvailabilityCacheRefreshInterval) - { - return cache.HasArchive; - } - - var fingerprint = GetMapArchiveAvailabilityFingerprint(isMulti); - if (cache != null && cache.Fingerprint == fingerprint) - { - cache.CheckedAtUtc = now; - return cache.HasArchive; - } - - var hasArchive = GetLatestReadableMapArchive(isMulti, MapArchiveKind.Continue, 0) != null; - MapArchiveAvailabilityCaches[isMulti] = new MapArchiveAvailabilityCache - { - CheckedAtUtc = now, - Fingerprint = fingerprint, - HasArchive = hasArchive - }; - return hasArchive; - } - - public static MapData GetMapData(bool isMulti = false, bool isBegin = false, bool isEnd = false, uint mapId = 0) - { - var kind = GetMapArchiveKind(isBegin, isEnd); - return GetLatestReadableMapArchive(isMulti, kind, mapId); - } - - private static MapArchiveKind GetMapArchiveKind(bool isBegin, bool isEnd) - { - if (isBegin) return MapArchiveKind.Begin; - if (isEnd) return MapArchiveKind.End; - return MapArchiveKind.Continue; - } - - private static MapData GetLatestReadableMapArchive(bool isMulti, MapArchiveKind kind, uint mapId) - { - var files = GetMapArchiveCandidates(isMulti, kind, mapId); - var targetFile = files.FirstOrDefault(); - if (targetFile == null) return null; - - if (!TryParseMapArchiveFileName(targetFile, out _, out _, out var archiveMapId)) return null; - var endArchiveTimes = kind == MapArchiveKind.Continue - ? GetLatestEndMapArchiveTimes(isMulti) - : null; - if (endArchiveTimes != null - && endArchiveTimes.TryGetValue(archiveMapId, out var endWriteTime) - && endWriteTime >= GetMapArchiveLastWriteTime(targetFile)) - { - return null; - } - - var mapData = ReadMapDataWithBackup(targetFile); - if (mapData == null) return null; - if (ShouldSkipMapArchive(mapData)) return null; - - var expectedMode = isMulti ? NetMode.Multi : NetMode.Single; - if (mapData.Net.Mode != expectedMode) - { - LogSystem.LogWarning($"存档模式与文件名不一致,跳过: {targetFile}"); - return null; - } - - if (kind == MapArchiveKind.Continue && mapData.PlayerMap.SelfPlayerData?.IsSurrender == true) - return null; - - return mapData; - } - - private static bool ShouldSkipMapArchive(MapData map) - { - return map?.Net?.Mode == NetMode.Spectator - || map?.MapConfig?.MatchSettlement == MatchSettlementType.Story; - } - - private static void InvalidateMapArchiveAvailabilityCache() - { - MapArchiveAvailabilityCaches.Clear(); - } - - private static string GetMapArchiveAvailabilityFingerprint(bool isMulti) - { - string directory = Application.persistentDataPath + "/../Config/"; - if (!Directory.Exists(directory)) return string.Empty; - - unchecked - { - long hash = 17; - int count = 0; - AddMapArchiveFingerprint(directory, "map_archive_continue*.dat", isMulti, ref hash, ref count); - AddMapArchiveFingerprint(directory, "map_archive_continue*.dat.bak", isMulti, ref hash, ref count); - AddMapArchiveFingerprint(directory, "map_archive_end*.dat", isMulti, ref hash, ref count); - AddMapArchiveFingerprint(directory, "map_archive_end*.dat.bak", isMulti, ref hash, ref count); - return $"{count}:{hash}"; - } - } - - private static void AddMapArchiveFingerprint(string directory, string pattern, bool isMulti, - ref long hash, ref int count) - { - try - { - foreach (var path in Directory.EnumerateFiles(directory, pattern)) - { - if (!TryParseMapArchiveFileName(path, out var kind, out var fileIsMulti, out _)) continue; - if (fileIsMulti != isMulti) continue; - if (kind != MapArchiveKind.Continue && kind != MapArchiveKind.End) continue; - - count++; - hash = hash * 31 + Path.GetFileName(path).GetHashCode(); - hash = hash * 31 + GetMapArchiveLastWriteTime(path).Ticks; - hash = hash * 31 + GetMapArchiveFileLength(path); - } - } - catch (Exception ex) - { - LogSystem.LogError($"读取存档目录失败: {ex.Message}"); - } - } - - private static long GetMapArchiveFileLength(string path) - { - try - { - return new FileInfo(path).Length; - } - catch - { - return 0; - } - } - - private static List GetMapArchiveCandidates(bool isMulti, MapArchiveKind kind, uint mapId) - { - string directory = Application.persistentDataPath + "/../Config/"; - if (!Directory.Exists(directory)) return new List(); - - try - { - return Directory.GetFiles(directory, "map_archive_*.dat") - .Concat(Directory.GetFiles(directory, "map_archive_*.dat.bak")) - .Where(path => IsMatchingMapArchive(path, isMulti, kind, mapId)) - .OrderByDescending(GetMapArchiveLastWriteTime) - .ToList(); - } - catch (Exception ex) - { - LogSystem.LogError($"读取存档目录失败: {ex.Message}"); - return new List(); - } - } - - private static bool IsMatchingMapArchive(string path, bool isMulti, MapArchiveKind kind, uint mapId) - { - if (!TryParseMapArchiveFileName(path, out var fileKind, out var fileIsMulti, out var fileMapId)) return false; - if (fileKind != kind || fileIsMulti != isMulti) return false; - return mapId == 0 || fileMapId == mapId; - } - - private static bool TryParseMapArchiveFileName(string path, out MapArchiveKind kind, out bool isMulti, out uint mapId) - { - kind = MapArchiveKind.Continue; - isMulti = false; - mapId = 0; - - var fileName = Path.GetFileName(path); - if (string.IsNullOrEmpty(fileName)) return false; - if (fileName.EndsWith(".bak", StringComparison.OrdinalIgnoreCase)) - fileName = fileName.Substring(0, fileName.Length - ".bak".Length); - if (!fileName.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) return false; - - var stem = fileName.Substring(0, fileName.Length - ".dat".Length); - const string prefix = "map_archive_"; - if (!stem.StartsWith(prefix, StringComparison.Ordinal)) return false; - - var rest = stem.Substring(prefix.Length); - if (rest.StartsWith("begin_", StringComparison.Ordinal)) - { - kind = MapArchiveKind.Begin; - rest = rest.Substring("begin_".Length); - } - else if (rest.StartsWith("continue_", StringComparison.Ordinal)) - { - kind = MapArchiveKind.Continue; - rest = rest.Substring("continue_".Length); - } - else if (rest.StartsWith("end_", StringComparison.Ordinal)) - { - kind = MapArchiveKind.End; - rest = rest.Substring("end_".Length); - } - else - { - return false; - } - - if (rest.StartsWith("multi_", StringComparison.Ordinal)) - { - isMulti = true; - rest = rest.Substring("multi_".Length); - } - - return uint.TryParse(rest, out mapId); - } - - private static Dictionary GetLatestEndMapArchiveTimes(bool isMulti) - { - var latestTimes = new Dictionary(); - var endFiles = GetMapArchiveCandidates(isMulti, MapArchiveKind.End, 0); - foreach (var endFile in endFiles) - { - if (!TryParseMapArchiveFileName(endFile, out _, out _, out var mapId)) continue; - - var writeTime = GetMapArchiveLastWriteTime(endFile); - if (!latestTimes.TryGetValue(mapId, out var existingTime) || writeTime > existingTime) - latestTimes[mapId] = writeTime; - } - - return latestTimes; - } - - private static DateTime GetMapArchiveLastWriteTime(string path) - { - try - { - return File.GetLastWriteTime(path); - } - catch - { - return DateTime.MinValue; - } - } - - private static MapData ReadMapDataWithBackup(string targetFile) - { - if (targetFile.EndsWith(".bak", StringComparison.OrdinalIgnoreCase)) - return ReadMapDataFile(targetFile); - - var mapData = ReadMapDataFile(targetFile); - if (mapData != null) return mapData; - - var backupPath = targetFile + ".bak"; - if (!File.Exists(backupPath)) return null; - - LogSystem.LogWarning($"读取地图数据失败,尝试读取备份: {backupPath}"); - return ReadMapDataFile(backupPath); - } - - private static MapData ReadMapDataFile(string targetFile) - { - if (!File.Exists(targetFile)) return null; - - int retryCount = 3; - while (retryCount > 0) - { - try - { - byte[] bytes = File.ReadAllBytes(targetFile); - var mapData = DeserializeMapArchive(bytes); - - // 版本校验:检查反序列化后的数据是否有效 - if (mapData == null - || mapData.DeserializedMissingCriticalData - || mapData.MapConfig == null - || mapData.GridMap == null - || mapData.PlayerMap == null - || mapData.CityMap == null - || mapData.UnitMap == null - || mapData.Net == null) - { - LogSystem.LogError($"反序列化后的地图数据不完整,可能是版本不兼容"); - return null; - } - - return mapData; - } - catch (IOException ex) - { - retryCount--; - if (retryCount <= 0) - { - LogSystem.LogError($"读取地图数据失败: {ex.Message}"); - return null; - } - } - catch (MemoryPackSerializationException ex) - { - LogSystem.LogError($"地图数据反序列化失败,可能是版本不兼容: {ex.Message}"); - return null; - } - catch (Exception ex) - { - LogSystem.LogError($"读取地图数据时发生未知错误: {ex.Message}"); - return null; - } - } - - return null; - } - + // 旧版按 MapID 扫描/读写散落存档文件的流程已经移除。 + // 新版所有 begin / quick_continue / continue / end 都通过 GameArchiveManager 管理。 private static byte[] SerializeMapArchive(MapData map) { var rawBytes = MemoryPackSerializer.Serialize(map); @@ -2738,20 +2375,13 @@ namespace RuntimeData return; } - // 存档。 + // 新版快速存档。 // 这里已经过了“只允许房主更新联机回合”的判断,并且 Net.CurPlayerId == 0, // 所以这是每轮轮转到下一位玩家前的统一自动保存点。 - var saveSucceeded = SaveMapData(Main.MapData); // 新版快速存档:每次回合轮转覆盖 quick_continue/quick.dat,并更新唯一 Quick record。 // 不扫描本地所有存档,只维护当前这一局的快速继续入口。 GameArchiveManager.Instance.SaveQuickContinueRecord(Main.MapData); AchievementDataManager.Instance.SaveAchievementData(); - if (saveSucceeded) - { - if (Main.MapData.Net.Mode == NetMode.Single) PlayerPrefs.SetInt("Archive", 1); - if (Main.MapData.Net.Mode == NetMode.Multi) PlayerPrefs.SetInt("MultiArchive", 1); - PlayerPrefs.Save(); - } // 设置当前玩家 Main.PlayerLogic.StartPlayerTurn(this, nextPlayer.Id); } diff --git a/Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs b/Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs index 65dba8518..199e94f7a 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs @@ -2458,23 +2458,9 @@ namespace Logic.Action protected override bool Execute(CommonActionParams actionParams) { actionParams.PlayerData.Surrender(actionParams.MapData); - SaveLocalSurrenderEndArchive(actionParams); return true; } - private void SaveLocalSurrenderEndArchive(CommonActionParams actionParams) - { - var map = actionParams.MapData; - if (map == null || map != Main.MapData) return; - if (actionParams.PlayerData == null || actionParams.PlayerData != map.PlayerMap.SelfPlayerData) return; - if (map.MapConfig?.MatchSettlement == MatchSettlementType.Story) return; - - if (map.Net.Mode == NetMode.Multi) PlayerPrefs.SetInt("MultiArchive", 0); - if (map.Net.Mode == NetMode.Single) PlayerPrefs.SetInt("Archive", 0); - PlayerPrefs.Save(); - MapData.SaveMapData(map, false, true); - } - public override bool CheckCan(CommonActionParams actionParams) { if (actionParams.PlayerData == null) return false; diff --git a/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs b/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs index 67b5152cb..b39117a80 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs @@ -549,14 +549,6 @@ namespace Logic // 教程仍不进历史记录,过滤由 GameArchiveManager 内部处理。 GameArchiveManager.Instance.SaveEndRecord(Main.MapData); - // 保存存档 - if (Main.MapData.MapConfig.MatchSettlement != MatchSettlementType.Story) - { - if (Main.MapData.Net.Mode == NetMode.Multi) PlayerPrefs.SetInt("MultiArchive", 0); - if (Main.MapData.Net.Mode == NetMode.Single) PlayerPrefs.SetInt("Archive", 0); - MapData.SaveMapData(Main.MapData, false, true); - } - // 上传到 oss 服务器 var id = LobbyManager.Instance.Lobby.GetSelfMemberId(); if (id != 0) OssManager.Instance.UploadCollectData(id.ToString(), Main.MapData); diff --git a/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs b/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs index 9fbe452ae..ac70f1ff9 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs @@ -272,7 +272,6 @@ namespace TH1_Logic.Core }, 1.5f, "Main_CenterMessage_Anim"); MapData.SaveMatchConfig(MapConfig); - MapData.SaveMapData(MapData, true); // 新版存档:新开局一定先写 begin。 // 后续每回合 quick、玩家手动 manual、最终 end 都会通过这个 begin 串起来。 GameArchiveManager.Instance.SaveBeginArchive(MapData); @@ -284,12 +283,17 @@ namespace TH1_Logic.Core MapData.RefreshTurn(); } - public bool ResumeMatch(GameRecord record) + public bool ResumeMatch(GameRecord record, MapData prereadMap = null) { // 新版 record 继续入口。 // 传入 Quick/Manual record 后,先通过 record.ContinueArchiveId 读取 MapData。 // 只有读到完整、NetMode 匹配的 continue 文件,才会进入真正的单机/联机开始流程。 - if (!GameArchiveManager.Instance.TryLoadContinueArchive(record, out var resumeMap)) return false; + if (record == null) return false; + + var resumeMap = prereadMap; + if (resumeMap == null && !GameArchiveManager.Instance.TryLoadContinueArchive(record, out resumeMap)) + return false; + if (resumeMap?.Net == null || resumeMap.Net.Mode != record.NetMode) return false; // record 继续要沿用原 record.BeginArchiveId。 // 如果开始流程失败,需要恢复之前的 begin 会话,避免后续保存挂错局。 @@ -298,37 +302,21 @@ namespace TH1_Logic.Core var result = record.NetMode switch { // 单机 record:直接复用单机读档开始流程。 - NetMode.Single => ResumeMatch(resumeMap, true), + NetMode.Single => ResumeMatch(resumeMap), // 联机 record:仍走房主联机继续流程,里面会做 Net.RefreshPlayerNet 和 GameStart 广播。 - NetMode.Multi => MainMemberResumeMatchWithMap(resumeMap, true), + NetMode.Multi => MainMemberResumeMatchWithMap(resumeMap), _ => false }; if (!result) GameArchiveManager.Instance.SetCurrentBeginArchiveId(previousBeginArchiveId); return result; } - public bool ResumeMatch(uint mapId) - { - if (mapId == 0) return false; - var resumeMap = MapData.GetMapData(mapId: mapId); - return resumeMap != null && ResumeMatch(resumeMap); - } - // 继续单机游戏。 // - // prereadMap: - // - 旧 UI 可以预读 MapData 传进来,避免 Loading 图和正式进入时重复反序列化。 - // - // useCurrentArchiveSession: - // - false:旧继续入口,没有 record 索引,需要给这次会话新建 begin。 - // - true:新版 record 继续入口,已经在 ResumeMatch(GameRecord) 里绑定了 BeginArchiveId,不能再新建 begin。 - public bool ResumeMatch(MapData prereadMap = null, bool useCurrentArchiveSession = false) + // 这里不再提供无 record 的旧 continue 入口;调用方必须先通过 GameRecord 找到 continue 文件。 + // ResumeMatch(GameRecord) 已经绑定 record.BeginArchiveId,这里只负责把传入的 MapData 启动起来。 + private bool ResumeMatch(MapData resumeMap) { - //如果没有存档,退出(外部已预读传入时跳过存档磁盘检查,map 自身即证据) - if (prereadMap == null && !HasArchive()) return false; - - //step #2 读取存档的map(外部已预读则复用,避免重复反序列化) - var resumeMap = prereadMap ?? MapData.GetMapData(); if (resumeMap == null) return false; MapData = resumeMap; @@ -356,9 +344,7 @@ namespace TH1_Logic.Core && MapData.GetGridDataByCityId(cap.Id, out var grid)) camera.CameraFocusOnGrid(grid,true); - // 旧单机继续没有 record.BeginArchiveId,补一条 begin 让后续 quick/manual/end 可以索引。 - // 新版 record 继续则沿用原 begin。 - if (!useCurrentArchiveSession) GameArchiveManager.Instance.SaveBeginArchive(MapData); + // 继续游戏不会新建 begin;后续 quick/end 会继续挂到 record.BeginArchiveId。 MapData.RefreshTurn(); return true; } @@ -442,7 +428,6 @@ namespace TH1_Logic.Core //UIManager.Instance.CenterMessageUI.SetCenterMessageShow(UICenterMessageID.StartGame,MapData.PlayerMap.SelfPlayerData); },1.5f,"Main_CenterMessage_Anim"); - MapData.SaveMapData(MapData, true); // 联机新开局也写 begin,但必须放在 GameStart 广播成功之后。 // 否则房主本地开始失败时可能留下一个没有真正开局的 begin。 GameArchiveManager.Instance.SaveBeginArchive(MapData); @@ -463,38 +448,14 @@ namespace TH1_Logic.Core PlayerLogic?.UpdateAllTeammateCapitalSight(mapData); } - public bool MainMemberResumeMatch(uint mapId) - { - if (mapId == 0) return false; - var resumeMap = MapData.GetMapData(isMulti: true, mapId: mapId); - if (resumeMap == null) - { - NetworkPlayerTipManager.Instance.Request(NetworkPlayerTipType.GameStartFailed); - return false; - } - - return MainMemberResumeMatchWithMap(resumeMap); - } - - // 房主继续多人游戏 - public bool MainMemberResumeMatch() - { - //如果没有存档,退出 - if (!HasMultiArchive()) return false; - - return MainMemberResumeMatchWithMap(MapData.GetMapData(isMulti: true)); - } - // 房主联机继续的实际实现。 // - // useCurrentArchiveSession 与单机 ResumeMatch 相同: - // - false:旧联机继续入口,需要补 begin。 - // - true:新版 record 继续入口,沿用 record.BeginArchiveId。 + // ResumeMatch(GameRecord) 已经绑定 record.BeginArchiveId,这里只负责用传入 MapData 启动房主联机继续。 // // 网络安全点: // - 先 RefreshPlayerNet 校验当前房间成员映射。 // - GameStart 广播成功后才算真正进入游戏,失败会回滚。 - private bool MainMemberResumeMatchWithMap(MapData resumeMap, bool useCurrentArchiveSession = false) + private bool MainMemberResumeMatchWithMap(MapData resumeMap) { var previousMap = MapData; var previousInput = InputLogic; @@ -553,9 +514,7 @@ namespace TH1_Logic.Core return false; } - // 旧联机继续没有 record.BeginArchiveId,广播成功后补一条 begin。 - // 新版 record 继续必须沿用原 begin,保证后续 quick/end 仍挂在同一局链路下。 - if (!useCurrentArchiveSession) GameArchiveManager.Instance.SaveBeginArchive(MapData); + // 继续游戏不会新建 begin;后续 quick/end 会继续挂到 record.BeginArchiveId。 MapData.RefreshTurn(); LogSystem.LogInfo($"MainMemberResumeMatch : {NetData.GetMapDataHash(MapData)}"); return true; @@ -849,7 +808,8 @@ namespace TH1_Logic.Core Debug.Log("Main : Trgger Force Change To NetGame"); //UIManager.Instance.GameUI.CloseAllGameUI(); UIManager.Instance.UIOutsideManager.CloseAll(); - MainMemberResumeMatch(); + var record = GameArchiveManager.Instance.GetQuickResumeRecord(NetMode.Multi); + if (record != null) ResumeMatch(record); } } @@ -875,18 +835,6 @@ namespace TH1_Logic.Core } } - - - public bool HasArchive() - { - return MapData.HasMapArchive(); - } - - public bool HasMultiArchive() - { - return MapData.HasMapArchive(true); - } - private void OnApplicationQuit() { LobbyManager.Instance.Lobby.Cleanup(); diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/SpectatorEditorWindow.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/SpectatorEditorWindow.cs index 020a70170..15ba072ab 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Editor/SpectatorEditorWindow.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/SpectatorEditorWindow.cs @@ -12,6 +12,7 @@ using System.IO; using System.Linq; using RuntimeData; using TH1_Logic.Core; +using TH1_Logic.GameArchive; using UnityEditor; using UnityEngine; @@ -76,6 +77,7 @@ namespace Logic.Editor EditorGUILayout.BeginVertical("box"); EditorGUILayout.LabelField($"Map ID: {pair.MapId}", EditorStyles.boldLabel); + EditorGUILayout.LabelField($"记录: {pair.RecordName}"); EditorGUILayout.LabelField($"类型: {(pair.IsMulti ? "多人" : "单人")}"); EditorGUILayout.LabelField($"开始存档: {Path.GetFileName(pair.BeginPath)}"); EditorGUILayout.LabelField($"{GetEndKindLabel(pair.EndKind)}: {Path.GetFileName(pair.EndPath)}"); @@ -99,54 +101,12 @@ namespace Logic.Editor private void RefreshMapList() { _mapPairs.Clear(); - - string directory = Application.persistentDataPath + "/../Config/"; - if (!Directory.Exists(directory)) + + var records = GameRecordManager.Instance.GameRecordData?.Records ?? new List(); + foreach (var record in records) { - Debug.LogWarning($"存档目录不存在: {directory}"); - return; - } - - // 查找所有 begin 文件 - var beginFiles = Directory.GetFiles(directory, "map_archive_begin*.dat") - .Concat(Directory.GetFiles(directory, "map_archive_begin*.dat.bak")) - .OrderByDescending(File.GetLastWriteTime); - var visitedMapKeys = new HashSet(); - - foreach (var beginFile in beginFiles) - { - var fileName = Path.GetFileName(beginFile); - - // 解析文件名获取 MapId 和类型 - if (!TryParseFileName(fileName, out uint mapId, out bool isMulti)) - continue; - - var mapKey = $"{isMulti}:{mapId}"; - if (!visitedMapKeys.Add(mapKey)) - continue; - - // 优先查找对应的 end 文件;没有 end 时使用 continue 作为回放终点。 - var endKind = ReplayEndKind.End; - var endPath = FindArchivePath(directory, "end", isMulti, mapId); - if (endPath == null) - { - endKind = ReplayEndKind.Continue; - endPath = FindArchivePath(directory, "continue", isMulti, mapId); - } - - if (endPath == null) - continue; - - // 添加到列表 - _mapPairs.Add(new MapArchivePair - { - MapId = mapId, - IsMulti = isMulti, - BeginPath = beginFile, - EndPath = endPath, - EndKind = endKind, - LastModifiedTime = Max(File.GetLastWriteTime(beginFile), File.GetLastWriteTime(endPath)) - }); + if (TryBuildMapArchivePair(record, out var pair)) + _mapPairs.Add(pair); } // 按修改时间降序排序 @@ -155,45 +115,61 @@ namespace Logic.Editor Debug.Log($"找到 {_mapPairs.Count} 对可观战的地图存档"); } - private bool TryParseFileName(string fileName, out uint mapId, out bool isMulti) + private bool TryBuildMapArchivePair(GameRecord record, out MapArchivePair pair) { - mapId = 0; - isMulti = false; - - // 文件名格式: map_archive_begin[_multi]_{mapId}.dat - var stem = StripArchiveExtensions(fileName); - if (string.IsNullOrEmpty(stem)) - return false; - - const string singlePrefix = "map_archive_begin_"; - const string multiPrefix = "map_archive_begin_multi_"; - string idPart; - if (stem.StartsWith(multiPrefix, StringComparison.Ordinal)) - { - isMulti = true; - idPart = stem.Substring(multiPrefix.Length); - } - else if (stem.StartsWith(singlePrefix, StringComparison.Ordinal)) - { - idPart = stem.Substring(singlePrefix.Length); - } - else + pair = null; + if (record == null || string.IsNullOrEmpty(record.BeginArchiveId)) return false; + if (!GameArchiveManager.Instance.TryGetArchivePath( + GameArchiveFileKind.Begin, + record.BeginArchiveId, + out var beginPath)) { return false; } - return uint.TryParse(idPart, out mapId); + var endKind = ReplayEndKind.End; + var companionArchiveKind = GameArchiveFileKind.End; + var companionArchiveId = record.EndArchiveId; + if (string.IsNullOrEmpty(companionArchiveId)) + { + endKind = ReplayEndKind.Continue; + companionArchiveKind = record.RecordKind == GameRecordKind.Quick + ? GameArchiveFileKind.QuickContinue + : GameArchiveFileKind.Continue; + companionArchiveId = record.ContinueArchiveId; + } + + if (string.IsNullOrEmpty(companionArchiveId)) return false; + if (!GameArchiveManager.Instance.TryGetArchivePath(companionArchiveKind, companionArchiveId, out var endPath)) + { + return false; + } + + pair = new MapArchivePair + { + Record = record, + RecordName = GetRecordDisplayName(record), + MapId = record.MapID, + IsMulti = record.NetMode == NetMode.Multi, + BeginPath = beginPath, + EndPath = endPath, + EndKind = endKind, + LastModifiedTime = Max(File.GetLastWriteTime(beginPath), File.GetLastWriteTime(endPath)) + }; + return true; } private void LoadMapForSpectator(MapArchivePair pair) { - // 这里添加加载地图的逻辑 - // 例如: 调用游戏中的地图加载方法 - // Main.Instance.LoadSpectatorMap(mapId, isMulti); - var startMap = MapData.GetMapData(pair.IsMulti, true, false, pair.MapId); - var endMap = pair.EndKind == ReplayEndKind.End - ? MapData.GetMapData(pair.IsMulti, false, true, pair.MapId) - : MapData.GetMapData(pair.IsMulti, false, false, pair.MapId); + if (!GameArchiveManager.Instance.TryLoadBeginArchive(pair.Record, out var startMap)) + startMap = null; + + MapData endMap = null; + if (pair.EndKind == ReplayEndKind.End) + GameArchiveManager.Instance.TryLoadEndArchive(pair.Record, out endMap); + else + GameArchiveManager.Instance.TryLoadContinueArchive(pair.Record, out endMap); + if (startMap == null || endMap == null) { Debug.LogWarning($"加载回放存档失败: mapId={pair.MapId}, isMulti={pair.IsMulti}, endKind={pair.EndKind}"); @@ -203,24 +179,10 @@ namespace Logic.Editor Main.Instance.StartSpectate(startMap, endMap); } - private static string FindArchivePath(string directory, string kind, bool isMulti, uint mapId) + private static string GetRecordDisplayName(GameRecord record) { - var fileName = $"map_archive_{kind}{(isMulti ? "_multi" : "")}_{mapId}.dat"; - var path = Path.Combine(directory, fileName); - if (File.Exists(path)) return path; - - var backupPath = path + ".bak"; - return File.Exists(backupPath) ? backupPath : null; - } - - private static string StripArchiveExtensions(string fileName) - { - if (string.IsNullOrEmpty(fileName)) return string.Empty; - if (fileName.EndsWith(".bak", StringComparison.OrdinalIgnoreCase)) - fileName = fileName.Substring(0, fileName.Length - ".bak".Length); - if (!fileName.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) - return string.Empty; - return fileName.Substring(0, fileName.Length - ".dat".Length); + if (!string.IsNullOrEmpty(record.RecordName)) return record.RecordName; + return $"{record.RecordKind} {record.Time}"; } private static DateTime Max(DateTime a, DateTime b) @@ -241,6 +203,8 @@ namespace Logic.Editor private class MapArchivePair { + public GameRecord Record; + public string RecordName; public uint MapId; public bool IsMulti; public string BeginPath; diff --git a/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs b/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs index f6021d332..68ff5f546 100644 --- a/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs +++ b/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs @@ -61,7 +61,7 @@ namespace TH1_Logic.GameArchive // 调用时机: // - 单机新开局:地图生成完成后。 // - 联机房主新开局:GameStart 广播成功后,避免网络开始失败却留下 begin。 - // - 旧的非 record 继续入口:为了让之后的 quick/manual/end 也能挂到一个 begin。 + // - 存档兜底:如果当前局缺少 begin,会先补一条 begin,避免 quick/manual/end 没有索引。 public bool SaveBeginArchive(MapData map) { // 先清空旧会话 id,避免本次 begin 写入失败时误把后续存档挂到上一局。 @@ -167,6 +167,21 @@ namespace TH1_Logic.GameArchive 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 的含义: @@ -186,6 +201,56 @@ namespace TH1_Logic.GameArchive return mapData != null; } + // 读取 record 对应的 begin 快照,供回放、OSS 上传等需要“开局状态”的系统使用。 + public bool TryLoadBeginArchive(GameRecord record, out MapData mapData) + { + mapData = null; + if (record == null) 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; + 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) @@ -245,7 +310,7 @@ namespace TH1_Logic.GameArchive } // 确保当前局已经有 begin。 - // 正常新开局会先 SaveBeginArchive;这里主要兜底旧继续入口或异常调用顺序。 + // 正常新开局或 record 继续都会先绑定 begin;这里主要兜底异常调用顺序。 private bool EnsureCurrentBeginArchive(MapData map) { return !string.IsNullOrEmpty(_currentBeginArchiveId) || SaveBeginArchive(map); @@ -409,7 +474,7 @@ namespace TH1_Logic.GameArchive return Path.Combine(GetArchiveDirectory(archiveKind), archiveId + ".dat"); } - // 新系统全部放在 Config/GameArchives 下,和旧 map_archive_* 文件分开。 + // 新系统全部放在 Config/GameArchives 下,和旧的散落存档文件路径隔离。 private string GetArchiveDirectory(GameArchiveFileKind archiveKind) { var root = Path.GetFullPath(Path.Combine(Application.persistentDataPath, "../Config", ArchiveRootFolderName)); diff --git a/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs b/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs index 34bb12863..1052dc185 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Oss/OssManager.cs @@ -7,6 +7,7 @@ using Steamworks; using TH1_Logic.Collect; using TH1_Logic.Config; using TH1_Logic.Core; +using TH1_Logic.GameArchive; using TH1_Logic.Net; using UnityEngine; @@ -43,8 +44,7 @@ namespace TH1_Logic.Oss public void UploadMapData(string steamId, MapData endMap) { if (endMap.Net.Mode == NetMode.Multi && !LobbyManager.Instance.Lobby.IsLobbyOwner()) return; - var beginMap = MapData.GetMapData(endMap.Net.Mode == NetMode.Multi, true, false, endMap.MapID); - if (beginMap == null) + if (!GameArchiveManager.Instance.TryLoadCurrentBeginArchive(endMap.Net.Mode, out var beginMap)) { LogSystem.LogError($"UploadMapData Error beginMap is null : {endMap.Net.Mode} {endMap.MapID}"); return; diff --git a/Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs b/Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs index f17a677f0..c47d447d2 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs @@ -13,7 +13,9 @@ using System.Text; using Logic.Config; using Logic.CrashSight; using Logic.Multilingual; +using RuntimeData; using TH1_Logic.Config; +using TH1_Logic.GameArchive; using UnityEngine; @@ -32,6 +34,7 @@ namespace TH1_Logic.Oss public string mode; public uint mapId; public string kind; + public string archiveFolder; public string sourceFileName; public string zipEntry; public long fileSize; @@ -370,102 +373,57 @@ namespace TH1_Logic.Oss private static bool TryGetLatestArchiveSession(bool isMulti, out PlayerBugReportArchiveSession session) { session = null; - var files = GetArchiveFiles(isMulti); - if (files.Count == 0) return false; - - var groups = new Dictionary(); - foreach (var file in files) - { - if (!groups.TryGetValue(file.MapId, out var group)) - { - group = new ArchiveGroup { MapId = file.MapId, IsMulti = isMulti }; - groups[file.MapId] = group; - } - - group.Add(file); - } - - session = groups.Values - .Select(group => group.TryBuildSession()) + var expectedMode = isMulti ? NetMode.Multi : NetMode.Single; + var records = GameRecordManager.Instance.GameRecordData?.Records ?? new List(); + session = records + .Where(record => record != null && record.NetMode == expectedMode) + .Select(TryBuildArchiveSession) .Where(value => value != null) .OrderByDescending(value => value.LatestWriteTimeUtc) .FirstOrDefault(); return session != null; } - private static List GetArchiveFiles(bool isMulti) + private static PlayerBugReportArchiveSession TryBuildArchiveSession(GameRecord record) { - var result = new List(); - var directory = ConfigDirectory; - if (!Directory.Exists(directory)) return result; - - foreach (var pattern in new[] { "map_archive_*.dat", "map_archive_*.dat.bak" }) + if (record == null || string.IsNullOrEmpty(record.BeginArchiveId)) return null; + if (!GameArchiveManager.Instance.TryGetArchivePath( + GameArchiveFileKind.Begin, + record.BeginArchiveId, + out var beginPath)) { - foreach (var path in Directory.GetFiles(directory, pattern)) - { - if (TryParseArchiveFile(path, out var file) && file.IsMulti == isMulti) - result.Add(file); - } + return null; } - return result; - } - - private static bool TryParseArchiveFile(string path, out ArchiveFile file) - { - file = null; - - var fileName = Path.GetFileName(path); - if (string.IsNullOrEmpty(fileName)) return false; - - if (fileName.EndsWith(".bak", StringComparison.OrdinalIgnoreCase)) - fileName = fileName.Substring(0, fileName.Length - ".bak".Length); - if (!fileName.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) return false; - - var stem = fileName.Substring(0, fileName.Length - ".dat".Length); - const string prefix = "map_archive_"; - if (!stem.StartsWith(prefix, StringComparison.Ordinal)) return false; - - var rest = stem.Substring(prefix.Length); - var kind = PlayerBugArchiveKind.Continue; - if (rest.StartsWith("begin_", StringComparison.Ordinal)) + var companionKind = PlayerBugArchiveKind.End; + var companionArchiveKind = GameArchiveFileKind.End; + var companionArchiveId = record.EndArchiveId; + if (string.IsNullOrEmpty(companionArchiveId)) { - kind = PlayerBugArchiveKind.Begin; - rest = rest.Substring("begin_".Length); - } - else if (rest.StartsWith("continue_", StringComparison.Ordinal)) - { - kind = PlayerBugArchiveKind.Continue; - rest = rest.Substring("continue_".Length); - } - else if (rest.StartsWith("end_", StringComparison.Ordinal)) - { - kind = PlayerBugArchiveKind.End; - rest = rest.Substring("end_".Length); - } - else - { - return false; + companionKind = PlayerBugArchiveKind.Continue; + companionArchiveKind = record.RecordKind == GameRecordKind.Quick + ? GameArchiveFileKind.QuickContinue + : GameArchiveFileKind.Continue; + companionArchiveId = record.ContinueArchiveId; } + if (string.IsNullOrEmpty(companionArchiveId)) return null; + if (!GameArchiveManager.Instance.TryGetArchivePath( + companionArchiveKind, + companionArchiveId, + out var companionPath)) + return null; - var isMulti = false; - if (rest.StartsWith("multi_", StringComparison.Ordinal)) + return new PlayerBugReportArchiveSession { - isMulti = true; - rest = rest.Substring("multi_".Length); - } - - if (!uint.TryParse(rest, out var mapId)) return false; - - file = new ArchiveFile - { - Path = path, - Kind = kind, - IsMulti = isMulti, - MapId = mapId, - LastWriteTimeUtc = File.GetLastWriteTimeUtc(path) + IsMulti = record.NetMode == NetMode.Multi, + MapId = record.MapID, + BeginPath = beginPath, + CompanionPath = companionPath, + CompanionKind = companionKind, + LatestWriteTimeUtc = File.GetLastWriteTimeUtc(beginPath) > File.GetLastWriteTimeUtc(companionPath) + ? File.GetLastWriteTimeUtc(beginPath) + : File.GetLastWriteTimeUtc(companionPath) }; - return true; } private static void AddArchiveFile(ZipArchive zip, PlayerBugReportArchiveSession session, @@ -475,7 +433,10 @@ namespace TH1_Logic.Oss var kindLabel = GetKindLabel(kind); var sourceFileName = Path.GetFileName(sourcePath); - var entryPath = $"saves/{session.ModeLabel}/{sourceFileName}"; + var archiveFolder = Path.GetFileName(Path.GetDirectoryName(sourcePath)) ?? ""; + var entryPath = string.IsNullOrEmpty(archiveFolder) + ? $"saves/{session.ModeLabel}/{sourceFileName}" + : $"saves/{session.ModeLabel}/{archiveFolder}/{sourceFileName}"; var entry = zip.CreateEntry(entryPath, System.IO.Compression.CompressionLevel.Optimal); using (var entryStream = entry.Open()) using (var fileStream = File.OpenRead(sourcePath)) @@ -489,6 +450,7 @@ namespace TH1_Logic.Oss mode = session.ModeLabel, mapId = session.MapId, kind = kindLabel, + archiveFolder = archiveFolder, sourceFileName = sourceFileName, zipEntry = entryPath, fileSize = fileInfo.Length, @@ -514,66 +476,6 @@ namespace TH1_Logic.Oss }; } - private class ArchiveFile - { - public string Path; - public PlayerBugArchiveKind Kind; - public bool IsMulti; - public uint MapId; - public DateTime LastWriteTimeUtc; - } - - private class ArchiveGroup - { - public uint MapId; - public bool IsMulti; - private ArchiveFile _begin; - private ArchiveFile _continue; - private ArchiveFile _end; - - public void Add(ArchiveFile file) - { - switch (file.Kind) - { - case PlayerBugArchiveKind.Begin: - if (_begin == null || file.LastWriteTimeUtc > _begin.LastWriteTimeUtc) _begin = file; - break; - case PlayerBugArchiveKind.End: - if (_end == null || file.LastWriteTimeUtc > _end.LastWriteTimeUtc) _end = file; - break; - default: - if (_continue == null || file.LastWriteTimeUtc > _continue.LastWriteTimeUtc) _continue = file; - break; - } - } - - public PlayerBugReportArchiveSession TryBuildSession() - { - if (_begin == null) return null; - var companion = PickCompanion(); - if (companion == null) return null; - - return new PlayerBugReportArchiveSession - { - IsMulti = IsMulti, - MapId = MapId, - BeginPath = _begin.Path, - CompanionPath = companion.Path, - CompanionKind = companion.Kind, - LatestWriteTimeUtc = _begin.LastWriteTimeUtc > companion.LastWriteTimeUtc - ? _begin.LastWriteTimeUtc - : companion.LastWriteTimeUtc - }; - } - - private ArchiveFile PickCompanion() - { - if (_end == null) return _continue; - if (_continue == null) return _end; - return _end.LastWriteTimeUtc >= _continue.LastWriteTimeUtc ? _end : _continue; - } - } - private static string GetLocalTimezone() { try diff --git a/Unity/Assets/Scripts/TH1_UI/View/Bottom/UIBottomBottomBarView.cs b/Unity/Assets/Scripts/TH1_UI/View/Bottom/UIBottomBottomBarView.cs index 39449d3f8..cd66f7955 100644 --- a/Unity/Assets/Scripts/TH1_UI/View/Bottom/UIBottomBottomBarView.cs +++ b/Unity/Assets/Scripts/TH1_UI/View/Bottom/UIBottomBottomBarView.cs @@ -11,6 +11,7 @@ using TH1_Core.Events; using TH1_Core.Managers; using TH1_Logic.Core; using TH1_Logic.Config; +using TH1_Logic.GameArchive; using TH1_Logic.Net; using TH1_Logic.Steam; using TH1_UI.Components; @@ -241,6 +242,8 @@ namespace TH1_UI.View.Bottom if (Main.MapData == null) return; if (!Main.MapData.CurPlayer.IsSelfPlayer()) return; if (Main.MapData.Net.Mode != NetMode.Single) return; + var resumeRecord = GameArchiveManager.Instance.GetQuickResumeRecord(NetMode.Single); + if (resumeRecord == null) return; // 切到 Menu 状态前先抓 Empire:MenuState.Enter 会调 Main.Instance.Clear() 清空 MapData var loadingEvt = new ShowUIOutsideLoading(); @@ -253,7 +256,7 @@ namespace TH1_UI.View.Bottom //播放loading EventManager.Publish(loadingEvt); //重置游戏 - Timer.Instance.TimerRegister(this, () => { Main.Instance.ResumeMatch(); }, 0.3f,"MainUI_ShowLoadingAndRusume"); + Timer.Instance.TimerRegister(this, () => { Main.Instance.ResumeMatch(resumeRecord); }, 0.3f,"MainUI_ShowLoadingAndRusume"); //关闭loading Timer.Instance.TimerRegister(this,()=> { EventManager.Publish(new HideUIOutsideLoading()); },1f,"MainUI_ShowLoadingAndRusume2"); } diff --git a/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMenuView.cs b/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMenuView.cs index 4dd5322fd..1a56e3d56 100644 --- a/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMenuView.cs +++ b/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMenuView.cs @@ -14,6 +14,7 @@ using TH1_Core.Managers; using TH1_Logic.Action; using TH1_Logic.Config; using TH1_Logic.Core; +using TH1_Logic.GameArchive; using TH1_Logic.Net; using TH1_Logic.Steam; using TH1_UI.View.Announce; @@ -123,7 +124,7 @@ namespace TH1_UI.View.Outside MultiplayButton.onClick.AddListener(OnMultiplayClicked); ResumeButton.onClick.RemoveAllListeners(); ResumeButton.gameObject.SetActive(false); - if (Main.Instance.HasArchive()) + if (GameArchiveManager.Instance.HasQuickResumeArchive(NetMode.Single)) { ResumeButton.gameObject.SetActive(true); ResumeButton.onClick.AddListener(OnResumeClicked); @@ -170,10 +171,11 @@ namespace TH1_UI.View.Outside public void OnResumeClicked() { - if (!Main.Instance.HasArchive()) return; + var record = GameArchiveManager.Instance.GetQuickResumeRecord(NetMode.Single); + if (record == null) return; - // 预读存档拿 Empire 用于切换 Loading 图;ResumeMatch 会复用该实例,不会重复反序列化 - var preread = MapData.GetMapData(); + // 预读新 quick 存档拿 Empire 用于切换 Loading 图;ResumeMatch 会复用该实例,不会重复反序列化。 + if (!GameArchiveManager.Instance.TryLoadContinueArchive(record, out var preread)) return; if (preread == null) return; // 单机存档 SelfPlayer 与 ResumeMatch 内一致:PlayerDataList[0] var dataList = preread.PlayerMap?.PlayerDataList; @@ -184,7 +186,7 @@ namespace TH1_UI.View.Outside EventManager.Publish(loadingEvt); var fadeinTime = ResourceCache.Instance.AnimCache.UICommonPanelFadeIn.length; var prepareTime = 1f; - Timer.Instance.TimerRegister(this,()=>{Main.Instance.ResumeMatch(preread);EventManager.Publish(new HideUIOutsideMenu());},fadeinTime,"MenuResumeClicked1"); + Timer.Instance.TimerRegister(this,()=>{Main.Instance.ResumeMatch(record, preread);EventManager.Publish(new HideUIOutsideMenu());},fadeinTime,"MenuResumeClicked1"); Timer.Instance.TimerRegister(this,()=>{EventManager.Publish(new HideUIOutsideAll());},fadeinTime+prepareTime,"MenuResumeClicked2"); } diff --git a/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMultiplayView.cs b/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMultiplayView.cs index 7cae5d4ed..e745faf91 100644 --- a/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMultiplayView.cs +++ b/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMultiplayView.cs @@ -14,6 +14,7 @@ using TH1_Core.Managers; using TH1_Logic.Action; using TH1_Logic.Chat; using TH1_Logic.Core; +using TH1_Logic.GameArchive; using TH1_Logic.MatchConfig; using TH1_Logic.Net; using TH1_Logic.Steam; @@ -678,7 +679,7 @@ namespace TH1_UI.View.Outside if (ResumeToggle == null) return; var mapConfig = Main.Instance.MapConfig; var isOwner = _lobby.IsLobbyOwner(); - var canOwnerResume = isOwner && Main.Instance.HasMultiArchive(); + var canOwnerResume = isOwner && GameArchiveManager.Instance.HasQuickResumeArchive(NetMode.Multi); var selected = mapConfig != null && mapConfig.IsResumeArchiveSelected; if (isOwner && selected && !canOwnerResume) selected = false; @@ -698,7 +699,7 @@ namespace TH1_UI.View.Outside return; } - if (!Main.Instance.HasMultiArchive()) selected = false; + if (!GameArchiveManager.Instance.HasQuickResumeArchive(NetMode.Multi)) selected = false; if (Main.Instance.MapConfig.SetResumeArchiveSelected(selected)) Main.Instance.MapConfig.CheckMapConfigChanged(); RefreshResumeBoard(selected); @@ -1538,7 +1539,7 @@ namespace TH1_UI.View.Outside SetMapConfig(); ReconcileRoomMembers(); - if(Main.Instance.HasMultiArchive() && Main.Instance.MapConfig.IsResumeArchiveSelected){ + if(GameArchiveManager.Instance.HasQuickResumeArchive(NetMode.Multi) && Main.Instance.MapConfig.IsResumeArchiveSelected){ if (!AreCurrentLobbyMembersReady()) { Debug.Log("Cannot resume multiplayer game: not all current lobby members are ready"); @@ -1672,7 +1673,10 @@ namespace TH1_UI.View.Outside private bool TryStartHostGame(bool resume) { - return resume ? Main.Instance.MainMemberResumeMatch() : Main.Instance.MainMemberStartMatch(); + if (!resume) return Main.Instance.MainMemberStartMatch(); + + var record = GameArchiveManager.Instance.GetQuickResumeRecord(NetMode.Multi); + return record != null && Main.Instance.ResumeMatch(record); }