From d79f1f951913ef5cd2e981131ea26201fc3a6f22 Mon Sep 17 00:00:00 2001 From: wuwenbo Date: Mon, 8 Jun 2026 16:55:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=83=AD=E4=BD=9C=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/skills/th1-action-logic/SKILL.md | 1 + .codex/skills/th1-game-archive/SKILL.md | 23 ++- .codex/skills/th1-network-sync/SKILL.md | 11 +- Unity/Assets/Scripts/TH1_Data/MapData.cs | 173 ++++++++++++++++-- .../Scripts/TH1_Logic/Core/GameLogic.cs | 9 +- .../Scripts/TH1_Logic/Player/PlayerLogic.cs | 2 +- .../View/Outside/UIOutsideMultiplayView.cs | 40 +++- 7 files changed, 230 insertions(+), 29 deletions(-) diff --git a/.codex/skills/th1-action-logic/SKILL.md b/.codex/skills/th1-action-logic/SKILL.md index 0f6cbc8f9..9fb8fa341 100644 --- a/.codex/skills/th1-action-logic/SKILL.md +++ b/.codex/skills/th1-action-logic/SKILL.md @@ -103,6 +103,7 @@ AI 的最终执行仍然走 action 层: 多人行为不要绕过 `CompleteExecute`: +- 判断本机是否能执行当前回合 action 时,优先使用 `MapData.CanLocalControlPlayer(playerId)`,不要只比较 `CurPlayer == PlayerMap.SelfPlayerData`。单机/房主可以控制多个真实 player 时,`SelfPlayerId` 会为旧 UI/input 临时切到当前本机可控玩家;非本机可控回合应恢复默认本机 player,避免资源、投降、存活等待等 UI 看错对象。 - 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 38ec66698..85cbd900d 100644 --- a/.codex/skills/th1-game-archive/SKILL.md +++ b/.codex/skills/th1-game-archive/SKILL.md @@ -44,6 +44,12 @@ 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: + +- `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. + ## Write Flow - New single-player game: write begin after map generation, before first turn refresh. @@ -51,12 +57,27 @@ Archive files are grouped by folder: - 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. +- Record delete: call `GameArchiveManager.DeleteGameRecord(record)` for any non-`Quick` record. This must only remove the record index, 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 + +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. + +Archive cleanup should cover both current and legacy local data: + +- current `Config/GameArchives/begin`, `quick_continue`, `continue`, and `end`; +- legacy `Config/map_archive_begin|continue|end[_multi]_{MapID}.dat` and sidecars; +- old `Config/begin`, `continue`, `end`, `begincontinue` / `begin_continue`, and `quick_continue` folders when they contain archive payloads. + ## Resume Flow Public resume should go through: diff --git a/.codex/skills/th1-network-sync/SKILL.md b/.codex/skills/th1-network-sync/SKILL.md index 0940b20c8..c440ae915 100644 --- a/.codex/skills/th1-network-sync/SKILL.md +++ b/.codex/skills/th1-network-sync/SKILL.md @@ -62,14 +62,18 @@ For the one-click Steam P2P stress tool, report fields, and current healthy base 6. Keep lobby `MapConfig` host-authoritative. - 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, and `TeamId == 0` means no team. + - 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()`. + - `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. - 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`. - Host must refresh lobby members before sending lobby config; new guests default not ready, and the owner is always ready. - Changing civ, team, AI slot ownership, slot assignment, or host room settings should clear guest ready state. + - UI room-row reconciliation must count host-controlled slots as occupied seats, not open seats, and must not auto-convert them between open and AI. - Host start/resume must require `AreAllLobbyMembersReady()`. - - `Net.RefreshPlayerNet(mapData)` maps real lobby `MemberId` values to the slot-created `PlayerId`; AI slots must not require a lobby member mapping. + - `Net.RefreshPlayerNet(mapData)` maps real lobby `MemberId` values to the slot-created `PlayerId`; AI and host-controlled slots must not require a lobby member mapping. - `TeamId` drives in-game teammate diplomacy, so host and clients must agree on the full slot list before `GameStart`. 7. Roll back failed start/resume. @@ -115,7 +119,8 @@ For network-heavy changes, inspect these risks explicitly: - Could a caller ignore a failed send and still mutate game state? - Could a client treat optimistic `MemberCiv` or ready state as authoritative before host `UpdateLobbyData`? - Could the host start/resume while any guest is not ready or current lobby members do not match `MapConfig.MultiCivs`? -- Could an empty non-AI slot (`MemberId == 0 && !IsAI`) start the game accidentally? +- Could an empty non-AI slot (`MemberId == 0 && !IsAI && !IsHostControlled`) start the game accidentally? +- Could a host-controlled slot be counted as an open UI seat, converted to AI/open by reconciliation, or inserted into `Net.Players`? - Could code reintroduce the old assumption that `MultiCivs.Count == current lobby member count`? - Could `MemberCiv.Index`, `PlayerId`, or `TeamId` diverge between host and clients before `GameStart`? - Could `MapData` deserialize with missing core fields and still be used? diff --git a/Unity/Assets/Scripts/TH1_Data/MapData.cs b/Unity/Assets/Scripts/TH1_Data/MapData.cs index 2a02615a2..6b9f5b6bb 100644 --- a/Unity/Assets/Scripts/TH1_Data/MapData.cs +++ b/Unity/Assets/Scripts/TH1_Data/MapData.cs @@ -224,7 +224,7 @@ namespace RuntimeData private static bool CanRemovePlayerSlot(MemberCiv slot) { - return slot == null || slot.MemberId == 0 && slot.IsAI; + return slot == null || slot.MemberId == 0 && slot.IsAI && !slot.IsHostControlled; } private MemberCiv CreateDefaultPlayerSlot(int index, NetMode netMode) @@ -239,7 +239,8 @@ namespace RuntimeData TeamId = NoTeamId, IsAI = true, IsReady = false, - IsCivFixed = false + IsCivFixed = false, + IsHostControlled = false }; return slot; @@ -251,6 +252,7 @@ namespace RuntimeData var selfSlot = MultiCivs[0]; PrepareSinglePlayerSlot(selfSlot, index: 0, isAI: false); + selfSlot.IsHostControlled = true; selfSlot.IsCivFixed = true; var usedCivs = new HashSet { selfSlot.CivId }; @@ -277,6 +279,7 @@ namespace RuntimeData slot.PlayerId = 0; slot.TeamId = NoTeamId; slot.IsAI = isAI; + slot.IsHostControlled = !isAI; } private static uint PickDefaultCivId(int preferredIndex, HashSet usedCivs) @@ -295,7 +298,12 @@ namespace RuntimeData { slot.Index = index; if (slot.TeamId < NoTeamId) slot.TeamId = NoTeamId; - if (slot.MemberId != 0) slot.IsAI = false; + if (slot.MemberId != 0) + { + slot.IsAI = false; + slot.IsHostControlled = false; + } + if (slot.IsAI) slot.IsHostControlled = false; if (slot.MemberId == 0) slot.PlayerId = 0; } @@ -303,7 +311,12 @@ namespace RuntimeData { slot.Index = index; if (slot.TeamId < NoTeamId) slot.TeamId = NoTeamId; - if (slot.MemberId != 0) slot.IsAI = false; + if (slot.MemberId != 0) + { + slot.IsAI = false; + slot.IsHostControlled = false; + } + if (slot.IsAI) slot.IsHostControlled = false; } // 根据房间成员信息更新 mapconfig 信息 @@ -336,6 +349,7 @@ namespace RuntimeData slot.MemberId = kv.Key; slot.PlayerId = 0; slot.IsAI = false; + slot.IsHostControlled = false; slot.IsReady = LobbyManager.Instance.Lobby.IsInLobby() && kv.Key == LobbyManager.Instance.Lobby.GetLobbyOwnerId(); changed = true; @@ -350,6 +364,7 @@ namespace RuntimeData foreach (var slot in MultiCivs) { if (slot == null || slot.MemberId != 0) continue; + if (slot.IsHostControlled) continue; return slot; } @@ -362,6 +377,7 @@ namespace RuntimeData slot.PlayerId = 0; slot.IsReady = false; slot.IsAI = makeAi; + slot.IsHostControlled = false; if (!clearCiv) return; slot.CivId = 0; slot.ForceId = 0; @@ -445,6 +461,31 @@ namespace RuntimeData return true; } + public bool SetPlayerSlotHostControlled(int index, bool isHostControlled, NetMode netMode = NetMode.Multi) + { + if (netMode == NetMode.Multi + && LobbyManager.Instance.Lobby.IsInLobby() + && !LobbyManager.Instance.Lobby.IsLobbyOwner()) + return false; + var slot = GetPlayerSlot(index, netMode); + if (slot == null) return false; + if (isHostControlled) + { + if (slot.MemberId != 0) return false; + if (slot.IsHostControlled && !slot.IsAI) return false; + slot.MemberId = 0; + slot.PlayerId = 0; + slot.IsAI = false; + slot.IsHostControlled = true; + slot.IsReady = false; + return true; + } + + if (!slot.IsHostControlled) return false; + slot.IsHostControlled = false; + return true; + } + public bool SetPlayerSlotCiv(int index, uint civId, uint forceId, NetMode netMode = NetMode.Multi) { var slot = GetPlayerSlot(index, netMode); @@ -493,12 +534,14 @@ namespace RuntimeData var current = GetMemberCiv(memberId); var target = MultiCivs[index]; if (target.MemberId != 0 && target.MemberId != memberId) return false; + if (target.IsHostControlled) return false; if (current == target) return false; if (current != null) ClearPlayerSlotMember(current, makeAi: true, clearCiv: true); target.MemberId = memberId; target.PlayerId = 0; target.IsAI = false; + target.IsHostControlled = false; target.IsReady = false; EnsureLobbyOwnerReady(); RefreshMultiCivsDict(); @@ -519,6 +562,30 @@ namespace RuntimeData return false; } + public bool IsHostControlledPlayer(uint playerId) + { + if (playerId == 0 || MultiCivs == null) return false; + foreach (var slot in MultiCivs) + { + if (slot == null || slot.PlayerId != playerId) continue; + return slot.IsHostControlled; + } + + return false; + } + + public bool IsRealPlayerSlot(uint playerId) + { + if (playerId == 0 || MultiCivs == null) return false; + foreach (var slot in MultiCivs) + { + if (slot == null || slot.PlayerId != playerId) continue; + return slot.MemberId != 0 || slot.IsHostControlled; + } + + return false; + } + public bool ArePlayersInSameTeam(uint playerIdA, uint playerIdB) { if (playerIdA == 0 || playerIdB == 0 || playerIdA == playerIdB) return false; @@ -615,6 +682,7 @@ namespace RuntimeData var targetSlot = GetPlayerSlot(index, NetMode.Multi); if (targetSlot == null) return false; if (targetSlot.MemberId != 0 && targetSlot.MemberId != selfMemberId) return false; + if (targetSlot.IsHostControlled) return false; var next = CopyMemberCiv(memberCiv); next.Index = index; @@ -657,6 +725,7 @@ namespace RuntimeData var targetSlot = GetPlayerSlot(index, NetMode.Multi); if (targetSlot == null) return false; if (targetSlot.MemberId != 0 && targetSlot.MemberId != selfMemberId) return false; + if (targetSlot.IsHostControlled) return false; var next = CopyMemberCiv(memberCiv); next.Index = index; @@ -680,7 +749,8 @@ namespace RuntimeData TeamId = memberCiv.TeamId, IsAI = memberCiv.IsAI, IsCivFixed = memberCiv.IsCivFixed, - IsReady = memberCiv.IsReady + IsReady = memberCiv.IsReady, + IsHostControlled = memberCiv.IsHostControlled }; } @@ -701,7 +771,8 @@ namespace RuntimeData TeamId = memberCiv.TeamId, IsAI = memberCiv.IsAI, IsCivFixed = memberCiv.IsCivFixed, - IsReady = memberId == ownerId || isReady + IsReady = memberId == ownerId || isReady, + IsHostControlled = memberCiv.IsHostControlled }; return UpdateMemberCiv(next); } @@ -738,7 +809,7 @@ namespace RuntimeData foreach (var slot in MultiCivs) { if (slot == null) return false; - if (slot.MemberId == 0 && !slot.IsAI) return false; + if (slot.MemberId == 0 && !slot.IsAI && !slot.IsHostControlled) return false; } var ownerId = LobbyManager.Instance.Lobby.GetLobbyOwnerId(); @@ -783,7 +854,7 @@ namespace RuntimeData var targetIndex = civ.Index; var targetSlot = targetIndex >= 0 && targetIndex < MultiCivs.Count ? MultiCivs[targetIndex] : null; - if (memberCiv != null && targetSlot != null && memberCiv != targetSlot && targetSlot.MemberId == 0) + if (memberCiv != null && targetSlot != null && memberCiv != targetSlot && targetSlot.MemberId == 0 && !targetSlot.IsHostControlled) { ClearPlayerSlotMember(memberCiv, makeAi: true, clearCiv: true); memberCiv = targetSlot; @@ -795,6 +866,7 @@ namespace RuntimeData var nextMemberId = civ.MemberId; var nextIsAI = nextMemberId == 0 && civ.IsAI; if (nextMemberId != 0) nextIsAI = false; + var nextIsHostControlled = nextMemberId == 0 && !nextIsAI && civ.IsHostControlled; if (memberCiv.MemberId == nextMemberId && memberCiv.CivId == civ.CivId @@ -803,7 +875,8 @@ namespace RuntimeData && memberCiv.TeamId == civ.TeamId && memberCiv.IsAI == nextIsAI && memberCiv.IsCivFixed == civ.IsCivFixed - && memberCiv.IsReady == civ.IsReady) return false; + && memberCiv.IsReady == civ.IsReady + && memberCiv.IsHostControlled == nextIsHostControlled) return false; memberCiv.MemberId = nextMemberId; memberCiv.CivId = civ.CivId; @@ -813,6 +886,7 @@ namespace RuntimeData memberCiv.IsAI = nextIsAI; memberCiv.IsCivFixed = civ.IsCivFixed; memberCiv.IsReady = civ.IsReady; + memberCiv.IsHostControlled = nextIsHostControlled; NormalizePlayerSlot(memberCiv, memberCiv.Index); RefreshMultiCivsDict(); return true; @@ -915,6 +989,7 @@ namespace RuntimeData public int TeamId; public bool IsAI; public bool IsCivFixed; + public bool IsHostControlled; } @@ -2381,7 +2456,7 @@ namespace RuntimeData LogSystem.LogError($"UpdateNextPlayer Error : nextPlayer is null!!!"); return; } - + if (Main.MapData.Net.Mode == NetMode.Spectator) { Main.PlayerLogic.StartPlayerTurn(this, nextPlayer.Id); @@ -2396,6 +2471,7 @@ namespace RuntimeData GameArchiveManager.Instance.SaveQuickContinueRecord(Main.MapData); AchievementDataManager.Instance.SaveAchievementData(); // 设置当前玩家 + SyncSelfPlayerToLocalControl(nextPlayer.Id); Main.PlayerLogic.StartPlayerTurn(this, nextPlayer.Id); } @@ -2559,6 +2635,8 @@ namespace RuntimeData public bool CheckIsRealPlayer(uint playerId) { + if (playerId == 0) return false; + if (MapConfig != null && MapConfig.IsRealPlayerSlot(playerId)) return true; if (Net.Mode == NetMode.Multi) { foreach (var kv in Net.Players) @@ -2573,16 +2651,77 @@ namespace RuntimeData public bool CheckIsAI(uint playerId) { - if (Net.Mode == NetMode.Multi) + return !CheckIsRealPlayer(playerId); + } + + public bool CanLocalControlPlayer(uint playerId) + { + if (playerId == 0) return false; + if (Net?.Mode == NetMode.Multi) { - foreach (var kv in Net.Players) - { - if (kv.Value == playerId) return false; - } + var lobby = LobbyManager.Instance.Lobby; + if (lobby == null || !lobby.IsInLobby()) return playerId == PlayerMap.SelfPlayerId; + var selfMemberId = lobby.GetSelfMemberId(); + if (Net.Players.TryGetValue(selfMemberId, out var selfPlayerId) && selfPlayerId == playerId) + return true; + return lobby.IsLobbyOwner() && (MapConfig?.IsHostControlledPlayer(playerId) ?? false); + } + + if (Net?.Mode == NetMode.Single) + return MapConfig?.IsRealPlayerSlot(playerId) ?? playerId == PlayerMap.SelfPlayerId; + + return playerId == PlayerMap.SelfPlayerId; + } + + public bool HasSurvivingLocalControlledPlayer() + { + if (PlayerMap?.PlayerDataList == null) return false; + foreach (var player in PlayerMap.PlayerDataList) + { + if (player == null || !player.IsSurvival) continue; + if (CanLocalControlPlayer(player.Id)) return true; + } + + return false; + } + + public bool SyncSelfPlayerToLocalControl(uint playerId) + { + if (!CanLocalControlPlayer(playerId)) return false; + if (PlayerMap == null || !PlayerMap.GetPlayerDataByPlayerID(playerId, out _)) return false; + if (PlayerMap.SelfPlayerId == playerId) return false; + PlayerMap.SelfPlayerId = playerId; + return true; + } + + public bool SyncSelfPlayerToDefaultLocalControl() + { + if (PlayerMap?.PlayerDataList == null) return false; + if (TryGetLocalControlledPlayerId(preferSurvival: true, out var playerId) + || TryGetLocalControlledPlayerId(preferSurvival: false, out playerId)) + { + if (PlayerMap.SelfPlayerId == playerId) return false; + PlayerMap.SelfPlayerId = playerId; return true; } - - return playerId != PlayerMap.SelfPlayerId; + + return false; + } + + private bool TryGetLocalControlledPlayerId(bool preferSurvival, out uint playerId) + { + playerId = 0; + if (PlayerMap?.PlayerDataList == null) return false; + foreach (var player in PlayerMap.PlayerDataList) + { + if (player == null) continue; + if (preferSurvival && !player.IsSurvival) continue; + if (!CanLocalControlPlayer(player.Id)) continue; + playerId = player.Id; + return true; + } + + return false; } // 用于使用指定地图的地图相关内容重生成 diff --git a/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs b/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs index aabe561e8..ed2986b6b 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs @@ -98,9 +98,12 @@ namespace Logic // 开始通用更新,主从客户端都走这一套 Main.MapData.RefreshTurn(); if (Main.MapData.CurPlayer == null) return; + var isLocalTurn = Main.MapData.CanLocalControlPlayer(Main.MapData.CurPlayer.Id); + if (isLocalTurn) Main.MapData.SyncSelfPlayerToLocalControl(Main.MapData.CurPlayer.Id); + else Main.MapData.SyncSelfPlayerToDefaultLocalControl(); if (_curState == GameState.Spectate) return; - else if (!Main.MapData.PlayerMap.SelfPlayerData.IsSurvival) ChangeState(GameState.DieWaiting); - else if (Main.MapData.CurPlayer.Id == Main.MapData.PlayerMap.SelfPlayerId) ChangeState(GameState.PlayerRound); + else if (!Main.MapData.HasSurvivingLocalControlledPlayer()) ChangeState(GameState.DieWaiting); + else if (isLocalTurn) ChangeState(GameState.PlayerRound); else ChangeState(GameState.OtherPlayerRound); UpdateAI(); UpdateConfirm(); @@ -179,7 +182,7 @@ namespace Logic private bool NeedAI() { - if (Main.MapData.CurPlayer == Main.MapData.PlayerMap.SelfPlayerData) return false; + if (Main.MapData.CanLocalControlPlayer(Main.MapData.CurPlayer.Id)) return false; var slot = Main.MapData.MapConfig?.MultiCivs?.Find(civ => civ != null && civ.PlayerId == Main.MapData.CurPlayer.Id); if (slot != null && slot.MemberId != 0) diff --git a/Unity/Assets/Scripts/TH1_Logic/Player/PlayerLogic.cs b/Unity/Assets/Scripts/TH1_Logic/Player/PlayerLogic.cs index 7048aff29..ffe4d4db0 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Player/PlayerLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Player/PlayerLogic.cs @@ -304,7 +304,7 @@ namespace Logic if ((map.Net.Mode == NetMode.Multi || map.Net.Mode == NetMode.Spectator) && map.Net.Players.ContainsValue(playerId)) return; - if (map.PlayerMap.SelfPlayerData.Id == playerId) return; + if (!map.CheckIsAI(playerId)) return; AIAddMoney(map, playerId); } diff --git a/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMultiplayView.cs b/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMultiplayView.cs index b30372cf8..77fa394e8 100644 --- a/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMultiplayView.cs +++ b/Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMultiplayView.cs @@ -151,6 +151,7 @@ namespace TH1_UI.View.Outside private enum RoomMemberRowType { Human, + HostControlled, Open, Line, AI @@ -472,8 +473,10 @@ namespace TH1_UI.View.Outside { _roomMemberRows.Clear(); var humanRows = new List(); + var hostControlledRows = new List(); var openRows = new List(); var emptyAiRows = new List(); + memberList.TryGetValue(_lobby.GetLobbyOwnerId(), out var ownerInfo); for (int i = 0; i < multiCivs.Count; i++) { @@ -487,6 +490,10 @@ namespace TH1_UI.View.Outside { emptyAiRows.Add(new RoomMemberRowData { Type = RoomMemberRowType.AI, MemberCiv = mc, SlotIndex = i }); } + else if (mc.MemberId == 0 && mc.IsHostControlled) + { + hostControlledRows.Add(new RoomMemberRowData { Type = RoomMemberRowType.HostControlled, MemberCiv = mc, MemberInfo = ownerInfo, SlotIndex = i }); + } else if (mc.MemberId == 0) { openRows.Add(new RoomMemberRowData { Type = RoomMemberRowType.Open, MemberCiv = mc, SlotIndex = i }); @@ -494,12 +501,14 @@ namespace TH1_UI.View.Outside } int totalPlayerCount = Mathf.Max(0, (int)Main.Instance.MapConfig.PlayerCount); - int roomSeatCount = Mathf.Clamp(_roomSeatCount, humanRows.Count, Mathf.Min(totalPlayerCount, MaxRoomSeatCount)); - _openMemberRowCount = Mathf.Max(0, roomSeatCount - humanRows.Count); + int occupiedSeatCount = humanRows.Count + hostControlledRows.Count; + int roomSeatCount = Mathf.Clamp(_roomSeatCount, occupiedSeatCount, Mathf.Min(totalPlayerCount, MaxRoomSeatCount)); + _openMemberRowCount = Mathf.Max(0, roomSeatCount - occupiedSeatCount); int aiCount = Mathf.Max(0, totalPlayerCount - roomSeatCount); bool showAiRows = IsTeamAndAIConfigEnabled(); _roomMemberRows.AddRange(humanRows); + _roomMemberRows.AddRange(hostControlledRows); for (int i = 0; i < _openMemberRowCount; i++) { if (i < openRows.Count) @@ -592,6 +601,24 @@ namespace TH1_UI.View.Outside showTeamControls); return; } + if (row.Type == RoomMemberRowType.HostControlled) + { + if (row.MemberInfo != null) + { + memberRow.SetHumanContent(row.MemberInfo, Table.Instance.TextDataAssets.MultiplayRoomOwnerTitle, civ, force, teamId, maxTeamId, _lobby, _lobby.IsLobbyOwner(), forceNameOverride, + direction => OnOpenRowForceChanged(row.SlotIndex, direction), + direction => OnOpenRowTeamChanged(row.SlotIndex, direction), + showTeamControls); + } + else + { + memberRow.SetOpenContent(civ, force, teamId, maxTeamId, _lobby.IsLobbyOwner(), forceNameOverride, + direction => OnOpenRowForceChanged(row.SlotIndex, direction), + direction => OnOpenRowTeamChanged(row.SlotIndex, direction), + showTeamControls); + } + return; + } var status = GetHumanStatus(mc); bool canEditHuman = mc.MemberId == _lobby.GetSelfMemberId(); @@ -815,7 +842,12 @@ namespace TH1_UI.View.Outside _roomSeatCount = Mathf.Clamp(_roomSeatCount, _lobby.GetMemberCount(), maxRoomSeatCount); int lobbyMemberLimit = _lobby.GetMemberLimit(); if (lobbyMemberLimit > 0) _roomSeatCount = Mathf.Clamp(lobbyMemberLimit, _lobby.GetMemberCount(), maxRoomSeatCount); - int openSlotBudget = Mathf.Max(0, _roomSeatCount - _lobby.GetMemberCount()); + int hostControlledSeatCount = 0; + foreach (var mc in multiCivs) + { + if (mc != null && mc.MemberId == 0 && mc.IsHostControlled) hostControlledSeatCount++; + } + int openSlotBudget = Mathf.Max(0, _roomSeatCount - _lobby.GetMemberCount() - hostControlledSeatCount); bool canReconcileOpenSlots = _lobby.IsLobbyOwner(); bool slotLayoutChanged = false; var usedCivs = new HashSet(); @@ -825,7 +857,7 @@ namespace TH1_UI.View.Outside { var mc = multiCivs[i]; if (mc == null) continue; - if (canReconcileOpenSlots && mc.MemberId == 0) + if (canReconcileOpenSlots && mc.MemberId == 0 && !mc.IsHostControlled) { bool shouldBeOpenSlot = openSlotBudget > 0; if (mc.IsAI == shouldBeOpenSlot)