diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 32233940b..40591a65f 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -218,6 +218,8 @@ public void ExposeData() // nevertheless still left. public int DecreasePlayerCount() => CurrentPlayerCount = Math.Max(0, CurrentPlayerCount - 1); + public void SetCurrentPlayerCount(int count) => CurrentPlayerCount = Math.Max(0, count); + public void FinalizeInit() { cmds = new Queue( diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 8184163ab..6c3ea5ab2 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -61,6 +61,8 @@ public TimeSpeed DesiredTimeSpeed public int CurrentPlayerCount { get; private set; } public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; + public void SetCurrentPlayerCount(int count) => CurrentPlayerCount = Math.Max(0, count); + public int TickableId => -1; public World world; @@ -295,6 +297,12 @@ private static void CreateJoinPointAndSendIfHost() // Hosted: only host/arbiter uploads world data SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true); } + + if (!Multiplayer.IsReplay) + { + Patches.VTRSync.ReportCurrentViewedMap(); + Patches.VTRSync.RequestPlayerCountsSync(); + } } } diff --git a/Source/Client/Networking/HostUtil.cs b/Source/Client/Networking/HostUtil.cs index 0b736217e..8a9116d18 100644 --- a/Source/Client/Networking/HostUtil.cs +++ b/Source/Client/Networking/HostUtil.cs @@ -46,6 +46,9 @@ public static async ClientTask HostServer(ServerSettings settings, bool fromRepl SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, false); StartLocalServer(); + + Patches.VTRSync.ReportCurrentViewedMap(); + Patches.VTRSync.RequestPlayerCountsSync(); } private static void CreateSession(ServerSettings settings) => diff --git a/Source/Client/Networking/State/ClientLoadingState.cs b/Source/Client/Networking/State/ClientLoadingState.cs index 2622c9611..2066a1924 100644 --- a/Source/Client/Networking/State/ClientLoadingState.cs +++ b/Source/Client/Networking/State/ClientLoadingState.cs @@ -145,5 +145,10 @@ public void HandleWorldData(ByteReader data) var loadingMs = watch.ElapsedMilliseconds; Log.Message($"Loaded game in {loadingMs}ms"); connection.ChangeState(ConnectionStateEnum.ClientPlaying); + if (!Multiplayer.IsReplay) + { + OnMainThread.Enqueue(Patches.VTRSync.ReportCurrentViewedMap); + OnMainThread.Enqueue(Patches.VTRSync.RequestPlayerCountsSync); + } } } diff --git a/Source/Client/Networking/State/ClientPlayingState.cs b/Source/Client/Networking/State/ClientPlayingState.cs index 6a96635a5..3c929a9be 100644 --- a/Source/Client/Networking/State/ClientPlayingState.cs +++ b/Source/Client/Networking/State/ClientPlayingState.cs @@ -220,6 +220,24 @@ public void HandleSetFaction(ServerSetFactionPacket packet) Session.myFactionId = factionId; } } + + [TypedPacketHandler] + public void HandlePlayerCounts(ServerPlayerCountsPacket packet) + { + var countById = new Dictionary(); + int len = Math.Min(packet.mapIds?.Length ?? 0, packet.counts?.Length ?? 0); + for (int i = 0; i < len; i++) + countById[packet.mapIds[i]] = packet.counts[i]; + + foreach (var map in Find.Maps) + { + if (countById.TryGetValue(map.uniqueID, out int count)) + map.AsyncTime().SetCurrentPlayerCount(count); + } + + if (countById.TryGetValue(Patches.VTRSync.WorldMapId, out int worldCount)) + Multiplayer.AsyncWorldTime.SetCurrentPlayerCount(worldCount); + } } } diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index eb5812a86..f60d892c9 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -2,6 +2,7 @@ using HarmonyLib; using Multiplayer.Client.Util; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using RimWorld.Planet; using Verse; @@ -48,7 +49,7 @@ static bool Prefix(ref int __result, WorldObject __instance) } } - static class VTRSync + public static class VTRSync { // Special identifier for the world map (since it doesn't have a uniqueID like regular maps) public const int WorldMapId = -2; @@ -75,6 +76,48 @@ public static void SendViewedMapUpdate(int previous, int current) MpLog.Debug($"VTR MapSwitchPatch: {lastMovedToMapId}->{current} @ tick {currentTick}{warn}"); Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(previous, current)); lastMovedToMapId = current; + + ReportViewedMap(current); + } + + public static void ReportViewedMap(int mapId) + { + var client = Multiplayer.Client; + if (client == null || Multiplayer.IsReplay) + return; + + if (client.State != ConnectionStateEnum.ClientPlaying) + return; + + int currentTick = Find.TickManager?.TicksGame ?? 0; + MpLog.Debug($"VTR report: map={mapId} @ tick {currentTick}"); + lastMovedToMapId = mapId; + client.Send(new ClientViewedMapReportPacket { mapId = mapId }); + } + + public static void ReportCurrentViewedMap() + { + if (Multiplayer.Client == null || Multiplayer.IsReplay) + return; + + int current = WorldRendererUtility.CurrentWorldRenderMode == WorldRenderMode.Planet + ? WorldMapId + : Find.CurrentMap?.uniqueID ?? InvalidMapId; + + ReportViewedMap(current); + } + + public static void RequestPlayerCountsSync() + { + var client = Multiplayer.Client; + if (client == null || Multiplayer.IsReplay) + return; + + if (client.State != ConnectionStateEnum.ClientPlaying) + return; + + MpLog.Debug($"VTR resync request @ tick {Find.TickManager?.TicksGame ?? 0}"); + client.Send(new ClientRequestPlayerCountsPacket()); } public static void Reset() diff --git a/Source/Client/Saving/Loader.cs b/Source/Client/Saving/Loader.cs index 74e5c0085..5a8307509 100644 --- a/Source/Client/Saving/Loader.cs +++ b/Source/Client/Saving/Loader.cs @@ -78,6 +78,12 @@ private static void PostLoad() Multiplayer.AsyncWorldTime.cmds = new Queue( Multiplayer.session.dataSnapshot.MapCmds.GetValueSafe(ScheduledCommand.Global) ?? []); // Map cmds are added in MapAsyncTimeComp.FinalizeInit + + if (!Multiplayer.IsReplay && Multiplayer.Client is ClientPlayingState) + { + OnMainThread.Enqueue(Patches.VTRSync.ReportCurrentViewedMap); + OnMainThread.Enqueue(Patches.VTRSync.RequestPlayerCountsSync); + } } private static XmlDocument DataSnapshotToXml(GameDataSnapshot dataSnapshot, List mapsToLoad) diff --git a/Source/Common/Networking/Packet/ViewedMapPackets.cs b/Source/Common/Networking/Packet/ViewedMapPackets.cs new file mode 100644 index 000000000..a635ee606 --- /dev/null +++ b/Source/Common/Networking/Packet/ViewedMapPackets.cs @@ -0,0 +1,33 @@ +namespace Multiplayer.Common.Networking.Packet; + +[PacketDefinition(Packets.Client_ViewedMapReport)] +public record struct ClientViewedMapReportPacket : IPacket +{ + public int mapId; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref mapId); + } +} + +[PacketDefinition(Packets.Client_RequestPlayerCounts)] +public record struct ClientRequestPlayerCountsPacket : IPacket +{ + public void Bind(PacketBuffer buf) + { + } +} + +[PacketDefinition(Packets.Server_PlayerCounts)] +public record struct ServerPlayerCountsPacket : IPacket +{ + public int[] mapIds; + public int[] counts; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref mapIds, BinderOf.Int()); + buf.Bind(ref counts, BinderOf.Int()); + } +} diff --git a/Source/Common/Networking/Packets.cs b/Source/Common/Networking/Packets.cs index 0d3cd3a69..41a66b421 100644 --- a/Source/Common/Networking/Packets.cs +++ b/Source/Common/Networking/Packets.cs @@ -36,6 +36,8 @@ public enum Packets : byte Client_FrameTime, Client_StandaloneWorldSnapshotUpload, Client_StandaloneMapSnapshotUpload, + Client_ViewedMapReport, + Client_RequestPlayerCounts, // Joining Server_ProtocolOk, @@ -65,6 +67,8 @@ public enum Packets : byte Server_SetFaction, Server_RequestRejoin, + Server_PlayerCounts, + // All states (Joining, Loading, Playing) Server_Disconnect, diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index f7b21e7a7..1606677d0 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -58,6 +58,38 @@ public void HandleClientCommand(ClientCommandPacket packet) Server.SendMapResponse(Player, currentMapId); } + [TypedPacketHandler] + public void HandleViewedMapReport(ClientViewedMapReportPacket packet) + { + Player.currentMapId = packet.mapId; + Player.hasReportedCurrentMap = true; + } + + [TypedPacketHandler] + public void HandleRequestPlayerCounts(ClientRequestPlayerCountsPacket packet) + { + var countsByMap = new Dictionary(); + foreach (var player in Server.PlayingPlayers) + { + if (player.currentMapId == -1) + continue; + countsByMap.TryGetValue(player.currentMapId, out int count); + countsByMap[player.currentMapId] = count + 1; + } + + var mapIds = new int[countsByMap.Count]; + var counts = new int[countsByMap.Count]; + int i = 0; + foreach (var kv in countsByMap) + { + mapIds[i] = kv.Key; + counts[i] = kv.Value; + i++; + } + + Player.SendPacket(new ServerPlayerCountsPacket { mapIds = mapIds, counts = counts }); + } + public const int MaxChatMsgLength = 128; [TypedPacketHandler] diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index 9e8b83277..e6ac62012 100644 --- a/Source/Common/Version.cs +++ b/Source/Common/Version.cs @@ -6,7 +6,7 @@ namespace Multiplayer.Common public static class MpVersion { public const string SimpleVersion = "0.11.5"; - public const int Protocol = 56; + public const int Protocol = 57; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() diff --git a/Source/Tests/ViewedMapSyncTest.cs b/Source/Tests/ViewedMapSyncTest.cs new file mode 100644 index 000000000..5a211a488 --- /dev/null +++ b/Source/Tests/ViewedMapSyncTest.cs @@ -0,0 +1,111 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[TestFixture] +public class ViewedMapSyncTest +{ + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + ServerLog.error = (msg) => TestContext.Error.WriteLine(msg); + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + nextPlayerId = 1; + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private (ServerPlayer player, RecordingConnection conn) AddPlayer(string username, int currentMapId, + bool hasReportedCurrentMap = true) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn) + { + currentMapId = currentMapId, + hasReportedCurrentMap = hasReportedCurrentMap, + }; + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return (player, conn); + } + + private ServerPlayingState PlayingState(ServerPlayer player) => + player.conn.GetState()!; + + [Test] + public void ViewedMapReport_SetsMapAbsolutely_AndIsIdempotent() + { + var (player, conn) = AddPlayer("player", -1, hasReportedCurrentMap: false); + + PlayingState(player).HandleViewedMapReport(new ClientViewedMapReportPacket { mapId = 7 }); + Assert.That(player.currentMapId, Is.EqualTo(7)); + Assert.That(player.hasReportedCurrentMap, Is.True); + + PlayingState(player).HandleViewedMapReport(new ClientViewedMapReportPacket { mapId = 7 }); + Assert.That(player.currentMapId, Is.EqualTo(7)); + } + + [Test] + public void ViewedMapReport_DoesNotGenerateAnyPacket() + { + server.worldData.mapData[3] = [1, 2, 3]; + var (player, conn) = AddPlayer("player", 3); + + PlayingState(player).HandleViewedMapReport(new ClientViewedMapReportPacket { mapId = 4 }); + + Assert.That(conn.SentPackets, Is.Empty); + } + + [Test] + public void RequestPlayerCounts_AggregatesMapsAndWorld_IgnoresUnreported() + { + var (p1a, _) = AddPlayer("a", 1); + var (p1b, _) = AddPlayer("b", 1); + var (pw, _) = AddPlayer("w", VTRSyncConstants.WorldMapId); + var (pn, _) = AddPlayer("n", -1, hasReportedCurrentMap: false); + + var reqConn = new RecordingConnection("req"); + var reqPlayer = new ServerPlayer(999, reqConn) + { + currentMapId = 1, + hasReportedCurrentMap = true, + }; + reqConn.serverPlayer = reqPlayer; + reqConn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(reqPlayer); + + PlayingState(reqPlayer).HandleRequestPlayerCounts(new ClientRequestPlayerCountsPacket()); + + Assert.That(reqConn.SentPackets, Does.Contain(Packets.Server_PlayerCounts)); + } + + [Test] + public void RequestPlayerCounts_DoesNotBroadcastToOtherPlayers() + { + var (other, otherConn) = AddPlayer("other", 1); + var (reqPlayer, _) = AddPlayer("req", 1); + + PlayingState(reqPlayer).HandleRequestPlayerCounts(new ClientRequestPlayerCountsPacket()); + + Assert.That(otherConn.SentPackets, Does.Not.Contain(Packets.Server_PlayerCounts)); + } +} + +internal static class VTRSyncConstants +{ + public const int WorldMapId = -2; +}