# 联机 MapData 一致性排查报告(2026-05-22 至 2026-05-28) ## 范围 - 基准提交:`575b8a288`,2026-05-21 18:41:43 +0800,`增加单位删除新增的格子保护` - 当前 HEAD:`b2a8019a0`,2026-05-28 16:29:21 +0800,merge commit - 扫描提交数:40 - `Unity/Assets/Scripts` 净变更:90 files changed, 7242 insertions, 893 deletions - 重点扫描:`MapData`/`NetData`/`PlayerData`/`UnitData`、MemoryPack 类型、Steam 联机收发、action 流、结算与技能生命周期 ## 总结 最像“进局后基本立刻不一致”的嫌疑,不是某个普通 action 的执行体,而是 `GameStart`/`ForceUpdate` 收到完整 `MapData` 后,本机反序列化和 `NetStartGame` 校验阶段会按本机 lobby/UI 状态重写 `MapConfig`。这会让客户端在执行第一个 `TurnStart` action 前,`NetData.GetMapDataHash(Main.MapData)` 已经和房主不同。 优先看 F-001 和 F-002。F-004 会放大 F-001:开局队友首都视野是 action 内数据改动,但它依赖 `MapConfig.MultiCivs[*].PlayerId/TeamId` 一致。 ## F-001 P0:`MapConfig.EnsurePlayerSlots` 在反序列化路径读取本机 lobby 并改写 `PlayerCount/MultiCivs` 相关提交:`03c33d637`(2026-05-23,`bug审查,优化槽位代码`) 关键位置: - `Unity/Assets/Scripts/TH1_Data/MapData.cs:115`:`MapConfig.OnAfterMemoryPackDeserialize` - `Unity/Assets/Scripts/TH1_Data/MapData.cs:125`:反序列化后直接 `EnsurePlayerSlots(NetMode.Multi)` - `Unity/Assets/Scripts/TH1_Data/MapData.cs:129`:`EnsurePlayerSlots` - `Unity/Assets/Scripts/TH1_Data/MapData.cs:174`:`GetRequiredPlayerSlotCount` - `Unity/Assets/Scripts/TH1_Data/MapData.cs:177`:读取 `LobbyManager.Instance?.Lobby` - `Unity/Assets/Scripts/TH1_Data/MapData.cs:180`:读取本机 `lobby.GetAllMemberIds()` - `Unity/Assets/Scripts/TH1_Data/MapData.cs:132-133`:用本机结果写回 `targetCount` 和 `PlayerCount` - `Unity/Assets/Scripts/TH1_Data/MapData.cs:1989`:`MapData.OnAfterMemoryPackDeserialize` 又调用 `MapConfig.ApplyTeamDiplomacy(this)` - `Unity/Assets/Scripts/TH1_Data/MapData.cs:492-521`:`ApplyTeamDiplomacy` 再次 `EnsurePlayerSlots` 并写 `MultiCivs[i].PlayerId` 风险说明: MemoryPack 的 `OnAfterMemoryPackDeserialize` 应该只做纯数据补默认值、重建缓存、确定性迁移。这里读取了本机 Steam lobby 成员列表,并据此改写 `MapConfig.PlayerCount` 与 `MultiCivs`。房主和客户端在进局瞬间的 lobby 可见成员列表、顺序、连接状态可能不完全同步;一旦任何客户端反序列化出的 `MultiCivs` 比房主多/少一个槽,或 `PlayerId/TeamId` 绑定不同,第一次 `ActionExecute` 的 map hash 就会不一致。 这条路径也会影响 `ForceUpdate`,因为客户端收到房主完整 `MapData` 后仍会本地反序列化并再次走这些后处理。 建议修复: - 把 `EnsurePlayerSlots` 拆成两套: - `EnsureSerializedPlayerSlots`:纯数据、只按已序列化的 `PlayerCount/MultiCivs` 修 null、修 index,不读取 lobby、不改变 `PlayerCount`。 - `EnsureLobbyPlayerSlots`:只允许在房间阶段的 `Main.Instance.MapConfig` 上、由 host 权威调用。 - `MapConfig.OnAfterMemoryPackDeserialize` 不要读取 `LobbyManager`,也不要按本机 lobby 扩容/裁剪槽位。 - `MapData.OnAfterMemoryPackDeserialize` 中的 `ApplyTeamDiplomacy` 不应再次触发 lobby 相关槽位修复。 - 对收到的 `GameStart/ForceUpdate`,如果槽位和 `Net.Players` 不一致,应校验失败并提示,而不是本地修。 ## F-002 P0/P1:`IsResumeArchiveSelected` 是 UI/房间状态,但新增为 `MapConfig` 可序列化字段 相关提交:`880e66fcd`(2026-05-28,`联机新功能`) 关键位置: - `Unity/Assets/Scripts/TH1_Data/MapData.cs:87`:`public bool IsResumeArchiveSelected;` - `Unity/Assets/Scripts/TH1_Data/MapData.cs:733`:`SetResumeArchiveSelected` - `Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMultiplayView.cs:673-700`:UI toggle 直接写 `Main.Instance.MapConfig` - `Unity/Assets/Scripts/TH1_Data/MapData.cs:1872`:`MapData(MapConfig mapCfg, ...)` 直接保存 `MapConfig = mapCfg`,不是拷贝 风险说明: `IsResumeArchiveSelected` 看语义是“房主当前是否选择继续存档”的 UI/房间状态,不是局内权威 gameplay 状态。但它没有 `[MemoryPackIgnore]`,所以会进入 `MapData` 的 MemoryPack 序列化和 hash。 更危险的是,房主 `new MapData(MapConfig, NetMode.Multi)` 时直接复用 `Main.Instance.MapConfig` 对象引用。进局后如果外部房间 UI、lobby 回调或残留 timer 再写 `Main.Instance.MapConfig.SetResumeArchiveSelected(...)`,房主的 `Main.MapData.MapConfig` 也会被 action 外修改,而客户端不会同步同样修改。 建议修复: - 给 `IsResumeArchiveSelected` 加 `[MemoryPackIgnore]`,或移出 `MapConfig`,放到独立房间 UI state。 - 如果它必须参与开始/继续流程,用 host-authoritative lobby 消息同步,不要放进局内 `MapData` hash。 - `MapData(MapConfig mapCfg, ...)` 最好深拷贝一份只读局内配置,避免进局后 `Main.Instance.MapConfig` 被 UI 改动时污染 `Main.MapData.MapConfig`。 ## F-003 P1:`MomentData.OnAfterMemoryPackDeserialize` 会修改已反序列化的 `Items` 相关提交:`a5a0bf51d`(2026-05-27,`联机功能开发`) 关键位置: - `Unity/Assets/Scripts/TH1_Logic/Moment/MomentData.cs:46-50`:反序列化后调用 `EnsureMomentItems` - `Unity/Assets/Scripts/TH1_Logic/Moment/MomentData.cs:56-87`:删除旧 `ExploitWonderMomentItem`,再通过 `assembly.GetTypes()` 补齐缺失 item - `Unity/Assets/Scripts/TH1_Logic/Moment/MomentData.cs:166-172`:新增 MemoryPackUnion 59-65 风险说明: `MomentData` 是 `PlayerData` 下的 MemoryPack 数据,会进入 `MapData` hash。现在每次反序列化都会改 `Items` 列表:移除旧奇观 moment,并用反射扫描补新类型。只要两端程序集类型枚举顺序、编译裁剪、类型列表存在差异,就可能得到不同 `Items` 顺序或内容。 即使当前 Windows 同版本通常稳定,这仍然是反序列化时改权威数据的隐患。它更适合做成显式版本迁移,并且只在 host 生成/加载地图后、广播前执行一次。 建议修复: - 用固定顺序的静态类型表代替 `assembly.GetTypes()`。 - 给 `MomentData` 增加 schema/version 字段,迁移逻辑只按版本执行。 - 客户端收到 `GameStart/ForceUpdate` 时不做会改变列表内容的“自动补全”,只做 null 防御。 ## F-004 P1:开局队友首都视野会在 `TurnStart` action 中写 `PlayerData.Sight` 相关提交:`7a501d15e`(2026-05-28,`增加开局开队友首都视野`) 关键位置: - `Unity/Assets/Scripts/TH1_Data/PlayerData.cs:499-502`:`OnTurnStart` 中调用 `UpdateAllTeammateCapitalSight` - `Unity/Assets/Scripts/TH1_Logic/Player/PlayerLogic.cs:1618-1639`:遍历所有玩家,为队友首都补视野 - `Unity/Assets/Scripts/TH1_Logic/Player/PlayerLogic.cs:1642-1650`:队友判断依赖 `MapConfig.ArePlayersInSameTeam` 和外交 `IsTeammate` 风险说明: 这条数据修改本身是在 `TurnStart` action 里,按 action 流程执行,不是直接绕过 action。但它强依赖 `MapConfig.MultiCivs[*].PlayerId/TeamId` 与外交标记一致。若 F-001 导致任意客户端槽位绑定不同,第一回合开始时玩家视野会立刻不同。 建议修复: - 先修 F-001,保证 `MultiCivs` 反序列化不依赖本机 lobby。 - 给首个 `TurnStart` 前后加临时日志:每个玩家的 `SightGidSet.Count`,以及所有 `MultiCivs` 的 `Index/MemberId/PlayerId/TeamId`。 - 若这条逻辑只需要开局执行,考虑放在 host 地图生成完成、`GameStart` 广播前,避免每次 `TurnStart` 反复补。 ## F-005 P1/P2:`SanaeDivineSkill.BigLucky` 仍存在基于本机视野的真实数据改动 相关文件本周被修改:`Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/SanaeDivineSkill.cs` 关键位置: - `Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/SanaeDivineSkill.cs:386`:同盟单位先执行一次 `RecoverHealth_Legacy` - `Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/SanaeDivineSkill.cs:388`:判断 `unit.Grid(map)?.InMainSight()` - `Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/SanaeDivineSkill.cs:399-400`:不在本机视野时又执行一次 `RecoverHealth_Legacy` - `Unity/Assets/Scripts/TH1_Data/GridData.cs:519-522`:`InMainSight()` 依赖 `Main.MapData.PlayerMap.SelfPlayerData.Sight` 风险说明: 这不是“进局即不一致”的首要嫌疑,但它是明确的联机 desync 风险:同一个 action 在不同客户端的“本机视野”可能不同,而这里的真实回血次数会随本机视野分支变化。可见时回血一次,不可见时回血两次。 建议修复: - 逻辑层固定只回血一次。 - `InMainSight()` 只控制 VFX/Renderer/timer,不控制任何 `MapData` 数据变更。 - 搜索所有 `InMainSight()`、`mapData == Main.MapData`、`Timer` 分支,确认分支里没有真实 HP、技能、单位、城市、视野、资源变化。 ## F-006 P2:`SakuyaGuardSkill` 改为 `OnDamaged` 中嵌套 `DamageSettlement` 相关文件本周被修改:`Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/SakuyaGuardSkill.cs` 关键位置: - `Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/SakuyaGuardSkill.cs:49-55`:新增“双结算模型” - `Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/SakuyaGuardSkill.cs:54`:在受击生命周期内调用 `Main.UnitLogic.DamageSettlement(...)` - `Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/SakuyaGuardSkill.cs:55`:原目标 `DamageValue = 0` 风险说明: 这条目前看仍由 action 触发,且没有看到本机随机/时间直接影响数据。但嵌套 `DamageSettlement` 会触发更多技能、Moment、HeroTask、外交、死亡流程,复杂度高。若其中任一生命周期有本机视野/renderer/timer 条件控制真实数据,就会被放大。 建议修复: - 保证嵌套结算只在逻辑阶段同步执行,timer 里只做表现。 - 为 SakuyaGuard 场景加一次 host/client 双端 action hash 对照测试。 ## MemoryPack/序列化变更清单 | 类型 | 变更 | 风险 | | --- | --- | --- | | `MapConfig` | 新增 `IsResumeArchiveSelected` | UI 状态进入 `MapData` hash,建议忽略或移出 | | `MapConfig` | `EnsurePlayerSlots` 变为可读取本机 lobby | 反序列化后可产生不同 `PlayerCount/MultiCivs` | | `MatchSettlementType` | `Domination/Perfect/Creative` 合并为 `Normal`,新增 Normalize | 同版本通常确定;旧值迁移需保证只按纯数据执行 | | `MomentItemBase` | 新增 union 59-65 | 类型表扩展本身可行,但反序列化补项方式有风险 | | `MomentData` | `OnAfterMemoryPackDeserialize` 自动删/补 items | 会在客户端收到完整 MapData 后改权威数据 | | `LobbyReportMessage` | 新增 Steam 消息 union 18 | 不属于 MapData,非本次 hash 首要嫌疑 | ## 建议排查日志 为了最快确认 F-001/F-002,建议在三处临时打印同一组字段: - 房主 `GameNetSender.GameStart()` 序列化前 - 客户端 `OnReceivedGameStart` 刚收到后、`NetStartGame` 前 - 客户端 `ValidateNetworkStartMap`/`RefreshPlayerNet` 后、第一条 `ActionExecute` 前 字段: - `NetData.GetMapDataHash(map)` - `MapConfig.PlayerCount` - `MapConfig.IsResumeArchiveSelected` - `MultiCivs.Count` - 每个 slot 的 `Index/MemberId/PlayerId/CivId/ForceId/TeamId/IsAI/IsReady/IsCivFixed` - `Net.Players` - 每个玩家 `Id/PlayerCivId/PlayerForceId/Sight.Count` - 每个玩家 `MomentData.Items.Select(type/name/isExecute/executeCount/playerCultureValue)` 最快验证手段: 1. 临时让 `GetRequiredPlayerSlotCount` 只返回传入的 `targetCount`,不读 lobby。 2. 临时给 `IsResumeArchiveSelected` 加 `[MemoryPackIgnore]`。 3. 若进局不再立刻 hash mismatch,优先按 F-001/F-002 正式修。