diff --git a/.codex/skills/th1-action-logic/SKILL.md b/.codex/skills/th1-action-logic/SKILL.md index 9fb8fa341..f9ff2f109 100644 --- a/.codex/skills/th1-action-logic/SKILL.md +++ b/.codex/skills/th1-action-logic/SKILL.md @@ -104,6 +104,7 @@ AI 的最终执行仍然走 action 层: 多人行为不要绕过 `CompleteExecute`: - 判断本机是否能执行当前回合 action 时,优先使用 `MapData.CanLocalControlPlayer(playerId)`,不要只比较 `CurPlayer == PlayerMap.SelfPlayerData`。单机/房主可以控制多个真实 player 时,`SelfPlayerId` 会为旧 UI/input 临时切到当前本机可控玩家;非本机可控回合应恢复默认本机 player,避免资源、投降、存活等待等 UI 看错对象。 +- 改 `CheckIsRealPlayer` / `CheckIsAI` 相关逻辑时,不要只看 `Net.Players` 或当前 `SelfPlayerId`。联机额外 host-controlled player 是真实玩家但不会进入 `Net.Players`,单机 slot 0 也不是 `IsHostControlled` 但仍是真实本机玩家。 - Host 本地 action:`CompleteExecute` 先 `ActionExecute` 广播,广播失败则不执行本地 mutation。 - Client 本地 action:`CompleteExecute` 发送 `ActionConfirm` 给 host;发送失败不执行。本地 `TurnEnd` 发送后直接返回 false,等待 host 广播。 - Host 收到 `ActionConfirm`:刷新 params,校验当前玩家,调用 `CompleteExecute`,再广播 `ActionExcuteMessage` 并执行。 diff --git a/.codex/skills/th1-game-archive/SKILL.md b/.codex/skills/th1-game-archive/SKILL.md index 85cbd900d..21cc26d90 100644 --- a/.codex/skills/th1-game-archive/SKILL.md +++ b/.codex/skills/th1-game-archive/SKILL.md @@ -44,33 +44,37 @@ Archive files are grouped by folder: `Quick` uses fixed record id and file id `quick`. Manual/end/begin ids are GUID-like strings from `GameArchiveManager`. -Record persistence: +Record archive discard index: -- `Manual` and `Quick` records are persistent by default and should stay persistent after old data migration. -- `Ended` records are non-persistent by default; only explicit user/UI action should persist them. -- Append new `GameRecord` fields at the end only. `IsPersistent` belongs after archive id fields. +- All `MapData` files are stored by default when the normal begin/quick_continue/continue/end flow writes them. +- `GameRecord.DiscardArchiveIndex` is used by cleaned `Ended` records so they stop protecting begin/end archive files during map cleanup. +- `Manual` cleanup deletes the record; it does not need a discard flag. +- `Quick` cleanup is a no-op through the user-facing cleanup interface. +- Append new `GameRecord` fields at the end only. If an old serialized field is removed from business logic, keep a serialization placeholder so later fields do not shift. ## 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. +- New multiplayer member game: write a local begin after `NetStartGame` succeeds. This supports local `Ended` records and replay for players who were present from game start. - 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)`. -- Record delete: call `GameArchiveManager.DeleteGameRecord(record)` for any non-`Quick` record. This must only remove the record index, not archive files. +- Record cleanup: call `GameArchiveManager.CleanupGameRecord(record)`. This must only update/delete the record, not archive files. 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. -## Record Cleanup +## Record And Map Cleanup Keep record operations and archive-file operations separate: -- `GameArchiveManager.SetGameRecordPersistent(record, bool)` may only change `Ended` records. -- `GameArchiveManager.DeleteGameRecord(record)` may delete `Manual` or `Ended` records, never `Quick`, and must not delete `MapData` files. -- `GameArchiveManager.CleanupNonPersistentGameRecords()` removes non-persistent records only. With defaults, this mostly cleans unpinned `Ended` records. -- `GameArchiveManager.CleanupUnlinkedLocalMapData()` removes local `MapData` files not referenced by any remaining record. +- `GameArchiveManager.CleanupGameRecord(record)` is the only user-facing record cleanup flow: + - `Quick`: no-op, return false. + - `Manual`: delete the record only; do not delete `MapData` files here. + - `Ended`: keep the record but set `DiscardArchiveIndex = true`; do not delete `MapData` files here. +- `GameArchiveManager.CleanupUnlinkedLocalMapData()` is the only map-file cleanup flow. It builds the valid archive index set from records, skips `Ended` records with `DiscardArchiveIndex == true`, and deletes local `MapData` files not referenced by the valid index. Archive cleanup should cover both current and legacy local data: diff --git a/.codex/skills/th1-network-sync/SKILL.md b/.codex/skills/th1-network-sync/SKILL.md index c440ae915..ee7938ef3 100644 --- a/.codex/skills/th1-network-sync/SKILL.md +++ b/.codex/skills/th1-network-sync/SKILL.md @@ -63,9 +63,10 @@ For the one-click Steam P2P stress tool, report fields, and current healthy base - Pre-game room settings, `MemberCiv`, player slot/team/AI flags, and ready state live in `Main.Instance.MapConfig`. - `MapConfig.MultiCivs` is now the full player-slot list and should be sized to `PlayerCount`; do not treat it as only current lobby members. - Each `MemberCiv` slot uses `Index` as the stable player position. `MemberId != 0` means a real member is bound, `MemberId == 0 && IsAI` means an AI slot, `MemberId == 0 && !IsAI && IsHostControlled` means a host/local-controlled real player slot, and `TeamId == 0` means no team. - - Host-controlled slots are real players, not AI and not lobby members. Keep `IsReady=false`, do not add them to `Net.Players`, skip them when assigning/moving real lobby members, and allow them in `AreAllLobbyMembersReady()`. + - Host-controlled slots are extra real players controlled by the local player/host; the singleplayer main slot and multiplayer lobby owner slot themselves are not `IsHostControlled`. Keep host-controlled slots `IsReady=false`, do not add them to `Net.Players`, skip them when assigning/moving real lobby members, and allow them in `AreAllLobbyMembersReady()`. - `Net.RefreshPlayerNet(mapData)` maps only current lobby `MemberId` values to `PlayerId`; host-controlled slots must not require a Steam member mapping or duplicate the host's mapping. - - Local control should go through `MapData.CanLocalControlPlayer(...)`: in singleplayer all real local slots are controllable; in multiplayer the member's mapped slot is controllable, and only the lobby owner may also control `IsHostControlled` slots. When switching turn identity for UI/input, restore the default local member slot on non-local turns. + - `MapData.CheckIsRealPlayer(...)` must treat `MapConfig.IsRealPlayerSlot(...)` as meaningful before relying on `Net.Players`: normal lobby players are covered by `Net.Players`, but host-controlled extra real players are intentionally absent from `Net.Players` and would otherwise be misclassified as AI. + - Local control should go through `MapData.CanLocalControlPlayer(...)`: in singleplayer slot 0 plus `IsHostControlled` extra slots are controllable; in multiplayer the member's mapped slot is controllable, and only the lobby owner may also control `IsHostControlled` slots. When switching turn identity for UI/input, restore the default local member slot on non-local turns. - Call `MapConfig.EnsurePlayerSlots(NetMode.Multi)` before reading or mutating lobby slots, especially after changing `PlayerCount` or receiving host config. - Clients may optimistically update their own `MemberCiv` only after `ChangeCiv` send succeeds, then still accept host `UpdateLobbyData` as authority. - Clients entering a room should request host lobby data until current lobby members match `MapConfig.MultiCivs`. diff --git a/Unity/Assets/Scripts/TH1_Data/MapData.cs b/Unity/Assets/Scripts/TH1_Data/MapData.cs index 6b9f5b6bb..14da4abf4 100644 --- a/Unity/Assets/Scripts/TH1_Data/MapData.cs +++ b/Unity/Assets/Scripts/TH1_Data/MapData.cs @@ -251,15 +251,15 @@ namespace RuntimeData if (MultiCivs.Count == 0) return; var selfSlot = MultiCivs[0]; - PrepareSinglePlayerSlot(selfSlot, index: 0, isAI: false); - selfSlot.IsHostControlled = true; + PrepareSinglePlayerSlot(selfSlot, index: 0, isAI: false, isHostControlled: false); selfSlot.IsCivFixed = true; var usedCivs = new HashSet { selfSlot.CivId }; for (int i = 1; i < MultiCivs.Count; i++) { var slot = MultiCivs[i]; - PrepareSinglePlayerSlot(slot, i, isAI: true); + var isHostControlled = slot.MemberId == 0 && !slot.IsAI && slot.IsHostControlled; + PrepareSinglePlayerSlot(slot, i, isAI: !isHostControlled, isHostControlled: isHostControlled); if (!slot.IsCivFixed || usedCivs.Contains(slot.CivId)) { var civId = PickDefaultCivId(i, usedCivs); @@ -271,7 +271,7 @@ namespace RuntimeData } } - private static void PrepareSinglePlayerSlot(MemberCiv slot, int index, bool isAI) + private static void PrepareSinglePlayerSlot(MemberCiv slot, int index, bool isAI, bool isHostControlled = false) { slot.Index = index; slot.MemberId = 0; @@ -279,7 +279,7 @@ namespace RuntimeData slot.PlayerId = 0; slot.TeamId = NoTeamId; slot.IsAI = isAI; - slot.IsHostControlled = !isAI; + slot.IsHostControlled = !isAI && isHostControlled; } private static uint PickDefaultCivId(int preferredIndex, HashSet usedCivs) @@ -469,6 +469,7 @@ namespace RuntimeData return false; var slot = GetPlayerSlot(index, netMode); if (slot == null) return false; + if (netMode == NetMode.Single && slot.Index == 0 && isHostControlled) return false; if (isHostControlled) { if (slot.MemberId != 0) return false; @@ -483,6 +484,7 @@ namespace RuntimeData if (!slot.IsHostControlled) return false; slot.IsHostControlled = false; + if (netMode == NetMode.Single && slot.MemberId == 0 && slot.Index != 0) slot.IsAI = true; return true; } @@ -502,14 +504,16 @@ namespace RuntimeData var slot = GetPlayerSlot(index, NetMode.Single); if (slot == null) return false; - var isAI = index != 0; + var isHostControlled = index != 0 && slot.MemberId == 0 && !slot.IsAI && slot.IsHostControlled; + var isAI = index != 0 && !isHostControlled; var changed = slot.CivId != civId || slot.ForceId != forceId || slot.IsAI != isAI + || slot.IsHostControlled != isHostControlled || !slot.IsCivFixed || slot.MemberId != 0; - PrepareSinglePlayerSlot(slot, index, isAI); + PrepareSinglePlayerSlot(slot, index, isAI, isHostControlled); slot.CivId = civId; slot.ForceId = forceId; slot.IsCivFixed = true; @@ -574,6 +578,30 @@ namespace RuntimeData return false; } + public bool HasHostControlledPlayer() + { + if (MultiCivs == null) return false; + foreach (var slot in MultiCivs) + { + if (slot == null) continue; + if (slot.MemberId == 0 && !slot.IsAI && slot.IsHostControlled) return true; + } + + return false; + } + + public bool IsSingleLocalControlledPlayer(uint playerId) + { + if (playerId == 0 || MultiCivs == null) return false; + foreach (var slot in MultiCivs) + { + if (slot == null || slot.PlayerId != playerId) continue; + return slot.Index == 0 || slot.IsHostControlled; + } + + return false; + } + public bool IsRealPlayerSlot(uint playerId) { if (playerId == 0 || MultiCivs == null) return false; @@ -2367,6 +2395,7 @@ namespace RuntimeData var versionInfo = ConfigManager.Instance.VersionCfg?.CurVersionInfo; gameRecord.GameVersion = versionInfo?.FullVersion ?? Application.version; gameRecord.GameVersionId = versionInfo?.VersionId ?? 0; + gameRecord.HasHostControlledPlayer = MapConfig?.HasHostControlledPlayer() ?? false; return gameRecord; } @@ -2636,8 +2665,14 @@ namespace RuntimeData public bool CheckIsRealPlayer(uint playerId) { if (playerId == 0) return false; + if (Net?.Mode == NetMode.Single) + { + if (MapConfig != null && MapConfig.IsSingleLocalControlledPlayer(playerId)) return true; + return playerId == PlayerMap.SelfPlayerId; + } + if (MapConfig != null && MapConfig.IsRealPlayerSlot(playerId)) return true; - if (Net.Mode == NetMode.Multi) + if (Net?.Mode == NetMode.Multi) { foreach (var kv in Net.Players) { @@ -2668,7 +2703,7 @@ namespace RuntimeData } if (Net?.Mode == NetMode.Single) - return MapConfig?.IsRealPlayerSlot(playerId) ?? playerId == PlayerMap.SelfPlayerId; + return MapConfig?.IsSingleLocalControlledPlayer(playerId) ?? playerId == PlayerMap.SelfPlayerId; return playerId == PlayerMap.SelfPlayerId; } diff --git a/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs b/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs index fd7b2c254..4dd106d10 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs @@ -579,6 +579,14 @@ namespace TH1_Logic.Core EventManager.Publish(announcement); //UIManager.Instance.CenterMessageUI.SetCenterMessageShow(UICenterMessageID.StartGame,MapData.PlayerMap.SelfPlayerData); },1.5f,"Main_CenterMessage_Anim"); + + // 成员端也要保存本机 begin。之后本机游戏结束会写 end + Ended record, + // 回放需要这条 record 同时索引到开局快照和结束快照。 + if (!GameArchiveManager.Instance.SaveBeginArchive(MapData)) + { + LogSystem.LogWarning("NetStartGame: 成员端保存 begin 存档失败,之后本机 Ended record 可能无法回放"); + } + LogSystem.LogInfo($"NetStartGame : {NetData.GetMapDataHash(MapData)}"); return true; } diff --git a/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs b/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs index 9c615d7c9..46ba2e0a3 100644 --- a/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs +++ b/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs @@ -112,7 +112,7 @@ namespace TH1_Logic.GameArchive // 通用存档规则: // - 可以有任意多条,所以每条 continue 文件都使用新的 archiveId。 // - recordName 是玩家自定义名字;空名字会归一成“手动存档”。 - // - 这里只创建,不自动删除;删除必须走 DeleteManualContinueRecord。 + // - 这里只创建,不自动删除;清理必须走 CleanupGameRecord。 public bool SaveManualContinueRecord(MapData map, string recordName) { if (!EnsureCurrentBeginArchive(map)) return false; @@ -207,6 +207,8 @@ namespace TH1_Logic.GameArchive { mapData = null; if (record == null) return false; + // Ended 清理后 record 仍可作为历史展示行存在,但不再拥有 begin/end 文件索引。 + if (record.RecordKind == GameRecordKind.Ended && record.DiscardArchiveIndex) return false; if (record.NetMode != NetMode.Single && record.NetMode != NetMode.Multi) return false; if (string.IsNullOrEmpty(record.BeginArchiveId)) return false; @@ -219,6 +221,8 @@ namespace TH1_Logic.GameArchive { mapData = null; if (record == null) return false; + // 与 map 清理规则保持一致:丢弃索引的 Ended record 不能再读取 end 文件。 + if (record.RecordKind == GameRecordKind.Ended && record.DiscardArchiveIndex) return false; if (record.NetMode != NetMode.Single && record.NetMode != NetMode.Multi) return false; if (string.IsNullOrEmpty(record.EndArchiveId)) return false; @@ -267,31 +271,6 @@ namespace TH1_Logic.GameArchive _currentBeginArchiveId = beginArchiveId; } - // 玩家主动删除通用存档的上层接口。 - // 这里只允许删除 Manual record,Quick 和 Ended 不通过这个接口删除。 - public bool DeleteManualGameRecord(GameRecord record) - { - return DeleteManualContinueRecord(record); - } - - // 删除玩家手动通用存档。 - // - // 删除顺序刻意是先删 record,再删文件: - // - 如果传进来的 record 不在 GameRecordData 里,不会误删磁盘文件。 - // - 如果文件删除失败,record 已经移除,之后 UI 不会继续显示一条坏记录。 - public bool DeleteManualContinueRecord(GameRecord record) - { - if (record == null || record.RecordKind != GameRecordKind.Manual) return false; - if (!GameRecordManager.Instance.RemoveRecord(record)) return false; - - if (!string.IsNullOrEmpty(record.ContinueArchiveId)) - { - DeleteArchive(GameArchiveFileKind.Continue, record.ContinueArchiveId); - } - - return true; - } - // 删除快速存档 record 和 quick.dat。 // // beginArchiveId 不为空时,只允许删除同一局的 quick。 @@ -310,27 +289,35 @@ namespace TH1_Logic.GameArchive return GameRecordManager.Instance.RemoveQuickRecord(beginArchiveId); } - // 设置 Ended record 是否在清理时保留。Manual/Quick 默认持久化,不通过这个接口改。 - public bool SetGameRecordPersistent(GameRecord record, bool isPersistent) + // 用户主动清理指定 record。这里只动 record 状态/索引,不清理 MapData 文件。 + // + // - Quick:自动快速存档由回合保存和游戏结束流程维护,这个接口不动它。 + // - Manual:直接删除 record;对应 continue/begin 文件等下一阶段无引用清理再回收。 + // - Ended:保留历史展示行,只丢弃 begin/end 索引,等待 map 清理回收文件。 + public bool CleanupGameRecord(GameRecord record) { - if (record == null || record.RecordKind != GameRecordKind.Ended) return false; - return GameRecordManager.Instance.SetRecordPersistent(record, isPersistent); + if (record == null) return false; + + switch (record.RecordKind) + { + case GameRecordKind.Quick: + // Quick 是唯一自动存档,用户清理 record 时不删除、不标记。 + return false; + case GameRecordKind.Manual: + // Manual 本身就是可继续入口;删除 record 后它自然不再保护任何 map 文件。 + return GameRecordManager.Instance.RemoveRecord(record); + case GameRecordKind.Ended: + // Ended 需要继续展示战绩,所以只让它停止保护 begin/end 文件。 + return GameRecordManager.Instance.DiscardEndedRecordArchiveIndex(record); + default: + return false; + } } - // 删除非 Quick record。这里只动 record,不清理 MapData 文件。 - public bool DeleteGameRecord(GameRecord record) - { - if (record == null || record.RecordKind == GameRecordKind.Quick) return false; - return GameRecordManager.Instance.RemoveRecord(record); - } - - // 清理所有未持久化 record。这里只动 record,不清理 MapData 文件。 - public int CleanupNonPersistentGameRecords() - { - return GameRecordManager.Instance.RemoveNonPersistentRecords(); - } - - // 删除所有没有 record 链接的本地 MapData 文件。 + // 第二阶段 map 清理:删除所有没有有效 record 索引保护的本地 MapData 文件。 + // + // Manual 清理会先删除 record,所以这里天然不会再看到它; + // Ended 清理只标记 DiscardArchiveIndex,所以 BuildReferencedArchiveKeySet 会显式跳过它。 // 覆盖新版 GameArchives,以及旧版 Config/map_archive_* 和早期 persistentDataPath/map_archive_*。 public int CleanupUnlinkedLocalMapData() { @@ -508,6 +495,10 @@ namespace TH1_Logic.GameArchive } } + // 建立“仍被 record 保护”的 archive 集合。 + // + // 只有进入这个集合的 begin/quick_continue/continue/end 文件才会被保留; + // 被清理过的 Ended record 虽然还在 Records 里,但已经主动放弃索引保护,必须跳过。 private HashSet BuildReferencedArchiveKeySet() { var archiveKeys = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -517,8 +508,10 @@ namespace TH1_Logic.GameArchive foreach (var record in records) { if (record == null) continue; + if (record.RecordKind == GameRecordKind.Ended && record.DiscardArchiveIndex) continue; AddArchiveKey(archiveKeys, GameArchiveFileKind.Begin, record.BeginArchiveId); + // Quick 的 continue 文件固定在 quick_continue/quick.dat;Manual 的 continue 文件在 continue/{id}.dat。 if (record.RecordKind == GameRecordKind.Quick) AddArchiveKey(archiveKeys, GameArchiveFileKind.QuickContinue, record.ContinueArchiveId); else diff --git a/Unity/Assets/Scripts/TH1_Logic/GameRecord/GameRecordManager.cs b/Unity/Assets/Scripts/TH1_Logic/GameRecord/GameRecordManager.cs index 675715dbd..6908f08fb 100644 --- a/Unity/Assets/Scripts/TH1_Logic/GameRecord/GameRecordManager.cs +++ b/Unity/Assets/Scripts/TH1_Logic/GameRecord/GameRecordManager.cs @@ -131,7 +131,6 @@ namespace RuntimeData if (record == null) return; RefreshGameRecord(); EnsureRecordId(record); - ApplyRecordPersistenceDefault(record); _gameRecord.Records.Add(record); SaveGameRecordData(); } @@ -144,7 +143,6 @@ namespace RuntimeData RefreshGameRecord(); record.RecordKind = GameRecordKind.Quick; record.RecordId = GameArchiveManager.QuickArchiveId; - record.IsPersistent = true; _gameRecord.Records.RemoveAll(existing => existing.RecordKind == GameRecordKind.Quick); _gameRecord.Records.Add(record); SaveGameRecordData(); @@ -172,7 +170,8 @@ namespace RuntimeData return true; } - // 删除某条 record。主要用于删除玩家手动通用存档。 + // 删除某条 record。当前用户清理流程只让 Manual 走这里。 + // Quick 不走用户清理;Ended 只丢弃索引,不直接删除历史展示行。 // UI 传回来的 record 可能不是同一个对象引用,所以 IsSameRecord 会用 RecordId 和索引字段兜底匹配。 public bool RemoveRecord(GameRecord record) { @@ -185,45 +184,27 @@ namespace RuntimeData return true; } - // 设置 Ended record 是否参与持久保留。 - // Manual/Quick 是普通可继续记录,默认并始终持久化,不通过这个接口改。 - public bool SetRecordPersistent(GameRecord record, bool isPersistent) + // 标记 Ended record 不再参与 MapData 索引。 + // + // Ended 清理后仍保留 record 作为历史战绩展示; + // 这里只写 DiscardArchiveIndex,不删除 begin/end 文件,文件回收交给 CleanupUnlinkedLocalMapData。 + public bool DiscardEndedRecordArchiveIndex(GameRecord record) { if (record == null) return false; RefreshGameRecord(); var storedRecord = FindRecord(record); if (storedRecord == null) return false; + if (storedRecord.RecordKind != GameRecordKind.Ended) return false; - if (storedRecord.RecordKind != GameRecordKind.Ended) - { - record.IsPersistent = true; - if (!storedRecord.IsPersistent) - { - storedRecord.IsPersistent = true; - SaveGameRecordData(); - } - return false; - } + // 同步传入对象,避免 UI 手里的 record 在下一次刷新前仍显示为未清理状态。 + record.DiscardArchiveIndex = true; + if (storedRecord.DiscardArchiveIndex) return false; - record.IsPersistent = isPersistent; - if (storedRecord.IsPersistent == isPersistent) return false; - - storedRecord.IsPersistent = isPersistent; + storedRecord.DiscardArchiveIndex = true; SaveGameRecordData(); return true; } - // 删除所有未标记持久化的 record,返回删除数量。 - public int RemoveNonPersistentRecords() - { - RefreshGameRecord(); - var removed = _gameRecord.Records.RemoveAll(record => record == null || !record.IsPersistent); - if (removed <= 0) return 0; - - SaveGameRecordData(); - return removed; - } - // 老数据没有 RecordId,新写入前补一个。 // Quick record 使用固定 quick id,会在 UpsertQuickRecord 里覆盖。 private void EnsureRecordId(GameRecord record) @@ -232,13 +213,6 @@ namespace RuntimeData record.RecordId = Guid.NewGuid().ToString("N"); } - private void ApplyRecordPersistenceDefault(GameRecord record) - { - if (record == null) return; - if (record.RecordKind == GameRecordKind.Quick || record.RecordKind == GameRecordKind.Manual) - record.IsPersistent = true; - } - private GameRecord FindRecord(GameRecord record) { if (record == null) return null; @@ -304,25 +278,6 @@ namespace RuntimeData _gameRecord ??= new GameRecordData(); _gameRecord.Records ??= new List(); - if (NormalizeRecordPersistenceDefaults()) SaveGameRecordData(); - } - - private bool NormalizeRecordPersistenceDefaults() - { - if (_gameRecord?.Records == null) return false; - - var changed = false; - foreach (var record in _gameRecord.Records) - { - if (record == null) continue; - if (record.RecordKind != GameRecordKind.Quick && record.RecordKind != GameRecordKind.Manual) continue; - if (record.IsPersistent) continue; - - record.IsPersistent = true; - changed = true; - } - - return changed; } } @@ -385,7 +340,12 @@ namespace RuntimeData public string ContinueArchiveId; // 结束快照文件 id。只有 Ended 需要它,Manual/Quick 通常为空。 public string EndArchiveId; - // 是否在存档清理中保留本条 record。新增字段必须继续追加在末尾。 - public bool IsPersistent; + // 已废弃的旧持久化字段占位。保留 MemoryPack 字段顺序,业务逻辑不要读取或写入。 + [MemoryPackInclude] + private bool _deprecatedIsPersistent; + // 本局是否存在额外由本机/房主控制的真实玩家。房主/本机主玩家本身不算。新增字段必须继续追加在末尾。 + public bool HasHostControlledPlayer; + // 只对 Ended 有业务意义:true 表示这条历史记录不再保护 begin/end 文件。新增字段必须继续追加在末尾。 + public bool DiscardArchiveIndex; } }