迭代新存档流程

This commit is contained in:
wuwenbo 2026-06-02 20:30:19 +08:00
parent c26c4d008a
commit 4e66b61e3c
16 changed files with 404 additions and 733 deletions

View File

@ -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.

View File

@ -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."

View File

@ -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` 子目录写回
---

View File

@ -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 配置,该文件应只保留在本机。

View File

@ -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

View File

@ -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<bool, MapArchiveAvailabilityCache> MapArchiveAvailabilityCaches =
new Dictionary<bool, MapArchiveAvailabilityCache>();
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<string> GetMapArchiveCandidates(bool isMulti, MapArchiveKind kind, uint mapId)
{
string directory = Application.persistentDataPath + "/../Config/";
if (!Directory.Exists(directory)) return new List<string>();
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<string>();
}
}
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<uint, DateTime> GetLatestEndMapArchiveTimes(bool isMulti)
{
var latestTimes = new Dictionary<uint, DateTime>();
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);
}

View File

@ -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;

View File

@ -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);

View File

@ -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();

View File

@ -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<GameRecord>();
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<string>();
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;

View File

@ -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));

View File

@ -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;

View File

@ -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<uint, ArchiveGroup>();
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<GameRecord>();
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<ArchiveFile> GetArchiveFiles(bool isMulti)
private static PlayerBugReportArchiveSession TryBuildArchiveSession(GameRecord record)
{
var result = new List<ArchiveFile>();
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

View File

@ -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 状态前先抓 EmpireMenuState.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");
}

View File

@ -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");
}

View File

@ -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);
}