diff --git a/.github/workflows/alpha-notes.md b/.github/workflows/alpha-notes.md index a75332c6e..f8ec5c59f 100644 --- a/.github/workflows/alpha-notes.md +++ b/.github/workflows/alpha-notes.md @@ -12,4 +12,24 @@ - Linux: `~/.steam/steam/steamapps/common/RimWorld/Mods` 3. Extract the zip file into the `Mods` folder. * You should have a `Multiplayer` folder in the `Mods` folder (`Mods/Multiplayer`) - * Make sure you do not have this directory structure: `Mods/Multiplayer-beta/Multiplayer`. If you do, move the `Multiplayer` folder to the parent directory. \ No newline at end of file + * Make sure you do not have this directory structure: `Mods/Multiplayer-beta/Multiplayer`. If you do, move the `Multiplayer` folder to the parent directory. + +#### Standalone server + +Download `Server-beta.zip` if you want to host a dedicated standalone server for testing. + +**Setup** +1. Download and extract `Server-beta.zip`. +2. Open the folder for your platform: + - `Server/Windows` + - `Server/Linux` +3. Start the server using: + - Windows: `Server.exe` + - Linux: `./Server.sh` +4. The server will start and wait for the first connection. + +**First-time configuration (bootstrap)** +No manual configuration files are required. +The **first player to connect** via the Multiplayer mod client will be prompted to perform the initial setup: +they can configure the game world, scenario, and other options directly from within RimWorld. +Subsequent players connect to the already-running session. \ No newline at end of file diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index 1f940ae1a..d4e2db162 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -34,6 +34,23 @@ jobs: - name: Build Mod run: dotnet build ${{ env.SLN_PATH }} --configuration Release --no-restore + - name: Publish Server (Windows) + run: dotnet publish ${{ env.SLN_PATH }}Server/Server.csproj --configuration Release --runtime win-x64 --self-contained false -p:UseAppHost=true -o output/Server/Windows + + - name: Publish Server (Linux) + run: dotnet publish ${{ env.SLN_PATH }}Server/Server.csproj --configuration Release --runtime linux-x64 --self-contained false -p:UseAppHost=true -o output/Server/Linux + + - name: Add Server Launchers + run: | + cat > output/Server/Linux/Server.sh <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + exec dotnet "$SCRIPT_DIR/Server.dll" "$@" + EOF + + chmod +x output/Server/Linux/Server.sh + - name: Package files run: | sed -i "s/\(.*\)<\/name>\$/\1 [Continuous]<\/name>/" About/About.xml @@ -41,6 +58,7 @@ jobs: mv About/ Assemblies/ AssembliesCustom/ Defs/ Languages/ Textures/ output/Multiplayer cd output/ zip -r ../Multiplayer-beta.zip Multiplayer/ + zip -r ../Server-beta.zip Server/ cd ../ - name: Upload Mod Artifacts @@ -57,5 +75,5 @@ jobs: - name: Upload new release run: | gh release create --target "${{ github.sha }}" --title "Continuous" --notes-file ".github/workflows/alpha-notes.md" --draft "continuous" - gh release upload "continuous" Multiplayer-beta.zip + gh release upload "continuous" Multiplayer-beta.zip Server-beta.zip gh release edit "continuous" --draft=false diff --git a/.gitignore b/.gitignore index 42c5123e7..066bc4f10 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ ipch/ *.opensdf *.sdf *.cachefile +*.lscache *.VC.db *.VC.VC.opendb diff --git a/Languages b/Languages index 8bfffb77a..0a6b746ca 160000 --- a/Languages +++ b/Languages @@ -1 +1 @@ -Subproject commit 8bfffb77a14dbe77c4240fad7291ed2d32dc3572 +Subproject commit 0a6b746ca9b2d05a0cc5770f5348ec077f980deb diff --git a/Source/ChatCommandContracts/ChatCommandAttributes.cs b/Source/ChatCommandContracts/ChatCommandAttributes.cs new file mode 100644 index 000000000..f8bb4072f --- /dev/null +++ b/Source/ChatCommandContracts/ChatCommandAttributes.cs @@ -0,0 +1,23 @@ +using System; + +namespace Multiplayer.Common; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ChatCommandAttribute(string name, params string[] aliases) : Attribute +{ + public string Name { get; } = name; + public string[] Aliases { get; } = aliases; + public string Usage { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public bool RequiresHost { get; set; } +} + +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field)] +public sealed class ChatArgumentAttribute(string name) : Attribute +{ + public string Name { get; } = name; + public string Description { get; set; } = string.Empty; +} + +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field)] +public sealed class ChatRestAttribute : Attribute; diff --git a/Source/ChatCommandContracts/ChatCommandContracts.csproj b/Source/ChatCommandContracts/ChatCommandContracts.csproj new file mode 100644 index 000000000..51f41f762 --- /dev/null +++ b/Source/ChatCommandContracts/ChatCommandContracts.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.0 + enable + 12 + Multiplayer.Common + MultiplayerChatCommandContracts + + + diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 7db59bb68..8184163ab 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -216,6 +216,9 @@ public void ExecuteCmd(ScheduledCommand cmd) if (cmdType == CommandType.CreateJoinPoint) { + if (Multiplayer.session?.ConnectedToStandaloneServer == true && !TickPatch.currentExecutingCmdIssuedBySelf) + return; + LongEventHandler.QueueLongEvent(CreateJoinPointAndSendIfHost, "MpCreatingJoinPoint", false, null); } @@ -273,11 +276,26 @@ public void ExecuteCmd(ScheduledCommand cmd) private static void CreateJoinPointAndSendIfHost() { - Multiplayer.session.dataSnapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveAndReload(), Multiplayer.GameComp.multifaction); + Multiplayer.session.dataSnapshot = SaveLoad.SaveReloadAndCreateSnapshot( + Multiplayer.GameComp.multifaction, + ReloadOptimizationMode.ForJoinPointSnapshot + ); - if (!TickPatch.Simulating && !Multiplayer.IsReplay && - (Multiplayer.LocalServer != null || Multiplayer.arbiterInstance)) - SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true); + if (!TickPatch.Simulating && !Multiplayer.IsReplay) + { + if (Multiplayer.session?.ConnectedToStandaloneServer == true) + { + // Standalone: every client uploads world data + individual snapshots + SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true); + SaveLoad.SendStandaloneMapSnapshots(Multiplayer.session.dataSnapshot); + SaveLoad.SendStandaloneWorldSnapshot(Multiplayer.session.dataSnapshot); + } + else if (Multiplayer.LocalServer != null || Multiplayer.arbiterInstance) + { + // Hosted: only host/arbiter uploads world data + SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true); + } + } } public void SetTimeEverywhere(TimeSpeed speed) diff --git a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs index 883980f39..e9332b7fb 100644 --- a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs +++ b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs @@ -250,7 +250,7 @@ public static void TickMapQuests(AsyncTimeComp mapAsyncTimeComp) /// Quests to run QuestTick() on private static void TickQuests(IEnumerable quests) { - foreach (var quest in quests) + foreach (var quest in quests.ToList()) { quest.QuestTick(); } diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index 7d21a4af5..f8068341e 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -131,6 +131,15 @@ static IEnumerable Postfix(IEnumerable session.autosaveInterval * TicksPerMinute) + { + session.autosaveCounter = 0; + Autosaving.DoAutosave(); + } + } + else if (session.autosaveUnit == AutosaveUnit.Days) + { + var anyMapCounterUp = + Multiplayer.game.mapComps + .Any(m => m.autosaveCounter > session.autosaveInterval * TicksPerIngameDay); + + if (anyMapCounterUp) + { + Multiplayer.game.mapComps.Do(m => m.autosaveCounter = 0); + Autosaving.DoAutosave(); + } + } + return; + } + if (Multiplayer.LocalServer is not { } server) return; if (server.settings.autosaveUnit == AutosaveUnit.Minutes) @@ -60,7 +94,8 @@ private static void TickAutosave() session.autosaveCounter = 0; Autosaving.DoAutosave(); } - } else if (server.settings.autosaveUnit == AutosaveUnit.Days && server.settings.autosaveInterval > 0) + } + else if (server.settings.autosaveUnit == AutosaveUnit.Days && server.settings.autosaveInterval > 0) { var anyMapCounterUp = Multiplayer.game.mapComps diff --git a/Source/Client/Debug/DebugActions.cs b/Source/Client/Debug/DebugActions.cs index 9bc4f5121..46d05147b 100644 --- a/Source/Client/Debug/DebugActions.cs +++ b/Source/Client/Debug/DebugActions.cs @@ -373,6 +373,13 @@ public static void DumpAddrInfoTable() } } + [DebugAction(MultiplayerLocalCategory, "Show mod compat", allowedGameStates = AllowedGameStates.Playing)] + public static void ShowModCompatDialog() + { + var window = new ModCompatWindow(null, true, false, null); + Find.WindowStack.Add(window); + } + #if DEBUG [DebugOutput] diff --git a/Source/Client/Desyncs/DeferredStackTracing.cs b/Source/Client/Desyncs/DeferredStackTracing.cs index 1f4b15030..db53da2df 100644 --- a/Source/Client/Desyncs/DeferredStackTracing.cs +++ b/Source/Client/Desyncs/DeferredStackTracing.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Reflection; +using System.Runtime.InteropServices; using HarmonyLib; using Multiplayer.Client.Patches; using Multiplayer.Common; @@ -25,6 +26,9 @@ static IEnumerable TargetMethods() public static int acc; + private static bool SupportsDeferredStackTracing => + RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86; + public static void Postfix() { if (Native.LmfPtr == 0) return; @@ -43,6 +47,7 @@ public static void Postfix() public static bool ShouldAddStackTraceForDesyncLog() { + if (!SupportsDeferredStackTracing) return false; if (Multiplayer.Client == null) return false; if (Multiplayer.settings.desyncTracingMode == DesyncTracingMode.None) return false; if (Multiplayer.game == null) return false; diff --git a/Source/Client/Factions/Blueprints.cs b/Source/Client/Factions/Blueprints.cs index ca5c32714..9a5920cfe 100644 --- a/Source/Client/Factions/Blueprints.cs +++ b/Source/Client/Factions/Blueprints.cs @@ -15,7 +15,7 @@ namespace Multiplayer.Client // Don't draw other factions' blueprints // Don't link graphics of different factions' blueprints - [HarmonyPatch(typeof(GenConstruct), nameof(GenConstruct.CanPlaceBlueprintAt))] + [HarmonyPatch(typeof(GenConstruct), nameof(GenConstruct.CanPlaceBlueprintAt_NewTemp))] static class CanPlaceBlueprintAtPatch { static MethodInfo CanPlaceBlueprintOver = AccessTools.Method(typeof(GenConstruct), nameof(GenConstruct.CanPlaceBlueprintOver)); @@ -48,7 +48,7 @@ static IEnumerable Transpiler(IEnumerable e, M static bool ShouldIgnore1(Thing oldThing) => oldThing.def.IsBlueprint && oldThing.Faction != Faction.OfPlayer; } - [HarmonyPatch(typeof(GenConstruct), nameof(GenConstruct.CanPlaceBlueprintAt))] + [HarmonyPatch(typeof(GenConstruct), nameof(GenConstruct.CanPlaceBlueprintAt_NewTemp))] static class CanPlaceBlueprintAtPatch2 { static IEnumerable Transpiler(IEnumerable e, MethodBase original) diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index c0ddb767a..c26ea721e 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -30,6 +30,15 @@ public static void SendPawn(int playerId, Pawn p) pawnStore.GetOrAddNew(playerId).Add(p); } + [SyncMethod] + public static void ChangeFactionColor(Faction faction, Color color) + { + if (faction is not { IsPlayer: true }) + return; + + faction.color = color; + } + [SyncMethod] public static void CreateFaction(int playerId, FactionCreationData creationData) { @@ -286,4 +295,3 @@ public record FactionCreationData : ISyncSimple public List startingPossessions; public bool setupNextMapFromTickZero; } - diff --git a/Source/Client/Factions/FactionsWindow.cs b/Source/Client/Factions/FactionsWindow.cs index 6145e32ee..436cae4b1 100644 --- a/Source/Client/Factions/FactionsWindow.cs +++ b/Source/Client/Factions/FactionsWindow.cs @@ -51,6 +51,21 @@ void DrawFactionInLastRect(Faction faction) using (MpStyle.Set(GameFont.Medium)) Layouter.Label(faction.Name); + + var colorRect = Layouter.Rect(20f, 20f); + Widgets.DrawBoxSolid(colorRect.ContractedBy(2f), faction.Color); + Widgets.DrawBox(colorRect); + Widgets.DrawHighlightIfMouseover(colorRect); + + if (Widgets.ButtonInvisible(colorRect)) + { + Find.WindowStack.Add(new Dialog_ChooseFactionColor(color => + { + FactionCreator.ChangeFactionColor(faction, color); + }, faction.Color)); + } + + TooltipHandler.TipRegion(colorRect, "MpChangeFactionColor".Translate()); } Layouter.EndHorizontal(); diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index 3e70605c0..9eea55518 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -1,4 +1,5 @@ using HarmonyLib; +using KTrie; using Multiplayer.API; using Multiplayer.Client.Factions; using RimWorld; @@ -8,6 +9,7 @@ using System.Linq; using System.Reflection; using System.Reflection.Emit; +using System.Runtime.Remoting.Messaging; using UnityEngine; using Verse; using Verse.AI; @@ -29,7 +31,7 @@ public static void Prefix(ref Rect rect, Quest quest) Rect iconRect = new Rect(rect.x + 2f, rect.y + 2f, 4f, rect.height - 4f); Widgets.DrawBoxSolid(iconRect, playerFaction.Color); rect.xMin += 8f; - TooltipHandler.TipRegion(rect,"MpQuestDesc".Translate(playerFaction.Name, playerFaction == Faction.OfPlayer ? ". (you)" : ".")); + TooltipHandler.TipRegion(rect, "MpQuestDesc".Translate(playerFaction.Name, playerFaction == Faction.OfPlayer ? ". (you)" : ".")); } else { @@ -299,7 +301,7 @@ static IEnumerable Postfix(IEnumerable gizmos, Pawn __instance) if (Multiplayer.Client == null || Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) yield break; - if (__instance.Faction is { IsPlayer: true } &&__instance.Faction != Faction.OfPlayer) + if (__instance.Faction is { IsPlayer: true } && __instance.Faction != Faction.OfPlayer) { var otherFaction = __instance.Faction; @@ -616,10 +618,14 @@ static void Postfix(IAttackTarget target, ref bool __result) } } -[HarmonyPatch(typeof(JobDriver_TakeToBed), nameof(JobDriver_TakeToBed.CheckMakeTakeeGuest))] -[HarmonyPatch(typeof(JobDriver_CarryDownedPawn), nameof(JobDriver_CarryDownedPawn.CheckMakeTakeeGuest))] +[HarmonyPatch] static class TakeToBedGuestFactionPatch { + static IEnumerable TargetMethods() + { + yield return AccessTools.DeclaredMethod(typeof(JobDriver_TakeToBed), nameof(JobDriver_TakeToBed.CheckMakeTakeeGuest)); + yield return AccessTools.DeclaredMethod(typeof(JobDriver_CarryDownedPawn), nameof(JobDriver_CarryDownedPawn.CheckMakeTakeeGuest)); + } static bool Prefix(JobDriver __instance) { var takee = __instance.job.GetTarget(TargetIndex.A).Pawn; @@ -627,6 +633,7 @@ static bool Prefix(JobDriver __instance) } } + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] static class LetterStackReceiveOnlyMyFaction { @@ -753,7 +760,7 @@ private static bool DontDrawIdeoPlate(bool generating) } } -[HarmonyPatch(typeof(CompShuttle), "ContainedColonistCount", MethodType.Getter)] +[HarmonyPatch(typeof(CompShuttle), nameof(CompShuttle.ContainedColonistCount), MethodType.Getter)] static class CompShuttle_ContainedColonistCount_Patch { static IEnumerable Transpiler(IEnumerable insts) @@ -782,7 +789,7 @@ public static bool IsFreeColonistAnyPlayerFaction(Pawn pawn) pawn.RaceProps.Humanlike && (!pawn.IsSlave || pawn.guest.SlaveIsSecure) && !pawn.IsSubhuman && - pawn.HostFaction == null; + pawn.HostFaction == null; } } @@ -823,14 +830,14 @@ static void Prefix(Gravship gravship, Map map, out bool __state) __state = true; } - static void Finalizer(Gravship gravship, Map map,bool __state) + static void Finalizer(Gravship gravship, Map map, bool __state) { if (!__state) return; map.PopFaction(); } } -[HarmonyPatch(typeof(QuestPart_LendColonistsToFaction), "Enable")] +[HarmonyPatch(typeof(QuestPart_LendColonistsToFaction), nameof(QuestPart_LendColonistsToFaction.Enable))] static class QuestPart_LendColonistsToFaction_Enable_Patch { static IEnumerable Transpiler(IEnumerable insts) @@ -849,3 +856,104 @@ static IEnumerable Transpiler(IEnumerable inst } } } + +[HarmonyPatch(typeof(Map), nameof(Map.IsPlayerHome), MethodType.Getter)] +static class Map_IsPlayerHome_Spectator_Patch +{ + static bool Prefix(Map __instance, ref bool __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || + Faction.OfPlayer != Multiplayer.WorldComp.spectatorFaction) + return true; + + { + // Is repeat through all player faction a better idea? + if (!__instance.wasSpawnedViaGravShipLanding) + { + MapInfo mapInfo = __instance.info; + if (((mapInfo != null) ? mapInfo.parent : null) == null || __instance.info.parent.Faction?.IsPlayer != true || !__instance.info.parent.def.canBePlayerHome) + { + __result = GravshipUtility.PlayerHasGravEngine(__instance); + return false; + } + } + __result = true; + return false; + } + } +} + +[HarmonyPatch] +static class Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch +{ + static MethodInfo TargetMethod() + { + return AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(Pawn_TraderTracker), nameof(Pawn_TraderTracker.ColonyThingsWillingToBuy))); + } + public static List AllBuildingsColonistOfDefOfPlayer(this ListerBuildings lister, ThingDef def) + { + if (def.CanHaveFaction) + return lister.AllBuildingsColonistOfDef(def).FindAll(building => building.Faction == Faction.OfPlayer); + else + return lister.AllBuildingsColonistOfDef(def); + } + public static IEnumerable AllColonistBuildingsOfTypeOfPlayer(this ListerBuildings lister) + { + return lister.AllColonistBuildingsOfType().Where(t => + { + if (t is Building building && (!building.def.CanHaveFaction || building.Faction == Faction.OfPlayer)) + return true; + return false; + }); + } + static IEnumerable Transpiler(IEnumerable insts) + { + var allBuildingsColonistOfDef = AccessTools.Method(typeof(ListerBuildings), nameof(ListerBuildings.AllBuildingsColonistOfDef)); + var allBuildingsColonistOfDefOfPlayer = AccessTools.Method(typeof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch), nameof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch.AllBuildingsColonistOfDefOfPlayer)); + var allColonistBuildingsOfType = AccessTools.Method(typeof(ListerBuildings), nameof(ListerBuildings.AllColonistBuildingsOfType)).MakeGenericMethod([typeof(IHaulSource)]); + var allColonistBuildingsOfTypeOfPlayer = AccessTools.Method(typeof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch), nameof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch.AllColonistBuildingsOfTypeOfPlayer)).MakeGenericMethod([typeof(IHaulSource)]); + foreach (var ci in insts) + { + if (ci.Calls(allBuildingsColonistOfDef)) + { + ci.opcode = OpCodes.Call; + ci.operand = allBuildingsColonistOfDefOfPlayer; + } + else if (ci.Calls(allColonistBuildingsOfType)) + { + ci.opcode = OpCodes.Call; + ci.operand = allColonistBuildingsOfTypeOfPlayer; + } + yield return ci; + } + } +} + +[HarmonyPatch] +static class TradeUtility_AllLaunchableThingsForTrade_Patch +{ + static MethodInfo TargetMethod() + { + return AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(TradeUtility), nameof(TradeUtility.AllLaunchableThingsForTrade))); + } + + public static IEnumerable AllPoweredOfPlayer(Map map) + { + return Building_OrbitalTradeBeacon.AllPowered(map).Where(beacon => beacon.Faction == Faction.OfPlayer); + } + static IEnumerable Transpiler(IEnumerable insts) + { + var allPowered = AccessTools.Method(typeof(Building_OrbitalTradeBeacon), nameof(Building_OrbitalTradeBeacon.AllPowered)); + var allPoweredOfPlayer = AccessTools.Method(typeof(TradeUtility_AllLaunchableThingsForTrade_Patch), nameof(TradeUtility_AllLaunchableThingsForTrade_Patch.AllPoweredOfPlayer)); + foreach (var ci in insts) + { + if (ci.Calls(allPowered)) + { + ci.opcode = OpCodes.Call; + ci.operand = allPoweredOfPlayer; + } + + yield return ci; + } + } +} diff --git a/Source/Client/ModCompatibilityManager.cs b/Source/Client/ModCompatibilityManager.cs index e7c3d409b..fd6072463 100644 --- a/Source/Client/ModCompatibilityManager.cs +++ b/Source/Client/ModCompatibilityManager.cs @@ -10,6 +10,7 @@ using Multiplayer.Common; using RestSharp; using Steamworks; +using UnityEngine; using Verse; namespace Multiplayer.Client @@ -165,6 +166,9 @@ private static void SetupFrom(List mods) .ToDictionary(grouping => grouping.Key, grouping => grouping.First()); } + public static ModCompatibility? LookupByMod(ModMetaData meta) => + LookupByWorkshopId(meta.GetPublishedFileId()) ?? LookupByName(meta.Name); + public static ModCompatibility? LookupByWorkshopId(PublishedFileId_t workshopId) => LookupByWorkshopId(workshopId.m_PublishedFileId); @@ -188,5 +192,23 @@ public class ModCompatibility public string name { get; set; } public long workshopId { get; set; } public string notes { get; set; } = ""; + + public static Color ScoreColor(int score) => score switch + { + 1 => ColorLibrary.Red, + 2 => ColorLibrary.Orange, + 3 => ColorLibrary.Yellow, + 4 => ColorLibrary.Green, + _ => ColorLibrary.Grey + }; + + public static string ScoreDescription(int score) => score switch + { + 1 => "MpModCompatScore1", + 2 => "MpModCompatScore2", + 3 => "MpModCompatScore3", + 4 => "MpModCompatScore4", + _ => "MpModCompatScoreUnk" + }; } } diff --git a/Source/Client/Multiplayer.csproj b/Source/Client/Multiplayer.csproj index 5a59eb989..26cdeb7b1 100644 --- a/Source/Client/Multiplayer.csproj +++ b/Source/Client/Multiplayer.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/Source/Client/MultiplayerGame.cs b/Source/Client/MultiplayerGame.cs index c75d499a4..ae9f3431d 100644 --- a/Source/Client/MultiplayerGame.cs +++ b/Source/Client/MultiplayerGame.cs @@ -123,8 +123,6 @@ public void ChangeRealPlayerFaction(int newFaction) public void ChangeRealPlayerFaction(Faction newFaction, bool regenMapDrawers = true) { - Log.Message($"Changing real player faction to {newFaction} from {myFaction}"); - myFaction = newFaction; FactionContext.Set(newFaction); worldComp.SetFaction(newFaction); diff --git a/Source/Client/Networking/HostUtil.cs b/Source/Client/Networking/HostUtil.cs index b7db1b98c..0b736217e 100644 --- a/Source/Client/Networking/HostUtil.cs +++ b/Source/Client/Networking/HostUtil.cs @@ -71,7 +71,8 @@ private static void PrepareLocalServer(ServerSettings settings, bool fromReplay) localServer.startingTimer = TickPatch.Timer; } - localServer.StartInitData().SetResult(ClientJoiningState.PackInitData(settings.syncConfigs)); + var initData = ClientJoiningState.CreateInitDataPacket(settings.syncConfigs); + localServer.StartInitData().SetResult(ServerInitData.FromNet(initData)); } private static void PrepareGame() diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index 789fee517..c123d9c2a 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -1,11 +1,12 @@ +using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using HarmonyLib; -using Ionic.Zlib; using Multiplayer.Client.Util; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using RimWorld; using Steamworks; using Verse; @@ -18,102 +19,49 @@ public static class JoinData public static List activeModsSnapshot; public static ModFileDict modFilesSnapshot; - public static byte[] WriteServerData(bool writeConfigs) + public static List WriteServerData(bool writeConfigs) { - var data = new ByteWriter(); - - data.WriteInt32(activeModsSnapshot.Count); - foreach (var m in activeModsSnapshot) - { - data.WriteString(m.PackageIdNonUnique); - data.WriteString(m.Name); - data.WriteULong((ulong)m.GetPublishedFileId()); - data.WriteEnum(m.Source); - } - - data.WriteInt32(modFilesSnapshot.Count()); - foreach (var files in modFilesSnapshot) - { - data.WriteString(files.Key); - data.WriteInt32(files.Value.Count); - - foreach (var file in files.Value.Values) - { - data.WriteString(file.relPath); - data.WriteInt32(file.hash); - } - } - - data.WriteBool(writeConfigs); - if (writeConfigs) - { - var configs = SyncConfigs.GetSyncableConfigContents( - activeModsSnapshot.Select(m => m.PackageIdNonUnique).ToList() - ); - - data.WriteInt32(configs.Count); - foreach (var config in configs) - { - data.WriteString(config.ModId); - data.WriteString(config.FileName); - data.WriteString(config.Contents); - } - } - - return GZipStream.CompressBuffer(data.ToArray()); - } - - public static void ReadServerData(byte[] compressedData, RemoteData remoteInfo) - { - var data = new ByteReader(GZipStream.UncompressBuffer(compressedData)); - - var modCount = data.ReadInt32(); - for (int i = 0; i < modCount; i++) + var configs = writeConfigs ? SyncConfigs.GetSyncableConfigContents( + activeModsSnapshot.Select(m => m.PackageIdNonUnique).ToList() + ) : []; + return activeModsSnapshot.Select(meta => { - var packageId = data.ReadString(); - var name = data.ReadString(); - var steamId = data.ReadULong(); - var source = data.ReadEnum(); - - remoteInfo.remoteMods.Add(new ModInfo() { packageId = packageId, name = name, steamId = steamId, source = source }); - } - - var rootCount = data.ReadInt32(); - for (int i = 0; i < rootCount; i++) - { - var modId = data.ReadString(); - var mod = GetInstalledMod(modId); - var fileCount = data.ReadInt32(); - - for (int j = 0; j < fileCount; j++) + var files = modFilesSnapshot + .Where(kv => kv.Key == meta.PackageIdNonUnique) + .SelectMany(modIdToFilesPair => modIdToFilesPair.Value) + .Select(pathToFilePair => new ClientInitDataPacket.ModFile + { + path = pathToFilePair.Value.relPath, + hash = pathToFilePair.Value.hash, + }) + .ToList(); + + var localConfig = configs.FirstOrDefault(localConfig => localConfig.ModId == meta.PackageIdNonUnique); + var source = meta.Source switch { - var relPath = data.ReadString(); - var hash = data.ReadInt32(); - string absPath = null; - - if (mod != null) - absPath = Path.Combine(mod.RootDir.FullName, relPath); - - remoteInfo.remoteFiles.Add(modId, new ModFile(absPath, relPath, hash)); - } - } - - remoteInfo.hasConfigs = data.ReadBool(); - if (remoteInfo.hasConfigs) - { - var configCount = data.ReadInt32(); - for (int i = 0; i < configCount; i++) + ContentSource.Undefined => ClientInitDataPacket.ModSource.Undefined, + ContentSource.OfficialModsFolder => ClientInitDataPacket.ModSource.OfficialModsFolder, + ContentSource.ModsFolder => ClientInitDataPacket.ModSource.ModsFolder, + ContentSource.SteamWorkshop => ClientInitDataPacket.ModSource.SteamWorkshop, + _ => throw new ArgumentOutOfRangeException() + }; + + return new ClientInitDataPacket.ModData { - const int MaxConfigContentLen = 8388608; // 8 megabytes - - var modId = data.ReadString(); - var fileName = data.ReadString(); - var contents = data.ReadString(MaxConfigContentLen); - - remoteInfo.remoteModConfigs.Add(new ModConfig(modId, fileName, contents)); - //remoteInfo.remoteModConfigs[trimmedPath] = remoteInfo.remoteModConfigs[trimmedPath].Insert(0, "a"); // for testing - } - } + packageIdNonUnique = meta.PackageIdNonUnique, + name = meta.Name, + publishedFileId = (ulong)meta.GetPublishedFileId(), + source = source, + files = files, + config = localConfig == null + ? null + : new ClientInitDataPacket.ModConfig + { + fileName = localConfig.FileName, + contents = localConfig.Contents, + } + }; + }).ToList(); } public static ModMetaData GetInstalledMod(string id) @@ -208,8 +156,6 @@ public class RemoteData public IEnumerable RemoteModIds => remoteMods.Select(m => m.packageId); - public IConnector connector; - public ModListDiff CompareMods(List localMods) { var mods1 = remoteMods.Select(m => (m.packageId, m.source)); @@ -223,6 +169,49 @@ public ModListDiff CompareMods(List localMods) return ModListDiff.None; } + + public static RemoteData FromNet(ServerJoinDataPacket packet) + { + var remoteInfo = new RemoteData + { + remoteRwVersion = packet.rwVersion, + remoteMpVersion = packet.mpVersion, + hasConfigs = packet.configsIncluded, + }; + + foreach (var mod in packet.ServerInitData) + { + var modInfo = new ModInfo + { + packageId = mod.packageIdNonUnique, name = mod.name, steamId = mod.publishedFileId, + source = mod.source switch + { + ClientInitDataPacket.ModSource.Undefined => ContentSource.Undefined, + ClientInitDataPacket.ModSource.OfficialModsFolder => ContentSource.OfficialModsFolder, + ClientInitDataPacket.ModSource.ModsFolder => ContentSource.ModsFolder, + ClientInitDataPacket.ModSource.SteamWorkshop => ContentSource.SteamWorkshop, + _ => throw new ArgumentOutOfRangeException() + } + }; + remoteInfo.remoteMods.Add(modInfo); + + var modMeta = JoinData.GetInstalledMod(modInfo.packageId); + foreach (var modFile in mod.files) + { + var absPath = modMeta == null ? null : Path.Combine(modMeta.RootDir.FullName, modFile.path); + remoteInfo.remoteFiles.Add(modInfo.packageId, new ModFile(absPath, modFile.path, modFile.hash)); + } + + if (mod.config.HasValue) + { + var modConfig = mod.config.Value; + remoteInfo.remoteModConfigs.Add( + new ModConfig(modInfo.packageId, modConfig.fileName, modConfig.contents)); + } + } + + return remoteInfo; + } } public enum ModListDiff @@ -287,33 +276,20 @@ public struct ModInfo public bool CanSubscribe => steamId != 0; } - public struct ModFile + public struct ModFile(string absPath, string relPath, int hash) { - public string absPath; // Can be null on the remote side - public string relPath; - public int hash; + public string absPath = absPath?.NormalizePath(); // Can be null on the remote side + public string relPath = relPath.NormalizePath(); + public int hash = hash; - public ModFile(string absPath, string relPath, int hash) - { - this.absPath = absPath?.NormalizePath(); - this.relPath = relPath.NormalizePath(); - this.hash = hash; - } + public bool Equals(ModFile other) => + relPath == other.relPath && hash == other.hash; - public bool Equals(ModFile other) - { - return relPath == other.relPath && hash == other.hash; - } + public override bool Equals(object obj) => + obj is ModFile other && Equals(other); - public override bool Equals(object obj) - { - return obj is ModFile other && Equals(other); - } - - public override int GetHashCode() - { - return Gen.HashCombineInt(relPath.GetHashCode(), hash); - } + public override int GetHashCode() => + Gen.HashCombineInt(relPath.GetHashCode(), hash); } [HarmonyPatch(typeof(ModLister), nameof(ModLister.RebuildModList))] diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index d3297ae4d..0b74bab3b 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -30,6 +30,10 @@ public override void StartState() [TypedPacketHandler] public void HandleProtocolOk(ServerProtocolOkPacket packet) { + Multiplayer.session.isStandaloneServer = packet.isStandaloneServer; + Multiplayer.session.autosaveInterval = packet.autosaveInterval; + Multiplayer.session.autosaveUnit = packet.autosaveUnit; + if (packet.hasPassword) { // Delay showing the window for better UX @@ -46,16 +50,21 @@ public void HandleProtocolOk(ServerProtocolOkPacket packet) [TypedPacketHandler] public void HandleInitDataRequest(ServerInitDataRequestPacket packet) => - connection.SendFragmented(PackInitData(packet.includeConfigs).ToNet().Serialize()); + connection.SendFragmented(CreateInitDataPacket(packet.includeConfigs).Serialize()); - public static ServerInitData PackInitData(bool includeConfigs) => new( - JoinData.WriteServerData(includeConfigs), - VersionControl.CurrentVersionString, - Sync.handlers.Where(h => h.debugOnly).Select(h => h.syncId).ToHashSet(), - Sync.handlers.Where(h => h.hostOnly).Select(h => h.syncId).ToHashSet(), - (MultiplayerData.modCtorRoundMode, MultiplayerData.staticCtorRoundMode), - new Dictionary(MultiplayerData.localDefInfos) - ); + public static ClientInitDataPacket CreateInitDataPacket(bool includeConfigs) => new() + { + rwVersion = VersionControl.CurrentVersionString, + debugOnlySyncCmds = Sync.handlers.Where(h => h.debugOnly).Select(h => h.syncId).ToHashSet().ToArray(), + hostOnlySyncCmds = Sync.handlers.Where(h => h.hostOnly).Select(h => h.syncId).ToHashSet().ToArray(), + modCtorRoundMode = MultiplayerData.modCtorRoundMode, + staticCtorRoundMode = MultiplayerData.staticCtorRoundMode, + defInfos = MultiplayerData.localDefInfos + .Select(kv => new KeyedDefInfo { name = kv.Key, count = kv.Value.count, hash = kv.Value.hash }) + .ToArray(), + includeConfigs = includeConfigs, + Mods = JoinData.WriteServerData(includeConfigs) + }; [PacketHandler(Packets.Server_UsernameOk)] public void HandleUsernameOk(ByteReader data) => @@ -74,13 +83,6 @@ public void HandleJoinData(ServerJoinDataPacket packet) Multiplayer.session.gameName = packet.gameName; Multiplayer.session.playerId = packet.playerId; - var remoteInfo = new RemoteData - { - remoteRwVersion = packet.rwVersion, - remoteMpVersion = packet.mpVersion, - connector = Multiplayer.session.connector - }; - var defDiff = false; var defStatusMap = new Dictionary(); var i = 0; @@ -93,7 +95,9 @@ public void HandleJoinData(ServerJoinDataPacket packet) defDiff = true; } - JoinData.ReadServerData(packet.rawServerInitData, remoteInfo); + var remoteInfo = RemoteData.FromNet(packet); + // Captured before Complete runs: StopMultiplayerAndClearAllWindows nulls the session + var connector = Multiplayer.session.connector; // Delay showing the window for better UX OnMainThread.Schedule(Complete, 0.3f); @@ -115,7 +119,8 @@ void Complete() .Take(10) .Join(kv => $"{kv.name}: {kv.status}", "\n"); - Find.WindowStack.Add(new JoinDataWindow(remoteInfo){ + Find.WindowStack.Add(new JoinDataWindow(remoteInfo, connector) + { connectAnywayDisabled = defDiff ? "MpMismatchDefsDiff".Translate() + defDiffStr : null, connectAnywayCallback = StartDownloading }); @@ -124,6 +129,16 @@ void StartDownloading() { if (bootstrapState is { Enabled: true } state) { + var connectingWindows = Find.WindowStack.Windows + .OfType() + .ToList(); + + foreach (var connectingWindow in connectingWindows) + { + connectingWindow.suppressPostCloseActions = true; + Find.WindowStack.TryRemove(connectingWindow); + } + connection.ChangeState(ConnectionStateEnum.ClientBootstrap); Find.WindowStack.Add(new BootstrapConfiguratorWindow(connection, state)); return; diff --git a/Source/Client/Networking/State/ClientPlayingState.cs b/Source/Client/Networking/State/ClientPlayingState.cs index da91769d8..6a96635a5 100644 --- a/Source/Client/Networking/State/ClientPlayingState.cs +++ b/Source/Client/Networking/State/ClientPlayingState.cs @@ -92,7 +92,7 @@ public void HandlePlayerList(ServerPlayerListPacket packet) } [TypedPacketHandler] - public void HandleChat(ServerChatPacket packet) => Multiplayer.session.AddMsg(packet.msg); + public void HandleChat(ServerChatPacket packet) => Multiplayer.session.AddMsg(packet.msg, rawMessage: packet.rawMessage); [TypedPacketHandler] public void HandleCursor(ServerCursorPacket packet) @@ -204,6 +204,9 @@ public void HandleTraces(ServerTracesPacket packet) [TypedPacketHandler] public void HandleDebug(ServerDebugPacket _) => Rejoiner.DoRejoin(); + [TypedPacketHandler] + public void HandleRequestRejoin(ServerRequestRejoinPacket _) => Rejoiner.DoRejoin(); + [TypedPacketHandler] public void HandleSetFaction(ServerSetFactionPacket packet) { diff --git a/Source/Client/OnMainThread.cs b/Source/Client/OnMainThread.cs index 3230718d4..866c50248 100644 --- a/Source/Client/OnMainThread.cs +++ b/Source/Client/OnMainThread.cs @@ -1,10 +1,10 @@ -using Multiplayer.Client.DebugUi; -using Multiplayer.Client.Networking; -using Multiplayer.Common; using System; using System.Collections.Generic; +using Multiplayer.Client.DebugUi; using Multiplayer.Client.Desyncs; +using Multiplayer.Client.Networking; using Multiplayer.Client.Util; +using Multiplayer.Common; using UnityEngine; using Verse; using Verse.Steam; @@ -53,8 +53,8 @@ public void Update() { Log.Error($"Exception handling packet by {conn}: {e}"); - ConnectionStatusListeners.TryNotifyAll_Disconnected(new SessionDisconnectInfo - { titleTranslated = "MpPacketErrorLocal".Translate() }); + ConnectionStatusListeners.TryNotifyAll_Disconnected( + SessionDisconnectInfo.FromLocalPacketReadException(e)); Multiplayer.StopMultiplayer(); } } diff --git a/Source/Client/Patches/AreaSource.cs b/Source/Client/Patches/AreaSource.cs index 3644e5986..0c278f6b8 100644 --- a/Source/Client/Patches/AreaSource.cs +++ b/Source/Client/Patches/AreaSource.cs @@ -2,26 +2,27 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Text; using System.Threading.Tasks; using Verse; namespace Multiplayer.Client.Patches { - [HarmonyPatch(typeof(AreaSource))] + [HarmonyPatch] static class AreaSource_Patch { - [HarmonyPatch(nameof(AreaSource.ComputeAll))] - [HarmonyPatch(nameof(AreaSource.UpdateIncrementally))] + static IEnumerable TargetMethods() + { + yield return AccessTools.DeclaredMethod(typeof(AreaSource), nameof(AreaSource.ComputeAll)); + yield return AccessTools.DeclaredMethod(typeof(AreaSource), nameof(AreaSource.UpdateIncrementally)); + } static void Prefix(AreaSource __instance, ref AreaManager __state) { if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) return; __state = __instance.map.areaManager; __instance.map.areaManager = __instance.map.MpComp().AllAreaManager(); } - - [HarmonyPatch(nameof(AreaSource.ComputeAll))] - [HarmonyPatch(nameof(AreaSource.UpdateIncrementally))] static void Finalizer(AreaSource __instance, AreaManager __state) { if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) return; diff --git a/Source/Client/Patches/BootstrapStartedNewGamePatch.cs b/Source/Client/Patches/BootstrapStartedNewGamePatch.cs deleted file mode 100644 index 1434a71db..000000000 --- a/Source/Client/Patches/BootstrapStartedNewGamePatch.cs +++ /dev/null @@ -1,18 +0,0 @@ -using HarmonyLib; -using Verse; - -namespace Multiplayer.Client; - -[HarmonyPatch(typeof(GameComponentUtility), nameof(GameComponentUtility.StartedNewGame))] -static class BootstrapStartedNewGamePatch -{ - static void Postfix() - { - var window = BootstrapConfiguratorWindow.Instance; - if (window == null) - return; - - BootstrapConfiguratorWindow.AwaitingBootstrapMapInit = true; - OnMainThread.Enqueue(window.OnBootstrapMapInitialized); - } -} \ No newline at end of file diff --git a/Source/Client/Patches/Feedback.cs b/Source/Client/Patches/Feedback.cs index e2e18e1e3..bf6183b18 100644 --- a/Source/Client/Patches/Feedback.cs +++ b/Source/Client/Patches/Feedback.cs @@ -237,11 +237,15 @@ static class DraftedMove_GotoFeedbackPatch private static MethodInfo tryTakeOrderedJob = AccessTools.Method(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.TryTakeOrderedJob)); + private static MethodInfo endCurrentJob = + AccessTools.Method(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.EndCurrentJob)); + static IEnumerable Transpiler(IEnumerable instructions) { foreach (var inst in instructions) { if (inst.Calls(tryTakeOrderedJob)) inst.operand = ((Delegate)CustomTryTakeOrderedJob).Method; + else if (inst.Calls(endCurrentJob)) inst.operand = ((Delegate)CustomEndCurrentJob).Method; yield return inst; } } @@ -254,6 +258,17 @@ static bool CustomTryTakeOrderedJob(Pawn_JobTracker self, Job job, JobTag? tag = FleckMaker.Static(job.targetA.Cell, self.pawn.Map, FleckDefOf.FeedbackGoto); return false; } + + // PawnGotoAction can also stop a pawn without going through TryTakeOrderedJob: when the pawn is + // already standing on gotoLoc and its current job is Goto, it calls EndCurrentJob directly. That + // call isn't synced, so the pawn stops only for the player who issued the order while it keeps + // walking for everyone else, causing a desync. Sync it the same way as the TryTakeOrderedJob call. + [SyncMethod] + static void CustomEndCurrentJob(Pawn_JobTracker self, JobCondition condition, + bool startNewJob = true, bool canReturnToPool = true) + { + self.EndCurrentJob(condition, startNewJob, canReturnToPool); + } } } diff --git a/Source/Client/Patches/Seeds.cs b/Source/Client/Patches/Seeds.cs index 373af28f8..c93ca513a 100644 --- a/Source/Client/Patches/Seeds.cs +++ b/Source/Client/Patches/Seeds.cs @@ -235,4 +235,64 @@ public static void Finalizer(bool __state) } } + [HarmonyPatch(typeof(RitualBehaviorWorker_GravshipLaunch), nameof(RitualBehaviorWorker_GravshipLaunch.CanReachGravship))] + static class SeedGravshipCanReachGravship + { + static void Prefix(ref bool __state) + { + if (Multiplayer.Client == null) return; + + // The gravship launch confirmation dialog works out which pawns can board while it is being + // built: Dialog_BeginRitual's constructor calls CreateRitualRoleAssignments -> + // RitualRoleAssignments.PawnNotAssignableReason -> RitualBehaviorWorker_GravshipLaunch + // .PawnCanFillRole -> CanReachGravship -> GravshipUtility.TryFindSpotOnGravship -> + // Region.RandomCell, which consumes RNG. Building that dialog is UI work and, because the + // dialog is opened only by the issuing peer (see CancelDialogBeginRitual's + // currentExecutingCmdIssuedBySelf gate), it runs on just one peer - so this RNG must not + // advance the shared stream. Spamming the launch button repeats it and the divergence + // accumulates into a desync ("Random state from commands doesn't match"). + // CanReachGravship returns a bool that does not depend on the RNG (it only searches for any + // allowed cell), and the real boarding calls TryFindSpotOnGravship outside this check, so + // isolating the RNG here leaves both the check result and determinism intact. + Rand.PushState(); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + Rand.PopState(); + } + } + + [HarmonyPatch(typeof(PowerNet), nameof(PowerNet.DistributeEnergyAmongBatteries))] + static class SeedPowerNetBatteryDistribution + { + static void Prefix(ref bool __state) + { + if (Multiplayer.Client == null) return; + + // DistributeEnergyAmongBatteries shuffles the net's battery list, consuming RNG. Whether it + // runs at all on a given tick is decided by a float comparison in PowerNet.ChangeStoredEnergy + // (extra > 0f), fed by CurrentEnergyGainRate/CurrentStoredEnergy summing floats across every + // power component on the net. Those sums drift by tiny amounts between machines, so the + // comparison can flip a tick earlier on one peer than another: one peer runs the shuffle on a + // tick where the other does not, the shared stream advances by a different number of calls, + // and the map desyncs ("Wrong random state on map 0"). A colony with a large enough grid hits + // this repeatedly - the desync recurs every few thousand ticks after each rejoin. + // Isolating the RNG is safe because the shuffled order cannot affect the outcome: each pass + // adds the same amount to every battery still in the list (the smallest AmountCanAccept, or + // an even share of what remains), and a battery's AmountCanAccept depends only on itself, so + // every permutation leaves the batteries holding the same energy. + Rand.PushState(); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + Rand.PopState(); + } + } + } diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index 9c41c43d7..a8068091b 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -174,6 +174,7 @@ private static bool RunCmds() while (tickable.Cmds.Count > 0 && tickable.Cmds.Peek().ticks == curTimer) { ScheduledCommand cmd = tickable.Cmds.Dequeue(); + // Minimal code impact fix for #733. Having all the commands be added to a single queue gets rid of // the out-of-order execution problem. With a proper fix, this can be reverted to tickable.ExecuteCmd var target = TickableById(cmd.mapId); diff --git a/Source/Client/Patches/TimestampFixer.cs b/Source/Client/Patches/TimestampFixer.cs index dedc99b02..5526021d1 100644 --- a/Source/Client/Patches/TimestampFixer.cs +++ b/Source/Client/Patches/TimestampFixer.cs @@ -96,8 +96,12 @@ static void Postfix(Pawn __instance) if (Multiplayer.Client == null) return; if (__instance.Map == null) return; - if (__instance.GetComp().worldPawnRemoveTick == Multiplayer.AsyncWorldTime.worldTicks) + var comp = __instance.GetComp(); + if (comp.worldPawnRemoveTick != -1) + { TimestampFixer.FixPawn(__instance, null, __instance.Map); + comp.worldPawnRemoveTick = -1; + } } } @@ -110,6 +114,9 @@ static void Prefix(Pawn p) var lastMap = p.GetComp().lastMap; if (lastMap != -1) + { TimestampFixer.FixPawn(p, Find.Maps.FirstOrDefault(m => m.uniqueID == lastMap), null); + p.GetComp().lastMap = -1; + } } } diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index 10ab17105..eb5812a86 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -15,8 +15,8 @@ static bool Prefix(Thing thing, ref int __result) if (Multiplayer.Client == null) return true; - // TODO: Put this back to the original value - // Probably need to sync up all the animations before doing this + // Keep the synchronized update rate until animation timing can be + // brought back in line with the vanilla value. __result = VTRSync.GetSynchronizedUpdateRate(thing); return false; } @@ -142,6 +142,11 @@ static void Postfix(WorldRenderMode __result) { VTRSync.SendViewedMapUpdate(VTRSync.lastMovedToMapId, VTRSync.WorldMapId); } + + // On standalone with streaming, trigger a join point when leaving a map + // so each player can save independently without disturbing others + if (Multiplayer.session?.ConnectedToStandaloneServer == true && Multiplayer.GameComp.multifaction && Multiplayer.GameComp.asyncTime) + Autosaving.SendAutosavingRequest(JoinPointRequestReason.WorldTravel); } // Detect transition back to tile map else if (__result != WorldRenderMode.Planet && lastRenderMode == WorldRenderMode.Planet) diff --git a/Source/Client/Saving/CacheForReloading.cs b/Source/Client/Saving/CacheForReloading.cs deleted file mode 100644 index 0eee71f2e..000000000 --- a/Source/Client/Saving/CacheForReloading.cs +++ /dev/null @@ -1,164 +0,0 @@ -using HarmonyLib; -using Multiplayer.Client.Util; -using RimWorld.Planet; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Verse; - -// TODO: TEST: Test that this works with the new world generation - -namespace Multiplayer.Client -{ - [HarmonyPatch(typeof(MapDrawer), nameof(MapDrawer.RegenerateEverythingNow))] - public static class MapDrawerRegenPatch - { - public static Dictionary copyFrom = new(); - - // These are readonly so they need to be set using reflection - private static FieldInfo mapDrawerMap = AccessTools.Field(typeof(MapDrawer), nameof(MapDrawer.map)); - private static FieldInfo sectionMap = AccessTools.Field(typeof(Section), nameof(Section.map)); - - static bool Prefix(MapDrawer __instance) - { - Map map = __instance.map; - if (!copyFrom.TryGetValue(map.uniqueID, out MapDrawer keepDrawer)) return true; - - map.mapDrawer = keepDrawer; - mapDrawerMap.SetValue(keepDrawer, map); - - foreach (Section section in keepDrawer.sections) - { - sectionMap.SetValue(section, map); - - for (int i = 0; i < section.layers.Count; i++) - { - SectionLayer layer = section.layers[i]; - - if (!ShouldKeep(layer)) - section.layers[i] = (SectionLayer)Activator.CreateInstance(layer.GetType(), section); - else if (layer is SectionLayer_TerrainScatter scatter) - scatter.scats.Do(s => s.map = map); - } - } - - foreach (Section s in keepDrawer.sections) - foreach (SectionLayer layer in s.layers) - if (!ShouldKeep(layer)) - layer.Regenerate(); - - copyFrom.Remove(map.uniqueID); - - return false; - } - - static bool ShouldKeep(SectionLayer layer) - { - return layer.GetType().Assembly == typeof(Game).Assembly; - } - } - - [HarmonyPatch(typeof(WorldGrid), MethodType.Constructor)] - public static class WorldGridCachePatch - { - public static AccessTools.FieldRef> globalLayers = AccessTools.FieldRefAccess>(nameof(WorldGrid.globalLayers)); - public static WorldGrid copyFrom; - - static bool Prefix(WorldGrid __instance, ref int ___cachedTraversalDistance, ref int ___cachedTraversalDistanceForStart, ref int ___cachedTraversalDistanceForEnd) - { - if (copyFrom == null) return true; - - WorldGrid grid = __instance; - - grid.surfaceViewAngle = copyFrom.SurfaceViewAngle; - grid.surfaceViewCenter = copyFrom.SurfaceViewCenter; - grid.surface.verts = copyFrom.UnsafeVerts; - grid.surface.tileIDToNeighbors_offsets = copyFrom.UnsafeTileIDToNeighbors_offsets; - grid.surface.tileIDToNeighbors_values = copyFrom.UnsafeTileIDToNeighbors_values; - grid.surface.tileIDToVerts_offsets = copyFrom.UnsafeTileIDToVerts_offsets; - grid.surface.averageTileSize = copyFrom.AverageTileSize; - grid.surface.tiles.Clear(); - globalLayers(grid) = copyFrom.globalLayers; - - ___cachedTraversalDistance = -1; - ___cachedTraversalDistanceForStart = -1; - ___cachedTraversalDistanceForEnd = -1; - - copyFrom = null; - - return false; - } - } - - [HarmonyPatch(typeof(WorldGrid), nameof(WorldGrid.ExposeData))] - public static class WorldGridExposeDataPatch - { - public static WorldGrid copyFrom; - - static bool Prefix(WorldGrid __instance) - { - if (copyFrom == null) return true; - - WorldGrid grid = __instance; - - List copyTiles = copyFrom.Tiles.ToList(); - List gridTiles = grid.Tiles.ToList(); - - for(int i = 0; i < copyTiles.Count; i++) - { - SurfaceTile sourceTile = copyTiles[i]; - SurfaceTile targetTile = gridTiles[i]; - - // Tile - targetTile.biome = sourceTile.biome; - targetTile.elevation = sourceTile.elevation; - targetTile.hilliness = sourceTile.hilliness; - targetTile.temperature = sourceTile.temperature; - targetTile.rainfall = sourceTile.rainfall; - targetTile.swampiness = sourceTile.swampiness; - targetTile.feature = sourceTile.feature; - targetTile.pollution = sourceTile.pollution; - targetTile.tile = sourceTile.tile; - targetTile.mutatorsNullable = sourceTile.mutatorsNullable; - - // Surface Tile - Roads/Rivers are getters for potentialRoads/potentialRivers - targetTile.potentialRoads = sourceTile.potentialRoads; - targetTile.riverDist = sourceTile.riverDist; - targetTile.potentialRivers = sourceTile.potentialRivers; - } - - // This is plain old data apart from the WorldFeature feature field which is a reference - // It later gets reset in WorldFeatures.ExposeData though so it can be safely copied - - // Use Clear/AddRange instead of reflection to preserve collection observers - // and handle readonly field correctly - grid.surface.tiles.Clear(); - grid.surface.tiles.AddRange(copyFrom.surface.tiles); - - // ExposeData runs multiple times but WorldGrid only needs LoadSaveMode.LoadingVars - copyFrom = null; - - return false; - } - } - - //TODO: TEST: Test that this works with the new world generation - [HarmonyPatch(typeof(WorldGrid), (nameof(WorldGrid.InitializeGlobalLayers)))] - public static class WorldRendererCachePatch - { - - public static AccessTools.FieldRef> globalLayers = AccessTools.FieldRefAccess>(nameof(WorldGrid.globalLayers)); - public static WorldGrid copyFrom; - - static bool Prefix(WorldGrid __instance) - { - if (copyFrom == null) return true; - - globalLayers(__instance) = copyFrom.globalLayers; - copyFrom = null; - - return false; - } - } -} diff --git a/Source/Client/Saving/ReloadOptimization.cs b/Source/Client/Saving/ReloadOptimization.cs new file mode 100644 index 000000000..5b907e6ae --- /dev/null +++ b/Source/Client/Saving/ReloadOptimization.cs @@ -0,0 +1,53 @@ +using System; +using Verse; + +namespace Multiplayer.Client +{ + public enum ReloadOptimizationMode + { + None, + ForJoinPointSnapshot, + } + + internal static class ReloadOptimization + { + public static ReloadOptimizationPlan PlanFor(ReloadOptimizationMode mode) + { + return mode switch + { + ReloadOptimizationMode.ForJoinPointSnapshot => new( + RegenerateMapDrawersWhenRestoringFaction: false, + RegenerateMapDrawersAfterSnapshot: true + ), + _ => new( + RegenerateMapDrawersWhenRestoringFaction: true, + RegenerateMapDrawersAfterSnapshot: false + ), + }; + } + + public static void Complete(ReloadOptimizationMode mode) + { + Complete(mode, RegenerateMapDrawers); + } + + internal static void Complete(ReloadOptimizationMode mode, Action regenerateMapDrawers) + { + if (!PlanFor(mode).RegenerateMapDrawersAfterSnapshot) + return; + + regenerateMapDrawers(); + } + + private static void RegenerateMapDrawers() + { + foreach (var map in Find.Maps) + map.mapDrawer.RegenerateEverythingNow(); + } + } + + internal readonly record struct ReloadOptimizationPlan( + bool RegenerateMapDrawersWhenRestoringFaction, + bool RegenerateMapDrawersAfterSnapshot + ); +} diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index 37f5ab661..857ff0b18 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -1,9 +1,11 @@ using Ionic.Zlib; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using RimWorld; using RimWorld.Planet; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; using System.Threading; using System.Xml; using Multiplayer.Client.Saving; @@ -19,13 +21,22 @@ public record TempGameData(XmlDocument SaveData, byte[] SessionData); public static class SaveLoad { public static TempGameData SaveAndReload() + { + return SaveAndReload(ReloadOptimizationMode.None); + } + + public static TempGameData SaveAndReload(ReloadOptimizationMode optimizationMode) + { + var data = SaveAndReloadCore(optimizationMode); + ReloadOptimization.Complete(optimizationMode); + return data; + } + + private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizationMode) { Multiplayer.reloading = true; - var worldGridSaved = Find.WorldGrid; - var worldRendererSaved = Find.World.renderer; var tweenedPos = new Dictionary(); - var drawers = new Dictionary(); var localFactionId = Multiplayer.RealPlayerFaction.loadID; var mapCmds = new Dictionary>(); var planetRenderMode = Find.World.renderer.wantedMode; @@ -36,8 +47,6 @@ public static TempGameData SaveAndReload() foreach (Map map in Find.Maps) { - drawers[map.uniqueID] = map.mapDrawer; - foreach (Pawn p in map.mapPawns.AllPawnsSpawned) tweenedPos[p.thingIDNumber] = p.drawer.tweener.tweenedPos; @@ -57,11 +66,6 @@ public static TempGameData SaveAndReload() gameData = SaveGameData(); } - // TODO - //MapDrawerRegenPatch.copyFrom = drawers; - //WorldGridCachePatch.copyFrom = worldGridSaved; - //WorldGridExposeDataPatch.copyFrom = worldGridSaved; - MusicManagerPlay musicManager = null; if (Find.MusicManagerPlay.gameObjectCreated) { @@ -77,7 +81,11 @@ public static TempGameData SaveAndReload() if (musicManager != null) Current.Root_Play.musicManagerPlay = musicManager; - Multiplayer.game.ChangeRealPlayerFaction(Find.FactionManager.GetById(localFactionId)); + var reloadPlan = ReloadOptimization.PlanFor(optimizationMode); + Multiplayer.game.ChangeRealPlayerFaction( + Find.FactionManager.GetById(localFactionId), + reloadPlan.RegenerateMapDrawersWhenRestoringFaction + ); foreach (Map m in Find.Maps) { @@ -107,6 +115,14 @@ public static TempGameData SaveAndReload() return gameData; } + public static GameDataSnapshot SaveReloadAndCreateSnapshot(bool removeCurrentMapId, ReloadOptimizationMode optimizationMode) + { + var data = SaveAndReloadCore(optimizationMode); + var snapshot = CreateGameDataSnapshot(data, removeCurrentMapId); + ReloadOptimization.Complete(optimizationMode); + return snapshot; + } + public static void LoadInMainThread(TempGameData gameData) { DeepProfiler.Start("Multiplayer LoadInMainThread"); @@ -240,6 +256,60 @@ void Send() else Send(); } + + /// + /// Send per-map standalone snapshots to the server for all maps in the given snapshot. + /// Called after autosave when connected to a standalone server. + /// + public static void SendStandaloneMapSnapshots(GameDataSnapshot snapshot) + { + var tick = snapshot.CachedAtTime; + + foreach (var (mapId, mapBytes) in snapshot.MapData) + { + var compressed = GZipStream.CompressBuffer(mapBytes); + + byte[] hash; + using (var sha = SHA256.Create()) + hash = sha.ComputeHash(compressed); + + var packet = new ClientStandaloneMapSnapshotPacket + { + mapId = mapId, + tick = tick, + mapData = compressed, + sha256Hash = hash, + }; + + OnMainThread.Enqueue(() => Multiplayer.Client?.SendFragmented(packet.Serialize())); + } + } + + /// + /// Send the world + session standalone snapshot to the server. + /// Called after autosave when connected to a standalone server. + /// + public static void SendStandaloneWorldSnapshot(GameDataSnapshot snapshot) + { + var tick = snapshot.CachedAtTime; + var worldCompressed = GZipStream.CompressBuffer(snapshot.GameData); + var sessionCompressed = GZipStream.CompressBuffer(snapshot.SessionData); + + using var hasher = SHA256.Create(); + hasher.TransformBlock(worldCompressed, 0, worldCompressed.Length, null, 0); + hasher.TransformFinalBlock(sessionCompressed, 0, sessionCompressed.Length); + var hash = hasher.Hash ?? System.Array.Empty(); + + var packet = new ClientStandaloneWorldSnapshotPacket + { + tick = tick, + worldData = worldCompressed, + sessionData = sessionCompressed, + sha256Hash = hash, + }; + + OnMainThread.Enqueue(() => Multiplayer.Client?.SendFragmented(packet.Serialize())); + } } } diff --git a/Source/Client/Session/Autosaving.cs b/Source/Client/Session/Autosaving.cs index 35f8dc22e..7f3786066 100644 --- a/Source/Client/Session/Autosaving.cs +++ b/Source/Client/Session/Autosaving.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using RimWorld; using UnityEngine; using Verse; @@ -14,11 +15,30 @@ public static void DoAutosave() { LongEventHandler.QueueLongEvent(() => { - SaveGameToFile_Overwrite(GetNextAutosaveFileName(), false); - Multiplayer.Client.Send(Packets.Client_Autosaving); + var snapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false); + + if (!SaveGameToFile_Overwrite(GetNextAutosaveFileName(), snapshot)) + return; + + SendAutosavingRequest(JoinPointRequestReason.Save); + + // When connected to a standalone server, also upload fresh snapshots + if (Multiplayer.session?.ConnectedToStandaloneServer == true) + { + SaveLoad.SendStandaloneMapSnapshots(snapshot); + SaveLoad.SendStandaloneWorldSnapshot(snapshot); + } }, "MpSaving", false, null); } + public static void SendAutosavingRequest(JoinPointRequestReason reason) + { + if (TickPatch.Simulating) + return; + + Multiplayer.Client.Send(new ClientAutosavingPacket(reason)); + } + private static string GetNextAutosaveFileName() { var autosavePrefix = "Autosave-"; @@ -33,30 +53,39 @@ private static string GetNextAutosaveFileName() .First(); } - public static void SaveGameToFile_Overwrite(string fileNameNoExtension, bool currentReplay) + public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, bool currentReplay) + => SaveGameToFile_Overwrite(fileNameNoExtension, + currentReplay ? Multiplayer.session.dataSnapshot : null); + + public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, GameDataSnapshot snapshot) { Log.Message($"Multiplayer: saving to file {fileNameNoExtension}"); try { - var tmp = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.tmp.zip")); - Replay.ForSaving(tmp).WriteData( - currentReplay ? - Multiplayer.session.dataSnapshot : - SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false) + var tmpPath = Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.tmp.zip"); + if (File.Exists(tmpPath)) + File.Delete(tmpPath); + + Replay.ForSaving(new FileInfo(tmpPath)).WriteData( + snapshot ?? SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false) ); - var dst = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip")); - if (!dst.Exists) dst.Open(FileMode.Create).Close(); - tmp.Replace(dst.FullName, null); + var dstPath = Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip"); + if (File.Exists(dstPath)) + File.Replace(tmpPath, dstPath, destinationBackupFileName: null); + else + File.Move(tmpPath, dstPath); Messages.Message("MpGameSaved".Translate(fileNameNoExtension), MessageTypeDefOf.SilentInput, false); Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup; + return true; } catch (Exception e) { Log.Error($"Exception saving multiplayer game as {fileNameNoExtension}: {e}"); Messages.Message("MpGameSaveFailed".Translate(), MessageTypeDefOf.SilentInput, false); + return false; } } } diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 262b2bf6b..9bf2464c1 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -4,6 +4,7 @@ using Multiplayer.Client.Networking; using Multiplayer.Client.Util; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using RimWorld; using Steamworks; using UnityEngine; @@ -51,6 +52,10 @@ public class MultiplayerSession : IConnectionStatusListener public bool ArbiterPlaying => players.Any(p => p.type == PlayerType.Arbiter && p.status == PlayerStatus.Playing); public IConnector connector; + public bool isStandaloneServer; + public float autosaveInterval; + public AutosaveUnit autosaveUnit; + public bool ConnectedToStandaloneServer => client != null && isStandaloneServer; public void Stop() { @@ -69,9 +74,9 @@ public void Stop() public PlayerInfo GetPlayerInfo(int id) => players.FirstOrDefault(p => p.id == id); - public void AddMsg(string msg, bool notify = true) + public void AddMsg(string msg, bool notify = true, bool rawMessage = false) { - AddMsg(new ChatMsg_Text(msg), notify); + AddMsg(new ChatMsg_Text(msg, rawMessage), notify); } public void AddMsg(ChatMsg msg, bool notify = true) diff --git a/Source/Client/Session/Rejoiner.cs b/Source/Client/Session/Rejoiner.cs index a69c0ff36..1d1f4c489 100644 --- a/Source/Client/Session/Rejoiner.cs +++ b/Source/Client/Session/Rejoiner.cs @@ -1,4 +1,5 @@ -using Multiplayer.Client.Util; +using System; +using Multiplayer.Client.Util; using Multiplayer.Common; using Verse; using Verse.Profile; @@ -7,18 +8,8 @@ namespace Multiplayer.Client; public static class Rejoiner { - public static void DoRejoin() + public static void ReturnToEntry(Action onFinished) { - Multiplayer.Client.Send(Packets.Client_RequestRejoin); - - Multiplayer.Client.ChangeState(ConnectionStateEnum.ClientLoading); - Multiplayer.Client.Lenient = true; - - Multiplayer.session.desynced = false; - - Log.Message("Multiplayer: rejoining"); - - // From GenScene.GoToMainMenu LongEventHandler.ClearQueuedEvents(); LongEventHandler.QueueLongEvent(() => { @@ -28,8 +19,22 @@ public static void DoRejoin() LongEventHandler.ExecuteWhenFinished(() => { MpUI.ClearWindowStack(); - Find.WindowStack.Add(new RejoiningWindow()); + onFinished?.Invoke(); }); }, "Entry", "LoadingLongEvent", true, null, false); } + + public static void DoRejoin() + { + Multiplayer.Client.Send(Packets.Client_RequestRejoin); + + Multiplayer.Client.ChangeState(ConnectionStateEnum.ClientLoading); + Multiplayer.Client.Lenient = true; + + Multiplayer.session.desynced = false; + + Log.Message("Multiplayer: rejoining"); + + ReturnToEntry(() => Find.WindowStack.Add(new RejoiningWindow())); + } } diff --git a/Source/Client/Session/SessionDisconnectInfo.cs b/Source/Client/Session/SessionDisconnectInfo.cs index ce20290cb..f31eff056 100644 --- a/Source/Client/Session/SessionDisconnectInfo.cs +++ b/Source/Client/Session/SessionDisconnectInfo.cs @@ -115,4 +115,19 @@ public static SessionDisconnectInfo From(MpDisconnectReason reason, ByteReader r return disconnectInfo; } + + public static SessionDisconnectInfo FromLocalPacketReadException(Exception e) + { + var disconnectInfo = new SessionDisconnectInfo + { titleTranslated = "MpPacketErrorLocal".Translate() }; + + if (e is PacketBadIdException) + { + disconnectInfo.descTranslated = "MpPacketErrorLocalBadId".Translate(); + disconnectInfo.descTranslated += '\n' + "MpWrongVersionUpdateInfo".Translate(); + disconnectInfo.wideWindow = true; + } + + return disconnectInfo; + } } diff --git a/Source/Client/Settings/MpSettings.cs b/Source/Client/Settings/MpSettings.cs index 2faded769..03845c5dd 100644 --- a/Source/Client/Settings/MpSettings.cs +++ b/Source/Client/Settings/MpSettings.cs @@ -14,6 +14,7 @@ public class MpSettings : ModSettings public bool showCursors = true; public bool autoAcceptSteam; public bool transparentChat = true; + public bool helpOnlyUsableCommands = true; public int autosaveSlots = 5; public bool showDevInfo; public bool includeReplayInDesync = VersionChecker.IsContinuousRelease; @@ -60,6 +61,7 @@ public override void ExposeData() Scribe_Values.Look(ref showCursors, "showCursors", true); Scribe_Values.Look(ref autoAcceptSteam, "autoAcceptSteam"); Scribe_Values.Look(ref transparentChat, "transparentChat", true); + Scribe_Values.Look(ref helpOnlyUsableCommands, "helpOnlyUsableCommands", true); Scribe_Values.Look(ref autosaveSlots, "autosaveSlots", 5); Scribe_Values.Look(ref showDevInfo, "showDevInfo"); Scribe_Values.Look(ref includeReplayInDesync, "includeReplayInDesync", VersionChecker.IsContinuousRelease); diff --git a/Source/Client/Settings/MpSettingsUI.cs b/Source/Client/Settings/MpSettingsUI.cs index 1d1e30d89..4c857ca10 100644 --- a/Source/Client/Settings/MpSettingsUI.cs +++ b/Source/Client/Settings/MpSettingsUI.cs @@ -70,6 +70,8 @@ public static void DoGeneralSettings(MpSettings settings, Rect inRect, Rect page listing.CheckboxLabeled("MpAutoAcceptSteam".Translate(), ref settings.autoAcceptSteam, "MpAutoAcceptSteamDesc".Translate()); listing.CheckboxLabeled("MpTransparentChat".Translate(), ref settings.transparentChat); + listing.CheckboxLabeled("MpHelpOnlyUsableCommands".Translate(), ref settings.helpOnlyUsableCommands, + "MpHelpOnlyUsableCommandsDesc".Translate()); listing.CheckboxLabeled("MpAppendNameToAutosave".Translate(), ref settings.appendNameToAutosave); listing.CheckboxLabeled("MpShowModCompat".Translate(), ref settings.showModCompatibility, "MpShowModCompatDesc".Translate()); diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index 1ee71d206..fbacb638e 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -105,7 +105,7 @@ public static void Init() SyncDelegate.Lambda(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp), 4); // Cancel gravship tile picker SyncDelegate.Lambda(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp), 5); // Confirm gravship landing tile SyncDelegate.Lambda(typeof(RitualOutcomeEffectWorker_GravshipLaunch), nameof(RitualOutcomeEffectWorker_GravshipLaunch.Apply), 0); // Confirm gravship prelaunch dialog - SyncDelegate.Lambda(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation), 4); // Cancel gravship prelaunch dialog + SyncDelegate.Lambda(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation), 4).SetContext(SyncContext.MapSelected); // Cancel gravship prelaunch dialog // Biosculpter pod SyncMethod.Lambda(typeof(CompBiosculpterPod), nameof(CompBiosculpterPod.CompGetGizmosExtra), 1); // Interrupt cycle (eject contents) @@ -257,8 +257,8 @@ public static void Init() SyncMethod.Lambda(typeof(Hediff_LaborPushing), nameof(Hediff_LaborPushing.GetGizmos), 1).SetDebugOnly(); // Force infant illness SyncMethod.Lambda(typeof(Hediff_LaborPushing), nameof(Hediff_LaborPushing.GetGizmos), 2).SetDebugOnly(); // Force healthy SyncMethod.Lambda(typeof(Hediff_LaborPushing), nameof(Hediff_LaborPushing.GetGizmos), 3).SetDebugOnly(); // Force end - SyncMethod.Lambda(typeof(Hediff_LaborPushing), nameof(Hediff_Pregnant.GetGizmos), 0).SetDebugOnly(); // Next trimester - SyncMethod.Lambda(typeof(Hediff_LaborPushing), nameof(Hediff_Pregnant.GetGizmos), 1).SetDebugOnly(); // Start labor + SyncMethod.Lambda(typeof(Hediff_Pregnant), nameof(Hediff_Pregnant.GetGizmos), 0).SetDebugOnly(); // Next trimester + SyncMethod.Lambda(typeof(Hediff_Pregnant), nameof(Hediff_Pregnant.GetGizmos), 1).SetDebugOnly(); // Start labor SyncDelegate.Lambda(typeof(Hediff_MetalhorrorImplant), nameof(Hediff_MetalhorrorImplant.GetGizmos), 0).SetDebugOnly(); // Emerge SyncDelegate.Lambda(typeof(Hediff_MetalhorrorImplant), nameof(Hediff_MetalhorrorImplant.GetGizmos), 1).SetDebugOnly(); // Mark for flesh drop SyncDelegate.Lambda(typeof(Hediff_MetalhorrorImplant), nameof(Hediff_MetalhorrorImplant.GetGizmos), 2).SetDebugOnly(); // Discover next interaction @@ -280,6 +280,9 @@ public static void Init() SyncDelegate.Lambda(typeof(Pawn_TrainingTracker), nameof(Pawn_TrainingTracker.GetGizmos), 0, fields: [SyncDelegate.DelegateThis, "master"]).SetContext(SyncContext.MapSelected).CancelIfNoSelectedMapObjects(); // Force attack target SyncDelegate.Lambda(typeof(Pawn_TrainingTracker), nameof(Pawn_TrainingTracker.GetGizmos), 3).SetContext(SyncContext.MapSelected).CancelIfNoSelectedMapObjects(); // Cancel attacking target + // Colonist bar reordering + SyncDelegate.Lambda(typeof(ColonistBar.Entry), null, lambdaOrdinal: 0, parentMethodType: MethodType.Constructor, parentArgs: [typeof(Pawn), typeof(Map), typeof(int)]); + InitRituals(); InitChoiceLetters(); InitDevTools(); diff --git a/Source/Client/Syncing/Game/SyncMethods.cs b/Source/Client/Syncing/Game/SyncMethods.cs index a3d5fbd9a..1b04e7881 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -28,7 +28,6 @@ public static void Init() SyncMethod.Register(typeof(Pawn_OutfitTracker), nameof(Pawn_OutfitTracker.CurrentApparelPolicy)).CancelIfAnyArgNull(); SyncMethod.Register(typeof(Pawn_FoodRestrictionTracker), nameof(Pawn_FoodRestrictionTracker.CurrentFoodPolicy)).CancelIfAnyArgNull(); SyncMethod.Register(typeof(Pawn_ReadingTracker), nameof(Pawn_ReadingTracker.CurrentPolicy)).CancelIfAnyArgNull(); - SyncMethod.Register(typeof(Policy), nameof(Policy.RenamableLabel)); SyncMethod.Register(typeof(Pawn_PlayerSettings), nameof(Pawn_PlayerSettings.AreaRestrictionInPawnCurrentMap)); SyncMethod.Register(typeof(Pawn_PlayerSettings), nameof(Pawn_PlayerSettings.Master)); SyncMethod.Register(typeof(Pawn), nameof(Pawn.Name)).ExposeParameter(0) @@ -54,7 +53,6 @@ public static void Init() SyncMethod.Register(typeof(Building_TurretGun), nameof(Building_TurretGun.ExtractShell)); SyncMethod.Register(typeof(Area), nameof(Area.Invert)); SyncMethod.Register(typeof(Area), nameof(Area.Delete)); - SyncMethod.Register(typeof(Area_Allowed), nameof(Area_Allowed.RenamableLabel)); SyncMethod.Register(typeof(AreaManager), nameof(AreaManager.TryMakeNewAllowed)); SyncMethod.Register(typeof(MainTabWindow_Research), nameof(MainTabWindow_Research.DoBeginResearch)) .TransformTarget(Serializer.SimpleReader(() => new MainTabWindow_Research())); @@ -295,7 +293,6 @@ public static void Init() SyncMethod.Register(typeof(CompPowerBattery), nameof(CompPowerBattery.SetStoredEnergyPct)).SetDebugOnly(); // Set battery to 0/100% SyncMethod.Lambda(typeof(CompPowerTrader), nameof(CompPowerTrader.CompGetGizmosExtra), 0).SetDebugOnly(); // Toggle power on/off SyncMethod.Lambda(typeof(CompProximityFuse), nameof(CompProximityFuse.CompGetGizmosExtra), 0).SetDebugOnly(); // Trigger - SyncMethod.Register(typeof(GameComponent_PsychicRitualManager), nameof(GameComponent_PsychicRitualManager.ClearAllCooldowns)).SetDebugOnly(); SyncMethod.Lambda(typeof(CompRevenant), nameof(CompRevenant.CompGetGizmosExtra), 0).SetDebugOnly(); // Reset hypnosis cooldown SyncMethod.Lambda(typeof(CompRevenant), nameof(CompRevenant.CompGetGizmosExtra), 1).SetDebugOnly(); // Change to wander mode SyncMethod.Lambda(typeof(CompRevenant), nameof(CompRevenant.CompGetGizmosExtra), 2).SetDebugOnly(); // Change to sleep mode @@ -926,7 +923,7 @@ static IEnumerable Transpiler(IEnumerable inst // Seems can't sync Action & Predicate so have to deduct params // This is enough for bookcase to use but needs update for new situation if needed. - + static bool SyncBookcaseTryDrop(Building_Bookcase bookcase, Thing thing, IntVec3 dropLoc, Map map, ThingPlaceMode mode, int count, out Thing resultingThing, Action placedAction = null, Predicate nearPlaceValidator = null) { return DoSyncBookcaseTryDrop(bookcase, thing, dropLoc, map, mode, count, out resultingThing); diff --git a/Source/Client/Syncing/Sync.cs b/Source/Client/Syncing/Sync.cs index 3ec9558ca..50b7cc7be 100644 --- a/Source/Client/Syncing/Sync.cs +++ b/Source/Client/Syncing/Sync.cs @@ -57,12 +57,13 @@ public static SyncField[] Fields(Type targetType, string instancePath, params st public static ISyncField RegisterSyncField(Type targetType, string fieldName) { - FieldInfo field = AccessTools.Field(targetType, fieldName) + MemberInfo field = AccessTools.Field(targetType, fieldName) as MemberInfo + ?? AccessTools.Property(targetType, fieldName) ?? throw new Exception($"Couldn't find field {targetType}::{fieldName}"); SyncField sf; string memberPath; - if (field.IsStatic) { + if (field.IsStatic()) { memberPath = field.ReflectedType + "/" + field.Name; sf = Field(null, null, memberPath); } else { @@ -256,10 +257,16 @@ static void RegisterSyncField(FieldInfo field, SyncFieldAttribute attribute) public static SyncMethod RegisterSyncMethod(MethodInfo method, SyncType[] argTypes = null) { + if (methodBaseToInternalId.TryGetValue(method, out var id)) + { + Log.Error($"Error in {method.DeclaringType?.FullName}::{method}: Method is already registered as a SyncMethod."); + return (SyncMethod)internalIdToSyncMethod[id]; + } + MpUtil.MarkNoInlining(method); - SyncMethod handler = new SyncMethod(method.IsStatic ? null : method.DeclaringType, null, method, argTypes); - methodBaseToInternalId[handler.method] = internalIdToSyncMethod.Count; + var handler = new SyncMethod(method.IsStatic ? null : method.DeclaringType, null, method, argTypes); + methodBaseToInternalId[method] = internalIdToSyncMethod.Count; internalIdToSyncMethod.Add(handler); handlers.Add(handler); diff --git a/Source/Client/UI/MainMenuPatches.cs b/Source/Client/UI/MainMenuPatches.cs index 321939342..246b66e80 100644 --- a/Source/Client/UI/MainMenuPatches.cs +++ b/Source/Client/UI/MainMenuPatches.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Reflection; using HarmonyLib; using Multiplayer.Client.Saving; @@ -126,20 +125,6 @@ static void Prefix(Rect rect, List optList) } } - static void ShowModDebugInfo() - { - return; - - var info = new RemoteData(); - JoinData.ReadServerData(JoinData.WriteServerData(true), info); - for (int i = 0; i < 200; i++) - info.remoteMods.Add(info.remoteMods.Last()); - info.remoteFiles.Add("rwmt.multiplayer", new ModFile() { relPath = "/Test/Test.xml" }); - //info.remoteFiles.Add("ludeon.rimworld", new ModFile() { relPath = "/Test/Test.xml" }); - - Find.WindowStack.Add(new JoinDataWindow(info)); - } - public static void AskQuitToMainMenu() { if (Multiplayer.LocalServer == null) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs index 26c99acb2..ea2fd5aab 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs @@ -16,6 +16,7 @@ public partial class BootstrapConfiguratorWindow { private const string BootstrapSaveName = "MpBootstrapSave"; private const float PostMapEnterSaveDelaySeconds = 1.5f; + private const float PostMapEnterWindowDelaySeconds = 0.1f; private bool hideWindowDuringMapGen; private bool autoAdvanceArmed; @@ -24,10 +25,12 @@ public partial class BootstrapConfiguratorWindow private bool saveReady; private bool isUploadingSave; private bool saveUploadAutoStarted; + private bool closeBootstrapModeOnDisconnect; private string savedReplayPath; private string saveUploadStatus; private float saveUploadProgress; private float postMapEnterSaveDelayRemaining; + private float postMapEnterWindowDelayRemaining; private float GetGenerateMapStepHeight() { @@ -131,6 +134,7 @@ private void StartVanillaNewColonyFlow() savedReplayPath = null; autoAdvanceArmed = true; AwaitingBootstrapMapInit = true; + postMapEnterWindowDelayRemaining = 0f; saveUploadStatus = "Generating map..."; Find.WindowStack.TryRemove(this); @@ -143,7 +147,7 @@ private void TryArmAwaitingBootstrapMapInit() if (AwaitingBootstrapMapInit) return; - if (Multiplayer.Client != null || bootstrapSaveQueued || saveReady || isUploadingSave || saveUploadAutoStarted) + if (Multiplayer.Client != null || bootstrapSaveQueued || saveReady || isUploadingSave || saveUploadAutoStarted || awaitingControllablePawns || postMapEnterSaveDelayRemaining > 0f) return; if (Current.ProgramState != ProgramState.Playing || Find.Maps == null || Find.Maps.Count == 0) @@ -173,12 +177,24 @@ public void OnBootstrapMapInitialized() retainInstanceOnClose = false; AwaitingBootstrapMapInit = false; postMapEnterSaveDelayRemaining = PostMapEnterSaveDelaySeconds; + postMapEnterWindowDelayRemaining = PostMapEnterWindowDelaySeconds; awaitingControllablePawns = true; bootstrapSaveQueued = false; - saveUploadStatus = "Map initialized. Waiting before saving..."; + saveUploadStatus = "Map initialized. Waiting for controllable colonists to spawn..."; + } + + private void TryShowBootstrapWindow() + { + if (Find.WindowStack == null) + return; + + if (Find.WindowStack.WindowOfType() != null) + return; - if (Find.WindowStack.WindowOfType() == null) - Find.WindowStack.Add(this); + if (Find.WindowStack.Windows.Any(window => window is Dialog_MessageBox or Dialog_NodeTree)) + return; + + Find.WindowStack.Add(this); } private void TickPostMapEnterSaveDelayAndMaybeSave() @@ -186,6 +202,13 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() if (hideWindowDuringMapGen || bootstrapSaveQueued || saveReady || isUploadingSave) return; + if (postMapEnterWindowDelayRemaining > 0f || postMapEnterSaveDelayRemaining > 0f || awaitingControllablePawns) + { + postMapEnterWindowDelayRemaining -= Time.deltaTime; + if (postMapEnterWindowDelayRemaining <= 0f) + TryShowBootstrapWindow(); + } + if (postMapEnterSaveDelayRemaining <= 0f && !awaitingControllablePawns) return; @@ -194,7 +217,10 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() return; if (!WaitForControllableColonists()) + { + TryShowBootstrapWindow(); return; + } postMapEnterSaveDelayRemaining = 0f; bootstrapSaveQueued = true; @@ -283,7 +309,16 @@ private void CreateBootstrapReplaySave() { try { - Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false); + if (!Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false)) + { + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Save failed, see log for details."; + bootstrapSaveQueued = false; + }); + return; + } + var path = Path.Combine(Multiplayer.ReplaysDir, $"{BootstrapSaveName}.zip"); OnMainThread.Enqueue(() => FinalizeBootstrapSave(path)); } @@ -318,26 +353,45 @@ private void FinalizeBootstrapSave(string path) StatusText = statusText ?? string.Empty }; - saveUploadStatus = "Save created. Returning to menu..."; - LongEventHandler.QueueLongEvent(ReturnToMenuAndReconnect, "Returning to menu", false, null); + saveUploadStatus = "Save created. Returning to entry..."; + LongEventHandler.ExecuteWhenFinished(ReturnToEntryAndReconnect); } - private void ReturnToMenuAndReconnect() + private void ReturnToEntryAndReconnect() { - GenScene.GoToMainMenu(); - OnMainThread.Enqueue(() => + try { - saveUploadStatus = "Reconnecting to upload save..."; - Multiplayer.StopMultiplayer(); + Log.Message("Bootstrap: returning to entry for save upload reconnect"); + Rejoiner.ReturnToEntry(ReconnectAfterReturningToEntry); + } + catch (Exception exception) + { + saveUploadStatus = $"Return to entry failed: {exception.GetType().Name}: {exception.Message}"; + bootstrapSaveQueued = false; + Log.Error($"Bootstrap return to entry failed: {exception}"); + } + } - if (reconnectConnector == null) - { - saveUploadStatus = "No connector available to reconnect to the bootstrap server."; - return; - } + private void ReconnectAfterReturningToEntry() + { + if (Current.ProgramState != ProgramState.Entry || Current.Game != null) + { + saveUploadStatus = "Waiting to finish returning to entry..."; + LongEventHandler.ExecuteWhenFinished(ReconnectAfterReturningToEntry); + return; + } + + Multiplayer.StopMultiplayer(); + + saveUploadStatus = "Reconnecting to upload save..."; + + if (reconnectConnector == null) + { + saveUploadStatus = "No connector available to reconnect to the bootstrap server."; + return; + } - ClientUtil.TryConnectWithWindow(reconnectConnector, false); - }); + ClientUtil.TryConnectWithWindow(reconnectConnector, false); } private void StartUploadSaveZip() @@ -371,6 +425,7 @@ private void StartUploadSaveZip() { try { + closeBootstrapModeOnDisconnect = true; connection.SendFragmented(new ClientBootstrapSaveDataPacket(saveData, hash).Serialize()); OnMainThread.Enqueue(() => @@ -383,6 +438,7 @@ private void StartUploadSaveZip() { OnMainThread.Enqueue(() => { + closeBootstrapModeOnDisconnect = false; isUploadingSave = false; saveUploadStatus = $"Failed to upload save.zip: {exception.GetType().Name}: {exception.Message}"; }); diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs index 98221d606..77ecb3da1 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs @@ -1,4 +1,3 @@ -using System; using Multiplayer.Client.Util; using Multiplayer.Common.Networking.Packet; using Multiplayer.Common.Util; @@ -92,9 +91,7 @@ private void DrawSettingsButtons(Rect inRect) { var previewRect = new Rect(inRect.x, inRect.y, 150f, inRect.height); if (Widgets.ButtonText(previewRect, "Preview TOML")) - { Find.WindowStack.Add(new DebugTextWindow(GenerateToml())); - } nextRect = new Rect(inRect.xMax - 150f, inRect.y, 150f, inRect.height); } @@ -126,7 +123,7 @@ private void StartUploadSettingsToml() { connection.Send(new ClientBootstrapSettingsPacket(settings)); } - catch (Exception e) + catch (System.Exception e) { Log.Error($"Bootstrap settings upload failed: {e}"); isUploadingToml = false; @@ -140,10 +137,8 @@ private void StartUploadSettingsToml() statusText = "Server settings uploaded. Waiting for the server to request save.zip generation."; step = Step.GenerateMap; saveUploadRequestedByServer = false; - bootstrapState = bootstrapState with { SettingsMissing = false, SaveMissing = false }; } - private string GenerateToml() => - "# Generated by Multiplayer bootstrap configurator\n\n" + TomlSettings.Serialize(settings); + private string GenerateToml() => "# Generated by Multiplayer bootstrap configurator\n\n" + TomlSettings.Serialize(settings); } diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 6fd324d83..f95268dfa 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -106,6 +106,35 @@ public override void PostClose() Instance = null; } + private void ExitBootstrapMode(bool clearPendingUploadState) + { + closeBootstrapModeOnDisconnect = false; + retainInstanceOnClose = false; + + ResetTransientUiState(resetServerDrivenState: true); + + isUploadingToml = false; + uploadProgress = 0f; + statusText = null; + settingsUploaded = false; + saveReady = false; + savedReplayPath = null; + saveUploadStatus = null; + saveUploadProgress = 0f; + bootstrapState = BootstrapServerState.None; + + if (clearPendingUploadState) + pendingUploadState = null; + + if (Current.Game?.components != null) + Current.Game.components.RemoveAll(component => component is Comp.BootstrapCoordinator); + + if (Find.WindowStack?.Windows.Contains(this) == true) + Find.WindowStack.TryRemove(this); + else if (ReferenceEquals(Instance, this)) + Instance = null; + } + internal void ResetTransientUiState(bool resetServerDrivenState = false) { AwaitingBootstrapMapInit = false; @@ -116,6 +145,7 @@ internal void ResetTransientUiState(bool resetServerDrivenState = false) isUploadingSave = false; saveUploadAutoStarted = false; postMapEnterSaveDelayRemaining = 0f; + postMapEnterWindowDelayRemaining = 0f; if (resetServerDrivenState) { @@ -217,10 +247,7 @@ private float GetActiveTabContentHeight() if (tab == Tab.Connecting) return 5 * 30f; - if (tab == Tab.Gameplay) - return (MpVersion.IsDebug ? 9 : 8) * 30f; - - return 260f; + return (MpVersion.IsDebug ? 9 : 8) * 30f; } private sealed class PendingUploadState @@ -236,7 +263,6 @@ public void Connected() public void Disconnected(SessionDisconnectInfo info) { - ResetTransientUiState(resetServerDrivenState: true); - Find.WindowStack.TryRemove(this); + ExitBootstrapMode(clearPendingUploadState: closeBootstrapModeOnDisconnect); } } diff --git a/Source/Client/Windows/ChatWindow.cs b/Source/Client/Windows/ChatWindow.cs index 5ef46f7e4..5f2362afb 100644 --- a/Source/Client/Windows/ChatWindow.cs +++ b/Source/Client/Windows/ChatWindow.cs @@ -288,8 +288,8 @@ private void DrawChat(Rect inRect) foreach (ChatMsg msg in Multiplayer.session.messages) { - float height = Text.CalcHeight(msg.Msg, width - 20f); - float textWidth = Text.CalcSize(msg.Msg).x + 15; + CalculateMessageSize(msg, width, out var height, out var textWidth); + Rect msgRect = new Rect(20f, yPos, width - 20f, height); if (Mouse.IsOver(msgRect)) @@ -310,7 +310,7 @@ private void DrawChat(Rect inRect) GUI.color = new Color(0.8f, 0.8f, 1); GUI.SetNextControlName("chat_msg_" + i++); - Widgets.TextArea(msgRect, msg.Msg, true); + DrawMessageTextArea(msgRect, msg); if (mouseOver && msg.Clickable) { @@ -362,7 +362,7 @@ public void SendMsg() if (Multiplayer.Client == null) Multiplayer.session.AddMsg(Multiplayer.username + ": " + currentMsg); else - Multiplayer.Client.Send(ClientChatPacket.Create(currentMsg)); + Multiplayer.Client.Send(ClientChatPacket.Create(currentMsg, Multiplayer.settings.helpOnlyUsableCommands)); currentMsg = ""; } @@ -429,6 +429,33 @@ public void OnChatReceived() chatScroll.y = messagesHeight; } + private static void CalculateMessageSize(ChatMsg msg, float width, out float height, out float textWidth) + { + var style = msg.RawMessage ? RawTextAreaStyle() : Text.CurTextAreaReadOnlyStyle; + var content = new GUIContent(msg.Msg); + height = style.CalcHeight(content, width - 20f); + textWidth = style.CalcSize(content).x + 15; + } + + private static void DrawMessageTextArea(Rect rect, ChatMsg msg) + { + if (!msg.RawMessage) + { + Widgets.TextArea(rect, msg.Msg, true); + return; + } + + GUI.Label(rect, msg.Msg, RawTextAreaStyle()); + } + + private static GUIStyle RawTextAreaStyle() + { + return new GUIStyle(Text.CurTextAreaReadOnlyStyle) + { + richText = false + }; + } + public override void PostClose() { if (Multiplayer.session != null && saveSize) @@ -474,6 +501,7 @@ public static void OpenChat() public abstract class ChatMsg { public virtual bool Clickable => false; + public virtual bool RawMessage => false; public abstract string Msg { get; } public virtual DateTime TimeStamp { get; } @@ -488,10 +516,12 @@ public virtual void Click() { } public class ChatMsg_Text : ChatMsg { public override string Msg { get; } + public override bool RawMessage { get; } - public ChatMsg_Text(string msg) + public ChatMsg_Text(string msg, bool rawMessage = false) { this.Msg = msg; + this.RawMessage = rawMessage; } } diff --git a/Source/Client/Windows/ConnectingWindow.cs b/Source/Client/Windows/ConnectingWindow.cs index 11aef7617..1e05e0e31 100644 --- a/Source/Client/Windows/ConnectingWindow.cs +++ b/Source/Client/Windows/ConnectingWindow.cs @@ -14,6 +14,7 @@ public abstract class BaseConnectingWindow : Window, IConnectionStatusListener protected abstract string ConnectingString { get; } public bool returnToServerBrowser; + public bool suppressPostCloseActions; protected string result; // Only show this window if there aren't any others during connecting @@ -117,6 +118,9 @@ public override void DoWindowContents(Rect inRect) public override void PostClose() { + if (suppressPostCloseActions) + return; + Multiplayer.StopMultiplayer(); if (returnToServerBrowser) diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index 2e6f47599..fb891cc22 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -47,6 +47,7 @@ enum NodeStatus } private RemoteData remote; + private IConnector connector; private Node filesRoot; private Node configsRoot; public string connectAnywayDisabled; @@ -54,9 +55,10 @@ enum NodeStatus private ModFileDict filesForUI; private ModListDiff modListDiff; - public JoinDataWindow(RemoteData remote) + public JoinDataWindow(RemoteData remote, IConnector connector) { this.remote = remote; + this.connector = connector; closeOnAccept = false; closeOnCancel = false; @@ -253,7 +255,7 @@ void RefreshFiles() } if (MpUI.ButtonTextWithTip(btnCenter, "MpFixAndRestart".Translate(), "MpRestartNeeded".Translate())) - Find.WindowStack.Add(new FixAndRestartWindow(remote)); + Find.WindowStack.Add(new FixAndRestartWindow(remote, connector)); if (Widgets.ButtonText(btnCenter.Right(150f), "MpMismatchQuit".Translate())) { @@ -678,14 +680,16 @@ void DrawModListItem(Vector2 topLeft, string name, string tip, ContentSource sou public class FixAndRestartWindow : Window { private RemoteData data; + private IConnector connector; private bool applyModList = true; private bool applyConfigs; public override Vector2 InitialSize => new(400, 200); - public FixAndRestartWindow(RemoteData data) + public FixAndRestartWindow(RemoteData data, IConnector connector) { this.data = data; + this.connector = connector; applyConfigs = data.hasConfigs; closeOnAccept = false; @@ -753,7 +757,7 @@ private void DoRestart() SyncConfigs.MarkApplicableForChildProcess(); } - AutoJoinHandler.SetForChildProcess(data.connector); + AutoJoinHandler.SetForChildProcess(connector); GenCommandLine.Restart(); } } diff --git a/Source/Client/Windows/ModCompatWindow.cs b/Source/Client/Windows/ModCompatWindow.cs index 656aff6a2..f1f589158 100644 --- a/Source/Client/Windows/ModCompatWindow.cs +++ b/Source/Client/Windows/ModCompatWindow.cs @@ -40,7 +40,7 @@ public ModCompatWindow(Window parent, bool popup, bool forceNameSort, Func modNameCache = new(); private Dictionary notesCache = new(); @@ -75,6 +76,12 @@ public override void SetInitialSizeAndPosition() const float ScrollbarWidth = 20f; const float ListInset = 15f; + public void ScrollToModName(string modName) + { + nameFieldStr = modName; + scrollToModName = true; + } + public override void DoWindowContents(Rect inRect) { if (modsHash != ModLister.InstalledModsListHash(true)) @@ -128,6 +135,11 @@ ref Multiplayer.settings.hideTranslationMods GUI.SetNextControlName("mod_search"); nameFieldStr = Widgets.TextField(nameField, nameFieldStr); nameFieldChanged = nameFieldStr != prevNameField; + if (scrollToModName) + { + nameFieldChanged = true; + scrollToModName = false; + } inRect.yMin += CheckboxesHeight + 10f; GUI.BeginGroup(inRect); @@ -249,7 +261,7 @@ private void DoHeaders(float width) // Notes header var notesHeader = headerRow.MaxX(headerRow.width - ScrollbarWidth); - Widgets.Label(notesHeader, $"MpModCompatHeaderNotes".Translate()); + Widgets.Label(notesHeader, "MpModCompatHeaderNotes".Translate()); Widgets.DrawHighlightIfMouseover(notesHeader); } @@ -266,8 +278,13 @@ private void DoModRow(ModMetaData mod, bool alt, Rect row) // Name { + var labelRect = row.Width(NameWidth - Spacing - ListInset); using (MpStyle.Set(nameContainsSearch ? Color.white : Color.grey)) - MpUI.LabelTruncatedWithTip(row.Width(NameWidth - Spacing - ListInset), modName, modNameCache); + MpUI.LabelTruncatedWithTip(labelRect, modName, modNameCache); + + if (Widgets.ButtonInvisible(labelRect) && mod.Source == ContentSource.SteamWorkshop) + SteamUtility.OpenWorkshopPage(mod.GetPublishedFileId()); + row.xMin += NameWidth - ListInset; } @@ -275,23 +292,10 @@ private void DoModRow(ModMetaData mod, bool alt, Rect row) { bool xml = MultiplayerData.IsXmlMod(mod); - var scoreColor = xml ? ColorLibrary.Green : info?.status switch - { - 1 => ColorLibrary.Red, - 2 => ColorLibrary.Orange, - 3 => ColorLibrary.Yellow, - 4 => ColorLibrary.Green, - _ => ColorLibrary.Grey - }; - - var scoreDescKey = xml ? "MpModCompatXmlOnlyDesc" : info?.status switch - { - 1 => "MpModCompatScore1", - 2 => "MpModCompatScore2", - 3 => "MpModCompatScore3", - 4 => "MpModCompatScore4", - _ => "MpModCompatScoreUnk" - }; + var scoreColor = xml ? ColorLibrary.Green : ModCompatibility.ScoreColor(info?.status ?? 0); + + var scoreDescKey = + xml ? "MpModCompatXmlOnlyDesc" : ModCompatibility.ScoreDescription(info?.status ?? 0); var scoreText = xml ? "XML" @@ -368,8 +372,7 @@ private static ModCompatibility TryGetCompatInfo(ModMetaData mod) if (!Multiplayer.settings.showModCompatibility) return null; - return ModCompatibilityManager.LookupByWorkshopId(mod.publishedFileIdInt) ?? - ModCompatibilityManager.LookupByName(mod.Name); + return ModCompatibilityManager.LookupByMod(mod); } private static string SortChar(SortDirection dir) => dir switch @@ -402,6 +405,68 @@ static IEnumerable Transpiler(IEnumerable inst } } + [HarmonyPatch(typeof(Page_ModsConfig), nameof(Page_ModsConfig.DoModRow))] + static class PageModsConfigShowModCompat + { + static IEnumerable Transpiler(IEnumerable insts) + { + var labelMethod = + AccessTools.Method(typeof(Widgets), nameof(Widgets.Label), [typeof(Rect), typeof(string)]); + foreach (var inst in insts) + { + if (inst.Calls(labelMethod)) + { + yield return CodeInstruction.LoadArgument(2); // ModMetaData + yield return CodeInstruction.LoadArgument(0); // Page_ModsConfig + inst.operand = AccessTools.Method(typeof(PageModsConfigShowModCompat), nameof(DrawModLabel)); + } + + yield return inst; + } + } + + public static void DrawModLabel(Rect r, string label, ModMetaData mod, Page_ModsConfig parent) + { + if (!Multiplayer.settings.showModCompatibility) + { + Widgets.Label(r, label); + return; + } + + var compat = ModCompatibilityManager.LookupByMod(mod); + var rect = new Rect(r.x, (float) (r.y + r.height / 2.0 - 12.0), 20f, 24f); + Text.Anchor = TextAnchor.MiddleCenter; + + bool xml = MultiplayerData.IsXmlMod(mod); + var score = xml ? 4 : compat?.status ?? 0; + var scoreColor = ModCompatibility.ScoreColor(score); + Widgets.Label(rect, "[" + score.ToString().Colorize(scoreColor) + "]"); + + if (Mouse.IsOver(rect)) + { + var scoreText = (xml ? "MpModCompatXmlOnlyDesc" : ModCompatibility.ScoreDescription(score)).Translate(); + if (compat?.notes is { Length: > 0 }) + { + scoreText += $"\n{compat.notes}"; + } + + TooltipHandler.TipRegion(rect, () => scoreText, + (int)(r.x + r.y * 56167.0)); + Widgets.DrawHighlight(rect); + } + + if (Widgets.ButtonInvisible(rect)) + { + var modCompatWindow = new ModCompatWindow(parent, true, false, null); + modCompatWindow.ScrollToModName(mod.Name); + Find.WindowStack.Add(modCompatWindow); + } + + Text.Anchor = TextAnchor.MiddleLeft; + Widgets.Label(r.Right(rect.width + 4f), label); + } + } + [HarmonyPatch(typeof(Page_ModsConfig), nameof(Page_ModsConfig.DoBottomButtons))] static class PageModsConfigAddButton diff --git a/Source/Client/Windows/SaveGameWindow.cs b/Source/Client/Windows/SaveGameWindow.cs index 4e6973804..b2eae89a4 100644 --- a/Source/Client/Windows/SaveGameWindow.cs +++ b/Source/Client/Windows/SaveGameWindow.cs @@ -1,4 +1,5 @@ using Multiplayer.Client.Util; +using Multiplayer.Common; using RimWorld; using System.Collections.Generic; using System.IO; @@ -199,7 +200,13 @@ private void Accept(bool currentReplay) { if (curText.Length != 0) { - LongEventHandler.QueueLongEvent(() => Autosaving.SaveGameToFile_Overwrite(curText, currentReplay), "MpSaving", false, null); + LongEventHandler.QueueLongEvent(() => + { + if (!Autosaving.SaveGameToFile_Overwrite(curText, currentReplay)) + return; + + Autosaving.SendAutosavingRequest(JoinPointRequestReason.Save); + }, "MpSaving", false, null); Close(); } } diff --git a/Source/Common/ChatCommands.cs b/Source/Common/ChatCommands.cs deleted file mode 100644 index 0a474d484..000000000 --- a/Source/Common/ChatCommands.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Collections.Generic; - -namespace Multiplayer.Common; - -public class ChatCmdManager -{ - private readonly IDictionary handlers = new Dictionary(); - - public void Handle(IChatSource source, string cmd) - { - var parts = cmd.Split(' '); - if (handlers.TryGetValue(parts[0], out var handler)) - { - if (handler.requiresHost && source is ServerPlayer { IsHost: false }) - source.SendMsg("No permission"); - else - handler.Handle(source, parts.SubArray(1)); - } - else - { - source.SendMsg("Invalid command"); - } - } - - public void AddCommandHandler(string name, ChatCmdHandler handler) => handlers[name] = handler; -} - -public abstract class ChatCmdHandler -{ - public bool requiresHost; - - public MultiplayerServer Server => MultiplayerServer.instance!; - - public abstract void Handle(IChatSource source, string[] args); - - public void SendNoPermission(ServerPlayer player) - { - player.SendMsg("You don't have permission."); - } - - public ServerPlayer? FindPlayer(string username) - { - return Server.GetPlayer(username); - } -} - -public class ChatCmdJoinPoint : ChatCmdHandler -{ - public ChatCmdJoinPoint() - { - requiresHost = true; - } - - public override void Handle(IChatSource source, string[] args) - { - if (!Server.worldData.TryStartJoinPointCreation(true)) - source.SendMsg("Join point creation already in progress."); - } -} - -public class ChatCmdKick : ChatCmdHandler -{ - public ChatCmdKick() - { - requiresHost = true; - } - - public override void Handle(IChatSource source, string[] args) - { - if (args.Length < 1) - { - source.SendMsg("No username provided."); - return; - } - - var toKick = FindPlayer(args[0]); - if (toKick == null) - { - source.SendMsg("Couldn't find the player."); - return; - } - - if (toKick.IsHost) - { - source.SendMsg("You can't kick the host."); - return; - } - - toKick.Disconnect(MpDisconnectReason.Kick); - } -} - -public class ChatCmdStop : ChatCmdHandler -{ - public ChatCmdStop() - { - requiresHost = true; - } - - public override void Handle(IChatSource source, string[] args) - { - Server.running = false; - } -} diff --git a/Source/Common/ChatCommands/BuiltIn/AnnounceCommand.cs b/Source/Common/ChatCommands/BuiltIn/AnnounceCommand.cs new file mode 100644 index 000000000..cbd37d284 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/AnnounceCommand.cs @@ -0,0 +1,12 @@ +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("announce", Description = "Broadcast a server announcement.", Usage = "announce ", RequiresHost = true)] +public class AnnounceCommand : ChatCommand +{ + protected override void Execute(ChatCommandContext context, AnnounceCommandArgs args) + { + Server.SendChat($"[Announcement] {args.Message}"); + } +} + +public readonly record struct AnnounceCommandArgs(string Message); diff --git a/Source/Common/ChatCommands/BuiltIn/HelpCommand.cs b/Source/Common/ChatCommands/BuiltIn/HelpCommand.cs new file mode 100644 index 000000000..b03c3f2a5 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/HelpCommand.cs @@ -0,0 +1,54 @@ +using System.Linq; +using Multiplayer.Common; + +namespace Multiplayer.Common.ChatCommands; + +public readonly record struct HelpCommandArgs(string? Command = null); + +[ChatCommand("help", "?", Description = "Show available commands or detailed help for one command.", Usage = "help [command]")] +public class HelpCommand : ChatCommand +{ + protected override void Execute(ChatCommandContext context, HelpCommandArgs args) + { + var source = context.Source; + if (args.Command != null) + { + if (Server.chatCmdManager.TryGetCommandInfo(args.Command, out var command)) + { + source.SendMsg($"Command: {command.DisplayNames}"); + + if (!string.IsNullOrEmpty(command.Description)) + source.SendMsg($"Description: {command.Description}"); + + if (!string.IsNullOrEmpty(command.Usage)) + source.SendRawMsg($"Usage: {command.Usage}"); + + if (command.RequiresHost) + source.SendMsg("Requires host permissions."); + + return; + } + + source.SendMsg($"Unknown command '{args.Command}'. Use help to list available commands."); + return; + } + + var onlyUsable = source is ServerPlayer { helpOnlyUsableCommands: true }; + source.SendMsg(onlyUsable ? "Available commands you can use:" : "Available commands:"); + + var commands = Server.chatCmdManager.GetCommandInfos(); + if (onlyUsable) + commands = commands.Where(command => command.CanUse(source)); + + foreach (var command in commands) + { + var summary = command.Description; + if (command.RequiresHost) + summary = string.IsNullOrEmpty(summary) ? "Requires host permissions." : $"{summary} Requires host permissions."; + + source.SendMsg($"- {command.DisplayNames}: {summary}"); + } + + source.SendRawMsg("Use help for detailed usage."); + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/JoinPointCommand.cs b/Source/Common/ChatCommands/BuiltIn/JoinPointCommand.cs new file mode 100644 index 000000000..1ae39df7e --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/JoinPointCommand.cs @@ -0,0 +1,13 @@ +using Multiplayer.Common; + +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("joinpoint", Description = "Create a fresh join point immediately.", Usage = "joinpoint", RequiresHost = true)] +public class JoinPointCommand : ChatCommand +{ + public override void Execute(ChatCommandContext context) + { + if (!Server.worldData.TryStartJoinPointCreation(true, sourcePlayer: context.Source as ServerPlayer)) + context.Source.SendMsg("Join point creation already in progress."); + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/KickCommand.cs b/Source/Common/ChatCommands/BuiltIn/KickCommand.cs new file mode 100644 index 000000000..903ad13c4 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/KickCommand.cs @@ -0,0 +1,22 @@ +using Multiplayer.Common; + +namespace Multiplayer.Common.ChatCommands; + +public readonly record struct KickCommandArgs([ChatArgument("username")] ServerPlayer Player); + +[ChatCommand("kick", Description = "Disconnect a player by username.", Usage = "kick ", RequiresHost = true)] +public class KickCommand : ChatCommand +{ + protected override void Execute(ChatCommandContext context, KickCommandArgs args) + { + ServerPlayer toKick = args.Player; + + if (toKick.IsHost) + { + context.Source.SendMsg("You can't kick the host."); + return; + } + + toKick.Disconnect(MpDisconnectReason.Kick); + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/ModsCommand.cs b/Source/Common/ChatCommands/BuiltIn/ModsCommand.cs new file mode 100644 index 000000000..cc9a98515 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/ModsCommand.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using Multiplayer.Common.Networking.Packet; + +namespace Multiplayer.Common.ChatCommands; + +public readonly record struct ModsCommandArgs( + [ChatArgument("page")] int Page = 1, + [ChatArgument("amount")] int Amount = 20 +); + +[ChatCommand("mods", Description = "Show the server mod list summary.", Usage = "mods [page] [amount]")] +public class ModsCommand : ChatCommand +{ + protected override void Execute(ChatCommandContext context, ModsCommandArgs args) + { + if (args.Page < 1 || args.Amount < 1) + { + context.Source.SendMsg("Usage: mods [page] [amount]"); + return; + } + + var initData = Server.InitData; + if (initData == null) + { + context.Source.SendMsg("Mod data is not available yet."); + return; + } + + try + { + var mods = ClientInitDataPacket.ModData.ListBinder.Deserialize(initData.RawData); + var totalPages = Math.Max(1, (int)Math.Ceiling(mods.Count / (double)args.Amount)); + if (args.Page > totalPages) + { + context.Source.SendMsg($"Page {args.Page} is out of range. Last page is {totalPages}."); + return; + } + + var pageMods = mods + .Skip((args.Page - 1) * args.Amount) + .Take(args.Amount) + .ToList(); + + context.Source.SendMsg($"RimWorld: {initData.RwVersion}"); + context.Source.SendMsg(totalPages == 1 + ? $"Mods ({mods.Count}):" + : $"Mods ({mods.Count}), page {args.Page}/{totalPages}:" + ); + + foreach (var mod in pageMods) + context.Source.SendMsg($"- {mod.name} ({mod.packageIdNonUnique})"); + } + catch (Exception e) + { + ServerLog.Error($"Failed to read server mod data: {e}"); + context.Source.SendMsg("Could not read mod data."); + } + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/PlayerCommandUtility.cs b/Source/Common/ChatCommands/BuiltIn/PlayerCommandUtility.cs new file mode 100644 index 000000000..7a6fc8921 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/PlayerCommandUtility.cs @@ -0,0 +1,12 @@ +namespace Multiplayer.Common.ChatCommands; + +internal static class PlayerCommandUtility +{ + public static string GetRole(ServerPlayer player) + { + if (player.IsHost) + return "host"; + + return player.IsArbiter ? "arbiter" : "player"; + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/PlayersCommand.cs b/Source/Common/ChatCommands/BuiltIn/PlayersCommand.cs new file mode 100644 index 000000000..b85ee75dd --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/PlayersCommand.cs @@ -0,0 +1,25 @@ +using System.Linq; + +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("players", "list", Description = "List connected players.", Usage = "players")] +public class PlayersCommand : ChatCommand +{ + public override void Execute(ChatCommandContext context) + { + var players = Server.playerManager.Players.OrderBy(player => player.id).ToList(); + if (players.Count == 0) + { + context.Source.SendMsg("No players connected."); + return; + } + + context.Source.SendMsg($"Players ({players.Count}):"); + foreach (var player in players) + { + context.Source.SendMsg( + $"- #{player.id} {player.Username} [{player.status}] {PlayerCommandUtility.GetRole(player)} faction={player.FactionId} map={player.currentMapId} ping={player.Latency}ms behind={player.ExtrapolatedTicksBehind}" + ); + } + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/ResyncCommand.cs b/Source/Common/ChatCommands/BuiltIn/ResyncCommand.cs new file mode 100644 index 000000000..6a954e30d --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/ResyncCommand.cs @@ -0,0 +1,29 @@ +using Multiplayer.Common.Networking.Packet; + +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("resync", Description = "Force a player to reload world data.", Usage = "resync ", RequiresHost = true)] +public class ResyncCommand : ChatCommand +{ + protected override void Execute(ChatCommandContext context, ResyncCommandArgs args) + { + ServerPlayer player = args.Player; + + if (player.IsHost) + { + context.Source.SendMsg("You can't force-resync the host."); + return; + } + + if (!player.IsPlaying) + { + context.Source.SendMsg("Player is not in the playing state."); + return; + } + + player.SendPacket(new ServerRequestRejoinPacket()); + context.Source.SendMsg($"Resync requested for {player.Username}."); + } +} + +public readonly record struct ResyncCommandArgs([ChatArgument("username")] ServerPlayer Player); diff --git a/Source/Common/ChatCommands/BuiltIn/StatusCommand.cs b/Source/Common/ChatCommands/BuiltIn/StatusCommand.cs new file mode 100644 index 000000000..243d66562 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/StatusCommand.cs @@ -0,0 +1,24 @@ +using System.Linq; + +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("status", Description = "Show multiplayer server status.", Usage = "status")] +public class StatusCommand : ChatCommand +{ + public override void Execute(ChatCommandContext context) + { + var worldState = Server.worldData.savedGame != null ? "loaded" : "not loaded"; + var joinPointState = Server.worldData.CreatingJoinPoint + ? "creating" + : Server.worldData.lastJoinPointAtTick >= 0 + ? $"last at tick {Server.worldData.lastJoinPointAtTick}" + : "never"; + + context.Source.SendMsg($"Server: {(Server.running ? "running" : "stopped")}"); + context.Source.SendMsg($"World: {worldState}, maps={Server.worldData.mapData.Count}, join point={joinPointState}"); + context.Source.SendMsg($"Ticks: game={Server.gameTimer}, net={Server.NetTimer}, work={Server.workTicks}"); + context.Source.SendMsg( + $"Players: connected={Server.playerManager.Players.Count}, joined={Server.JoinedPlayers.Count()}, playing={Server.PlayingPlayers.Count()}" + ); + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/StopCommand.cs b/Source/Common/ChatCommands/BuiltIn/StopCommand.cs new file mode 100644 index 000000000..4ce8abc00 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/StopCommand.cs @@ -0,0 +1,12 @@ +using Multiplayer.Common; + +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("stop", Description = "Stop the standalone server.", Usage = "stop", RequiresHost = true)] +public class StopCommand : ChatCommand +{ + public override void Execute(ChatCommandContext context) + { + Server.running = false; + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/TimeControlCommands.cs b/Source/Common/ChatCommands/BuiltIn/TimeControlCommands.cs new file mode 100644 index 000000000..1f29a1075 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/TimeControlCommands.cs @@ -0,0 +1,88 @@ +using System.Linq; + +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("pause", Description = "Pause the multiplayer session.", Usage = "pause")] +public class PauseCommand : TimeControlCommand +{ + public override void Execute(ChatCommandContext context) + { + TimeControlCommandUtil.SetSpeed(Server, context.Source, TimeVote.Paused); + context.Source.SendMsg("Speed set to Paused."); + } +} + +[ChatCommand("unpause", Description = "Resume the multiplayer session at normal speed.", Usage = "unpause")] +public class UnpauseCommand : TimeControlCommand +{ + public override void Execute(ChatCommandContext context) + { + TimeControlCommandUtil.SetSpeed(Server, context.Source, TimeVote.Normal); + context.Source.SendMsg("Speed set to Normal."); + } +} + +[ChatCommand("speed", Description = "Set global multiplayer speed.", Usage = "speed <1-4>")] +public class SpeedCommand : TimeControlCommand +{ + protected override void Execute(ChatCommandContext context, SpeedCommandArgs args) + { + if (args.Speed is < 1 or > 4) + { + context.Source.SendRawMsg("Usage: speed <1-4>"); + return; + } + + var speed = (TimeVote)args.Speed; + TimeControlCommandUtil.SetSpeed(Server, context.Source, speed); + context.Source.SendMsg($"Speed set to {speed}."); + } +} + +public readonly record struct SpeedCommandArgs([ChatArgument("speed")] int Speed); + +public abstract class TimeControlCommand : ChatCommand +{ + public override bool CanUse(IChatSource source) => TimeControlCommandUtil.CanUse(Server, source); +} + +public abstract class TimeControlCommand : ChatCommand +{ + public override bool CanUse(IChatSource source) => TimeControlCommandUtil.CanUse(Server, source); +} + +internal static class TimeControlCommandUtil +{ + public static bool CanUse(MultiplayerServer server, IChatSource source) + { + return server.settings.timeControl != TimeControl.HostOnly || + source is not ServerPlayer { IsHost: false } || + !server.PlayingPlayers.Any(player => player.IsHost); + } + + public static void SetSpeed(MultiplayerServer server, IChatSource source, TimeVote speed) + { + var sourcePlayer = source as ServerPlayer; + var factionId = sourcePlayer?.FactionId ?? ScheduledCommand.NoFaction; + + if (server.settings.timeControl == TimeControl.LowestWins) + { + server.commands.Send( + CommandType.TimeSpeedVote, + factionId, + ScheduledCommand.Global, + ByteWriter.GetBytes(speed, ScheduledCommand.Global), + sourcePlayer + ); + return; + } + + server.commands.Send( + CommandType.GlobalTimeSpeed, + factionId, + ScheduledCommand.Global, + [(byte)speed], + sourcePlayer + ); + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/WhoisCommand.cs b/Source/Common/ChatCommands/BuiltIn/WhoisCommand.cs new file mode 100644 index 000000000..0cdb75160 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/WhoisCommand.cs @@ -0,0 +1,23 @@ +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("whois", Description = "Show details for a connected player.", Usage = "whois ")] +public class WhoisCommand : ChatCommand +{ + protected override void Execute(ChatCommandContext context, WhoisCommandArgs args) + { + ServerPlayer player = args.Player; + + context.Source.SendMsg($"Player: {player.Username} (#{player.id})"); + context.Source.SendMsg($"Status: {player.status}"); + context.Source.SendMsg($"Role: {PlayerCommandUtility.GetRole(player)}"); + context.Source.SendMsg($"Faction: {player.FactionId}"); + context.Source.SendMsg($"Map: {player.currentMapId}"); + context.Source.SendMsg($"Latency: {player.Latency}ms"); + context.Source.SendMsg($"Ticks behind: {player.ExtrapolatedTicksBehind}"); + + if (player.steamId != 0 || !string.IsNullOrWhiteSpace(player.steamPersonaName)) + context.Source.SendMsg($"Steam: {player.steamPersonaName} ({player.steamId})"); + } +} + +public readonly record struct WhoisCommandArgs([ChatArgument("username")] ServerPlayer Player); diff --git a/Source/Common/ChatCommands/ChatCommand.Generic.cs b/Source/Common/ChatCommands/ChatCommand.Generic.cs new file mode 100644 index 000000000..594185398 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommand.Generic.cs @@ -0,0 +1,29 @@ +using System; + +namespace Multiplayer.Common.ChatCommands; + +public abstract class ChatCommand : ChatCommand +{ + private ChatCommandParser? parser; + + public void SetParser(ChatCommandParser parser) + { + this.parser = parser; + } + + public sealed override void Execute(ChatCommandContext context) + { + if (parser == null) + throw new InvalidOperationException($"No generated parser was registered for {GetType().FullName}."); + + if (!parser(context, out var args, out var error)) + { + context.Source.SendRawMsg(error ?? "Invalid command arguments."); + return; + } + + Execute(context, args); + } + + protected abstract void Execute(ChatCommandContext context, TArgs args); +} diff --git a/Source/Common/ChatCommands/ChatCommand.cs b/Source/Common/ChatCommands/ChatCommand.cs new file mode 100644 index 000000000..d00be6d4c --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommand.cs @@ -0,0 +1,29 @@ +using Multiplayer.Common; + +namespace Multiplayer.Common.ChatCommands; + +public abstract class ChatCommand : IChatCommand +{ + private bool requiresHost; + + protected MultiplayerServer Server => MultiplayerServer.instance!; + + internal void ConfigurePermissions(bool requiresHost) + { + this.requiresHost = requiresHost; + } + + public virtual bool CanUse(IChatSource source) + { + return !requiresHost || source is not ServerPlayer { IsHost: false }; + } + + public virtual string PermissionDeniedMessage => "No permission"; + + public abstract void Execute(ChatCommandContext context); + + public ServerPlayer? FindPlayer(string username) + { + return Server.GetPlayer(username); + } +} diff --git a/Source/Common/ChatCommands/ChatCommandArgumentReader.cs b/Source/Common/ChatCommands/ChatCommandArgumentReader.cs new file mode 100644 index 000000000..f09eeeee0 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandArgumentReader.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Multiplayer.Common.ChatCommands; + +public static class ChatCommandArgumentReader +{ + private delegate bool Parser(string raw, out T value); + + public static bool HasArgument(ChatCommandContext context, int index, string missingMessage, out string? error) + { + if (context.RawArgs.Count > index) + { + error = null; + return true; + } + + error = missingMessage; + return false; + } + + public static string JoinRest(IReadOnlyList args, int startIndex) + { + if (startIndex >= args.Count) + return string.Empty; + + var values = new string[args.Count - startIndex]; + for (var i = 0; i < values.Length; i++) + values[i] = args[startIndex + i]; + + return string.Join(" ", values); + } + + public static bool TryParseInt(string raw, string name, out int value, out string? error) + { + return TryParse(raw, name, int.TryParse, out value, out error); + } + + public static bool TryParseBool(string raw, string name, out bool value, out string? error) + { + return TryParse(raw, name, bool.TryParse, out value, out error); + } + + public static bool TryParseFloat(string raw, string name, out float value, out string? error) + { + return TryParse(raw, name, TryParseFloatInvariant, out value, out error); + } + + public static bool TryParseEnum(string raw, string name, out T value, out string? error) where T : struct + { + return TryParse(raw, name, TryParseEnumIgnoreCase, out value, out error); + } + + public static bool TryParsePlayer(ChatCommandContext context, string raw, string name, out ServerPlayer value, out string? error) + { + var server = MultiplayerServer.instance!; + var exact = server.playerManager.Players.FirstOrDefault(player => + string.Equals(player.Username, raw, System.StringComparison.OrdinalIgnoreCase) + ); + + if (exact != null) + { + value = exact; + error = null; + return true; + } + + var matches = server.playerManager.Players + .Where(player => player.Username.Contains(raw, System.StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matches.Count == 1) + { + value = matches[0]; + error = null; + return true; + } + + value = null!; + error = matches.Count == 0 + ? "Couldn't find the player." + : $"Player name '{raw}' is ambiguous: {string.Join(", ", matches.Select(player => player.Username))}."; + return false; + } + + private static bool TryParse(string raw, string name, Parser parseMethod, out T value, out string? error) + { + if (parseMethod(raw, out value)) + { + error = null; + return true; + } + + error = InvalidValueMessage(name); + return false; + } + + private static bool TryParseFloatInvariant(string raw, out float value) + { + return float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + } + + private static bool TryParseEnumIgnoreCase(string raw, out T value) where T : struct + { + return System.Enum.TryParse(raw, true, out value); + } + + private static string InvalidValueMessage(string name) => $"Invalid value for '{name}'."; +} diff --git a/Source/Common/ChatCommands/ChatCommandContext.cs b/Source/Common/ChatCommands/ChatCommandContext.cs new file mode 100644 index 000000000..b8ded95dc --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandContext.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Multiplayer.Common.ChatCommands; + +public sealed record ChatCommandContext( + IChatSource Source, + string CommandName, + IReadOnlyList RawArgs +); diff --git a/Source/Common/ChatCommands/ChatCommandInfo.cs b/Source/Common/ChatCommands/ChatCommandInfo.cs new file mode 100644 index 000000000..f0d41b664 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandInfo.cs @@ -0,0 +1,18 @@ +using System.Linq; + +namespace Multiplayer.Common.ChatCommands; + +public sealed class ChatCommandInfo(IChatCommand command, string[] names, string description, string usage, bool requiresHost) +{ + public IChatCommand Command { get; } = command; + public string[] Names { get; } = [.. names]; + public string PrimaryName => Names.First(); + public string DisplayNames => string.Join(", ", Names); + public string Description { get; } = description; + public string Usage { get; } = usage; + public bool RequiresHost { get; } = requiresHost; + + public bool CanUse(IChatSource source) => Command.CanUse(source); + + public string PermissionDeniedMessage => Command.PermissionDeniedMessage; +} diff --git a/Source/Common/ChatCommands/ChatCommandManager.cs b/Source/Common/ChatCommands/ChatCommandManager.cs new file mode 100644 index 000000000..1b59f4950 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandManager.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Multiplayer.Common; + +namespace Multiplayer.Common.ChatCommands; + +public class ChatCommandManager +{ + private readonly MultiplayerServer server; + private readonly IDictionary handlers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public ChatCommandManager(MultiplayerServer server) + { + this.server = server; + } + + public void Handle(IChatSource source, string cmd) + { + if (!CommandTokenizer.TryTokenize(cmd, out var parts, out var error)) + { + source.SendMsg(error ?? "Invalid command arguments."); + return; + } + + if (parts.Length == 0) + return; + + if (handlers.TryGetValue(parts[0], out var registration)) + { + if (!registration.Info.CanUse(source)) + source.SendMsg(registration.Info.PermissionDeniedMessage); + else + registration.Command.Execute(new ChatCommandContext(source, parts[0], parts.Skip(1).ToArray())); + } + else + { + source.SendMsg("Invalid command. Use help or ? to list available commands."); + } + } + + public void AddCommand(string name, IChatCommand command, ChatCommandInfo info) => + AddCommands([name], command, info); + + public void AddCommand(string name, IChatCommand command) + { + var names = handlers + .Where(entry => ReferenceEquals(entry.Value.Command, command)) + .Select(entry => entry.Key) + .Append(name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var info = new ChatCommandInfo(command, names, GetDescription(command), GetUsage(command), RequiresHost(command)); + AddCommands(names, command, info); + } + + public void AddCommands(string[] names, IChatCommand command, string description, string usage, bool requiresHost) + { + var registeredNames = names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var info = new ChatCommandInfo(command, registeredNames, description, usage, requiresHost); + AddCommands(registeredNames, command, info); + } + + private void AddCommands(string[] names, IChatCommand command, ChatCommandInfo info) + { + if (command is ChatCommand chatCommand) + chatCommand.ConfigurePermissions(info.RequiresHost); + + foreach (var name in names) + handlers[name] = new ChatCommandRegistration(command, info); + } + + private static string GetDescription(IChatCommand command) => + command is IChatCommandMetadata metadata ? metadata.Description : string.Empty; + + private static string GetUsage(IChatCommand command) => + command is IChatCommandMetadata metadata ? metadata.Usage : string.Empty; + + private static bool RequiresHost(IChatCommand command) => + command is IChatCommandMetadata { RequiresHost: true }; + + public IEnumerable GetCommandNames() => handlers.Keys.OrderBy(name => name); + + public IEnumerable GetCommandInfos() + { + return handlers + .Select(entry => entry.Value.Info) + .Distinct() + .OrderBy(info => info.PrimaryName); + } + + public bool TryGetCommandInfo(string name, out ChatCommandInfo info) + { + if (handlers.TryGetValue(name, out var registration)) + { + info = registration.Info; + return true; + } + + info = null!; + return false; + } +} diff --git a/Source/Common/ChatCommands/ChatCommandParser.cs b/Source/Common/ChatCommands/ChatCommandParser.cs new file mode 100644 index 000000000..0acdd65c2 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandParser.cs @@ -0,0 +1,3 @@ +namespace Multiplayer.Common.ChatCommands; + +public delegate bool ChatCommandParser(ChatCommandContext context, out TArgs args, out string? error); diff --git a/Source/Common/ChatCommands/ChatCommandRegistration.cs b/Source/Common/ChatCommands/ChatCommandRegistration.cs new file mode 100644 index 000000000..bc4cb99d4 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandRegistration.cs @@ -0,0 +1,3 @@ +namespace Multiplayer.Common.ChatCommands; + +public sealed record ChatCommandRegistration(IChatCommand Command, ChatCommandInfo Info); diff --git a/Source/Common/ChatCommands/ChatCommandRegistry.cs b/Source/Common/ChatCommands/ChatCommandRegistry.cs new file mode 100644 index 000000000..993c21d73 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandRegistry.cs @@ -0,0 +1,19 @@ +namespace Multiplayer.Common.ChatCommands; + +/// +/// The concrete command registry is generated by ChatCommandRegistryGenerator +/// from classes annotated with [ChatCommand]. +/// +/// +/// This partial declaration exists so the generated registry has a stable source +/// location in the IDE. The generated half provides Register, creates each +/// built-in command, attaches generated argument parsers, and registers command +/// names and aliases with . +/// +internal static partial class ChatCommandRegistry +{ + /// + /// Registers all source-generated chat commands and aliases. + /// + public static partial void Register(ChatCommandManager manager, MultiplayerServer server); +} diff --git a/Source/Common/ChatCommands/CommandTokenizer.cs b/Source/Common/ChatCommands/CommandTokenizer.cs new file mode 100644 index 000000000..80131d430 --- /dev/null +++ b/Source/Common/ChatCommands/CommandTokenizer.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Multiplayer.Common.ChatCommands; + +internal struct CommandTokenizer +{ + private const string MissingClosingQuoteError = "Invalid command arguments: missing closing quote."; + + private readonly string input; + private int index; + + private CommandTokenizer(string input) + { + this.input = input; + index = 0; + } + + public static bool TryTokenize(string input, out string[] tokens, out string? error) + { + var tokenizer = new CommandTokenizer(input); + return tokenizer.TryReadAll(out tokens, out error); + } + + private bool TryReadAll(out string[] tokens, out string? error) + { + var result = new List(); + + while (true) + { + SkipWhitespace(); + if (index >= input.Length) + { + tokens = [.. result]; + error = null; + return true; + } + + if (!TryReadToken(out var token, out error)) + { + tokens = []; + return false; + } + + result.Add(token); + } + } + + private void SkipWhitespace() + { + while (index < input.Length && char.IsWhiteSpace(input[index])) + index++; + } + + private bool TryReadToken(out string token, out string? error) + { + var tokenPartStart = index; + StringBuilder? builder = null; + + while (index < input.Length && !char.IsWhiteSpace(input[index])) + { + if (input[index] == '"') + { + builder ??= new StringBuilder(); + builder.Append(input, tokenPartStart, index - tokenPartStart); + + index++; + if (!TryReadQuotedToken(builder, out error)) + { + token = string.Empty; + return false; + } + + tokenPartStart = index; + continue; + } + + ReadUnquotedToken(); + } + + if (builder == null) + { + token = input[tokenPartStart..index]; + } + else + { + builder.Append(input, tokenPartStart, index - tokenPartStart); + token = builder.ToString(); + } + + error = null; + return true; + } + + private bool TryReadQuotedToken(StringBuilder builder, out string? error) + { + while (index < input.Length) + { + var c = input[index++]; + + if (c == '"') + { + error = null; + return true; + } + + if (c == '\\') + { + if (index >= input.Length) + { + builder.Append('\\'); + continue; + } + + builder.Append(input[index++]); + continue; + } + + builder.Append(c); + } + + error = MissingClosingQuoteError; + return false; + } + + private void ReadUnquotedToken() + { + while (index < input.Length && !char.IsWhiteSpace(input[index]) && input[index] != '"') + index++; + } +} diff --git a/Source/Common/ChatCommands/IChatCommand.cs b/Source/Common/ChatCommands/IChatCommand.cs new file mode 100644 index 000000000..520e022d5 --- /dev/null +++ b/Source/Common/ChatCommands/IChatCommand.cs @@ -0,0 +1,10 @@ +namespace Multiplayer.Common.ChatCommands; + +public interface IChatCommand +{ + bool CanUse(IChatSource source); + + string PermissionDeniedMessage { get; } + + void Execute(ChatCommandContext context); +} diff --git a/Source/Common/ChatCommands/LegacyChatCommands.cs b/Source/Common/ChatCommands/LegacyChatCommands.cs new file mode 100644 index 000000000..ea94f6ee3 --- /dev/null +++ b/Source/Common/ChatCommands/LegacyChatCommands.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using Multiplayer.Common.ChatCommands; + +namespace Multiplayer.Common; + +[Obsolete("Use Multiplayer.Common.ChatCommands.ChatCommandInfo instead.")] +public sealed class ChatCmdInfo(ChatCmdHandler handler, string[] names) +{ + public ChatCmdHandler Handler { get; } = handler; + public string[] Names { get; } = names; + public string PrimaryName => Names.First(); + public string DisplayNames => string.Join(", ", Names); +} + +[Obsolete("Use Multiplayer.Common.ChatCommands.ChatCommand or ChatCommand instead.")] +public abstract class ChatCmdHandler : IChatCommand, IChatCommandMetadata +{ + public bool requiresHost; + + public MultiplayerServer Server => MultiplayerServer.instance!; + + public virtual string Description => string.Empty; + public virtual string Usage => string.Empty; + bool IChatCommandMetadata.RequiresHost => requiresHost; + public virtual string PermissionDeniedMessage => "No permission"; + + public virtual bool CanUse(IChatSource source) + { + return !requiresHost || source is not ServerPlayer { IsHost: false }; + } + + public void Execute(ChatCommandContext context) + { + Handle(context.Source, context.RawArgs.ToArray()); + } + + public abstract void Handle(IChatSource source, string[] args); + + public void SendNoPermission(ServerPlayer player) + { + player.SendMsg("You don't have permission."); + } + + public ServerPlayer? FindPlayer(string username) + { + return Server.GetPlayer(username); + } +} + +internal interface IChatCommandMetadata +{ + string Description { get; } + string Usage { get; } + bool RequiresHost { get; } +} diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index 5e5c84ae1..9e94cb170 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -12,15 +12,20 @@ - + - + + + + + + diff --git a/Source/Common/IChatSource.cs b/Source/Common/IChatSource.cs index 83fb0b465..a272a6e63 100644 --- a/Source/Common/IChatSource.cs +++ b/Source/Common/IChatSource.cs @@ -3,4 +3,5 @@ public interface IChatSource { void SendMsg(string msg); + void SendRawMsg(string msg); } diff --git a/Source/Common/JoinPointRequestReason.cs b/Source/Common/JoinPointRequestReason.cs new file mode 100644 index 000000000..129f2fe65 --- /dev/null +++ b/Source/Common/JoinPointRequestReason.cs @@ -0,0 +1,7 @@ +namespace Multiplayer.Common; + +public enum JoinPointRequestReason : byte +{ + Save = 1, + WorldTravel = 2, +} diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 88ddbc00b..7d3940f8b 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using HarmonyLib; +using Multiplayer.Common.ChatCommands; using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Common @@ -36,7 +37,7 @@ static MultiplayerServer() public WorldData worldData; public FreezeManager freezeManager; public CommandHandler commands; - public ChatCmdManager chatCmdManager; + public ChatCommandManager chatCmdManager; public PlayerManager playerManager; public List netManagers = []; public IEnumerable JoinedPlayers => playerManager.JoinedPlayers; @@ -74,6 +75,7 @@ static MultiplayerServer() public int NetTimer { get; private set; } public bool IsStandaloneServer { get; set; } + public StandalonePersistence? persistence; public MultiplayerServer(ServerSettings settings) { @@ -82,12 +84,10 @@ public MultiplayerServer(ServerSettings settings) worldData = new WorldData(this); freezeManager = new FreezeManager(this); commands = new CommandHandler(this); - chatCmdManager = new ChatCmdManager(); + chatCmdManager = new ChatCommandManager(this); playerManager = new PlayerManager(this); - RegisterChatCmd("joinpoint", new ChatCmdJoinPoint()); - RegisterChatCmd("kick", new ChatCmdKick()); - RegisterChatCmd("stop", new ChatCmdStop()); + ChatCommandRegistry.Register(chatCmdManager, this); initDataSource.SetResult(null); } @@ -269,10 +269,20 @@ public void SendChat(string msg) public void SendNotification(string key, params string[] args) => SendToPlaying(new ServerNotificationPacket(key) { args = args }); + public void RegisterChatCommand(string commandName, IChatCommand command) => + chatCmdManager.AddCommand(commandName, command); + + public void RegisterChatCommand(string[] commandNames, IChatCommand command, string description = "", string usage = "", bool requiresHost = false) => + chatCmdManager.AddCommands(commandNames, command, description, usage, requiresHost); + + [Obsolete("Use RegisterChatCommand instead.")] public void RegisterChatCmd(string cmdName, ChatCmdHandler handler) => - chatCmdManager.AddCommandHandler(cmdName, handler); + RegisterChatCommand(cmdName, handler); + + public void HandleChatCommand(IChatSource source, string command) => chatCmdManager.Handle(source, command); - public void HandleChatCmd(IChatSource source, string cmd) => chatCmdManager.Handle(source, cmd); + [Obsolete("Use HandleChatCommand instead.")] + public void HandleChatCmd(IChatSource source, string cmd) => HandleChatCommand(source, cmd); public Task InitDataTask() => initDataSource.Task; diff --git a/Source/Common/Networking/ConnectionBase.cs b/Source/Common/Networking/ConnectionBase.cs index 9499534c0..14ab1b872 100644 --- a/Source/Common/Networking/ConnectionBase.cs +++ b/Source/Common/Networking/ConnectionBase.cs @@ -166,7 +166,7 @@ public virtual void HandleReceiveRaw(ByteReader data, bool reliable) protected virtual void HandleReceiveMsg(int msgId, int fragState, ByteReader reader, bool reliable) { if (msgId is < 0 or >= (int)Packets.Count) - throw new PacketReadException($"Bad packet id {msgId}"); + throw new PacketBadIdException(msgId); Packets packetType = (Packets)msgId; if (reader.Left > MaxSinglePacketSize) diff --git a/Source/Common/Networking/Packet/AutosavingPacket.cs b/Source/Common/Networking/Packet/AutosavingPacket.cs new file mode 100644 index 000000000..1b0b55b1a --- /dev/null +++ b/Source/Common/Networking/Packet/AutosavingPacket.cs @@ -0,0 +1,12 @@ +namespace Multiplayer.Common.Networking.Packet; + +[PacketDefinition(Packets.Client_Autosaving)] +public record struct ClientAutosavingPacket(JoinPointRequestReason reason) : IPacket +{ + public JoinPointRequestReason reason = reason; + + public void Bind(PacketBuffer buf) + { + buf.BindEnum(ref reason); + } +} \ No newline at end of file diff --git a/Source/Common/Networking/Packet/ChatPacket.cs b/Source/Common/Networking/Packet/ChatPacket.cs index 52d273d51..c2c57c812 100644 --- a/Source/Common/Networking/Packet/ChatPacket.cs +++ b/Source/Common/Networking/Packet/ChatPacket.cs @@ -4,12 +4,16 @@ namespace Multiplayer.Common.Networking.Packet; public record struct ServerChatPacket : IPacket { public string msg; + public bool rawMessage; public static ServerChatPacket Create(string msg) => new() { msg = msg.Trim() }; + public static ServerChatPacket CreateRaw(string msg) => new() { msg = msg.Trim(), rawMessage = true }; public void Bind(PacketBuffer buf) { buf.Bind(ref msg); + if (buf.isWriting || buf.DataRemaining) + buf.Bind(ref rawMessage); } } @@ -17,11 +21,18 @@ public void Bind(PacketBuffer buf) public record struct ClientChatPacket : IPacket { public string msg; + public bool helpOnlyUsableCommands; - public static ClientChatPacket Create(string msg) => new() { msg = msg.Trim() }; + public static ClientChatPacket Create(string msg, bool helpOnlyUsableCommands = false) => new() + { + msg = msg.Trim(), + helpOnlyUsableCommands = helpOnlyUsableCommands + }; public void Bind(PacketBuffer buf) { buf.Bind(ref msg); + if (buf.isWriting || buf.DataRemaining) + buf.Bind(ref helpOnlyUsableCommands); } } diff --git a/Source/Common/Networking/Packet/IPacket.cs b/Source/Common/Networking/Packet/IPacket.cs index 79f943763..1276ef9df 100644 --- a/Source/Common/Networking/Packet/IPacket.cs +++ b/Source/Common/Networking/Packet/IPacket.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.IO.Compression; namespace Multiplayer.Common.Networking.Packet; @@ -67,6 +69,9 @@ public static Binder Enum() where T: Enum => public static Binder String() => (PacketBuffer buf, ref string obj) => buf.Bind(ref obj); + + public static Binder> List(Binder itemBinder) => + (PacketBuffer buf, ref List obj) => buf.Bind(ref obj, itemBinder); } public static class BinderExtensions @@ -84,6 +89,37 @@ public static T Deserialize(this Binder binder, byte[] src) binder(new PacketReader(new ByteReader(src)), ref obj); return obj; } + + public static Binder Gzipped(this Binder inner, int maxLength = PacketBuffer.DefaultMaxLength) => + (PacketBuffer buf, ref T obj) => + { + if (buf.isWriting) + { + var writer = new ByteWriter(); + inner(new PacketWriter(writer), ref obj); + + using var outputStream = new MemoryStream(); + using (var compress = new DeflateStream(outputStream, CompressionLevel.Optimal)) + { + compress.Write(writer.ToArray(), 0, writer.Position); + } + var compressed = outputStream.ToArray(); + buf.BindBytes(ref compressed, maxLength); + } + else + { + byte[] compressed = []; + buf.BindBytes(ref compressed, maxLength); + using var inputStream = new MemoryStream(compressed); + using var outputStream = new MemoryStream(); + using (var decompress = new DeflateStream(inputStream, CompressionMode.Decompress)) + { + decompress.CopyTo(outputStream); + } + var reader = new ByteReader(outputStream.ToArray()); + inner(new PacketReader(reader), ref obj); + } + }; } public abstract class PacketBuffer(bool isWriting) diff --git a/Source/Common/Networking/Packet/InitDataPacket.cs b/Source/Common/Networking/Packet/InitDataPacket.cs index 3be13b793..ecec5699c 100644 --- a/Source/Common/Networking/Packet/InitDataPacket.cs +++ b/Source/Common/Networking/Packet/InitDataPacket.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace Multiplayer.Common.Networking.Packet; [PacketDefinition(Packets.Server_InitDataRequest)] @@ -23,7 +25,14 @@ public record struct ClientInitDataPacket : IPacket public RoundModeEnum modCtorRoundMode; public RoundModeEnum staticCtorRoundMode; public KeyedDefInfo[] defInfos; - public byte[] rawData; + public bool includeConfigs; + public byte[] rawMods; + + public List Mods + { + get => ModData.ListBinder.Deserialize(rawMods); + set => rawMods = ModData.ListBinder.Serialize(value); + } public void Bind(PacketBuffer buf) { @@ -33,6 +42,91 @@ public void Bind(PacketBuffer buf) buf.BindEnum(ref modCtorRoundMode); buf.BindEnum(ref staticCtorRoundMode); buf.Bind(ref defInfos, BinderOf.Identity()); - buf.BindRemaining(ref rawData, maxLength: MaxRawDataLength); + buf.Bind(ref includeConfigs); + buf.BindRemaining(ref rawMods); + } + + // Based on ContentSource but a byte, so smaller on the network and also doesn't use Verse + // (which is unavailable in the standalone server) + public enum ModSource : byte + { + Undefined, + OfficialModsFolder, + ModsFolder, + SteamWorkshop, + } + + public record struct ModData : IPacketBufferable + { + public string packageIdNonUnique; + public string name; + public ulong publishedFileId; + public ModSource source; + public List files; + public ModConfig? config; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref packageIdNonUnique); + buf.Bind(ref name); + buf.Bind(ref publishedFileId); + buf.BindEnum(ref source); + buf.Bind(ref files, BinderOf.Identity()); + buf.BindWith(ref config, ConfigBinder); + } + + private static readonly Binder ConfigBinder = (PacketBuffer buf, ref ModConfig? modConfig) => + { + if (buf.isWriting) + { + var present = modConfig.HasValue; + buf.Bind(ref present); + if (present) + { + ModConfig config = modConfig.Value; + buf.Bind(ref config); + } + } + else + { + var present = false; + buf.Bind(ref present); + if (present) + { + ModConfig config = new(); + buf.Bind(ref config); + modConfig = config; + } + } + }; + + public static Binder> ListBinder => + BinderOf.List(BinderOf.Identity()).Gzipped(MaxRawDataLength); + } + + public record struct ModFile : IPacketBufferable + { + public string path; + public int hash; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref path); + buf.Bind(ref hash); + } + } + + public record struct ModConfig : IPacketBufferable + { + public string fileName; + public string contents; + + private const int MaxConfigContentLen = 8388608; // 8 MiB + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref fileName); + buf.Bind(ref contents, maxLength:MaxConfigContentLen); + } } } diff --git a/Source/Common/Networking/Packet/JoinDataPacket.cs b/Source/Common/Networking/Packet/JoinDataPacket.cs index 641d493f1..507b59f37 100644 --- a/Source/Common/Networking/Packet/JoinDataPacket.cs +++ b/Source/Common/Networking/Packet/JoinDataPacket.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace Multiplayer.Common.Networking.Packet; [PacketDefinition(Packets.Server_JoinData, allowFragmented: true)] @@ -8,8 +10,15 @@ public record struct ServerJoinDataPacket : IPacket public string rwVersion; public string mpVersion; public DefCheckStatus[] defStatus; + public bool configsIncluded; public byte[] rawServerInitData; + public List ServerInitData + { + get => ClientInitDataPacket.ModData.ListBinder.Deserialize(rawServerInitData); + set => rawServerInitData = ClientInitDataPacket.ModData.ListBinder.Serialize(value); + } + public void Bind(PacketBuffer buf) { buf.Bind(ref gameName); @@ -17,6 +26,7 @@ public void Bind(PacketBuffer buf) buf.Bind(ref rwVersion); buf.Bind(ref mpVersion); buf.Bind(ref defStatus, BinderOf.Enum()); + buf.Bind(ref configsIncluded); // Max 512KiB. Should be way more than enough. As an example, one game with ~100 mods used ~35KiB. buf.BindRemaining(ref rawServerInitData, maxLength: 1<<19); } diff --git a/Source/Common/Networking/Packet/ProtocolPacket.cs b/Source/Common/Networking/Packet/ProtocolPacket.cs index 98b70963e..6b031968a 100644 --- a/Source/Common/Networking/Packet/ProtocolPacket.cs +++ b/Source/Common/Networking/Packet/ProtocolPacket.cs @@ -1,13 +1,19 @@ namespace Multiplayer.Common.Networking.Packet; [PacketDefinition(Packets.Server_ProtocolOk)] -public record struct ServerProtocolOkPacket(bool hasPassword) : IPacket +public record struct ServerProtocolOkPacket(bool hasPassword, bool isStandaloneServer = false) : IPacket { public bool hasPassword = hasPassword; + public bool isStandaloneServer = isStandaloneServer; + public float autosaveInterval; + public AutosaveUnit autosaveUnit; public void Bind(PacketBuffer buf) { buf.Bind(ref hasPassword); + buf.Bind(ref isStandaloneServer); + buf.Bind(ref autosaveInterval); + buf.BindEnum(ref autosaveUnit); } } diff --git a/Source/Common/Networking/Packet/RejoinPacket.cs b/Source/Common/Networking/Packet/RejoinPacket.cs new file mode 100644 index 000000000..ef27722f0 --- /dev/null +++ b/Source/Common/Networking/Packet/RejoinPacket.cs @@ -0,0 +1,9 @@ +namespace Multiplayer.Common.Networking.Packet; + +[PacketDefinition(Packets.Server_RequestRejoin)] +public record struct ServerRequestRejoinPacket : IPacket +{ + public void Bind(PacketBuffer buf) + { + } +} diff --git a/Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs b/Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs new file mode 100644 index 000000000..3151ec78d --- /dev/null +++ b/Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs @@ -0,0 +1,35 @@ +namespace Multiplayer.Common.Networking.Packet; + +[PacketDefinition(Packets.Client_StandaloneWorldSnapshotUpload, allowFragmented: true)] +public record struct ClientStandaloneWorldSnapshotPacket : IPacket +{ + public int tick; + public byte[] worldData; + public byte[] sessionData; + public byte[] sha256Hash; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref tick); + buf.BindBytes(ref worldData, maxLength: -1); + buf.BindBytes(ref sessionData, maxLength: -1); + buf.BindBytes(ref sha256Hash, maxLength: 32); + } +} + +[PacketDefinition(Packets.Client_StandaloneMapSnapshotUpload, allowFragmented: true)] +public record struct ClientStandaloneMapSnapshotPacket : IPacket +{ + public int mapId; + public int tick; + public byte[] mapData; + public byte[] sha256Hash; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref mapId); + buf.Bind(ref tick); + buf.BindBytes(ref mapData, maxLength: -1); + buf.BindBytes(ref sha256Hash, maxLength: 32); + } +} \ No newline at end of file diff --git a/Source/Common/Networking/PacketReadException.cs b/Source/Common/Networking/PacketReadException.cs index 94ff4b7da..4f644ce3a 100644 --- a/Source/Common/Networking/PacketReadException.cs +++ b/Source/Common/Networking/PacketReadException.cs @@ -12,4 +12,6 @@ public PacketReadException(string message, Exception innerException) : base(mess { } } + + public class PacketBadIdException(int id) : PacketReadException($"Bad packet id: {id}"); } diff --git a/Source/Common/Networking/Packets.cs b/Source/Common/Networking/Packets.cs index 18306038c..0d3cd3a69 100644 --- a/Source/Common/Networking/Packets.cs +++ b/Source/Common/Networking/Packets.cs @@ -34,6 +34,8 @@ public enum Packets : byte Client_RequestRejoin, Client_SetFaction, Client_FrameTime, + Client_StandaloneWorldSnapshotUpload, + Client_StandaloneMapSnapshotUpload, // Joining Server_ProtocolOk, @@ -61,6 +63,7 @@ public enum Packets : byte Server_PingLocation, Server_Traces, Server_SetFaction, + Server_RequestRejoin, // All states (Joining, Loading, Playing) Server_Disconnect, diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index ebf089537..b2d2a392f 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Threading.Tasks; using Multiplayer.Common.Networking.Packet; @@ -28,8 +29,8 @@ protected override async Task RunState() if (Server.settings.pauseOnJoin) Server.commands.PauseAll(); - if (Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Join)) - Server.worldData.TryStartJoinPointCreation(); + if (!Server.IsStandaloneServer && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Join)) + Server.worldData.TryStartJoinPointCreation(sourcePlayer: Player); Server.playerManager.OnJoin(Player); Server.playerManager.SendInitDataCommand(Player); @@ -45,7 +46,11 @@ private void HandleProtocol(ClientProtocolPacket packet) Player.Disconnect(MpDisconnectReason.Protocol, ByteWriter.GetBytes(MpVersion.Version, MpVersion.Protocol)); else { - Player.SendPacket(new ServerProtocolOkPacket(Server.settings.hasPassword)); + Player.SendPacket(new ServerProtocolOkPacket(Server.settings.hasPassword, Server.IsStandaloneServer) + { + autosaveInterval = Server.settings.autosaveInterval, + autosaveUnit = Server.settings.autosaveUnit + }); if (Server.BootstrapMode) { @@ -151,7 +156,8 @@ private bool HandleClientJoinData(ClientJoinDataPacket packet) rwVersion = serverInitData.RwVersion, mpVersion = MpVersion.Version, defStatus = defStatus, - rawServerInitData = serverInitData.RawData + configsIncluded = serverInitData.IncludeConfigs, + rawServerInitData = serverInitData.RawData, }.Serialize()); if (Server.BootstrapMode) diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index c6d22e298..f7b21e7a7 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -63,16 +63,20 @@ public void HandleClientCommand(ClientCommandPacket packet) [TypedPacketHandler] public void HandleChat(ClientChatPacket packet) { + Player.helpOnlyUsableCommands = packet.helpOnlyUsableCommands; + string msg = packet.msg; msg = msg.Trim(); - // todo handle max length if (msg.Length == 0) return; + if (msg.Length > MaxChatMsgLength) + msg = msg[..MaxChatMsgLength]; + if (msg[0] == '/') { var cmd = msg[1..]; - Server.HandleChatCmd(Player, cmd); + Server.HandleChatCommand(Player, cmd); } else { @@ -83,10 +87,11 @@ public void HandleChat(ClientChatPacket packet) [PacketHandler(Packets.Client_WorldDataUpload, allowFragmented: true)] public void HandleWorldDataUpload(ByteReader data) { - if (Server.ArbiterPlaying ? !Player.IsArbiter : !Player.IsHost) // policy + // On standalone, accept from any playing client; otherwise only host/arbiter + if (!Server.IsStandaloneServer && (Server.ArbiterPlaying ? !Player.IsArbiter : !Player.IsHost)) return; - ServerLog.Log($"Got world upload {data.Left}"); + ServerLog.Detail($"Got world upload {data.Left}"); Server.worldData.mapData = new Dictionary(); @@ -104,6 +109,54 @@ public void HandleWorldDataUpload(ByteReader data) Server.worldData.EndJoinPointCreation(); } + [TypedPacketHandler] + public void HandleStandaloneWorldSnapshot(ClientStandaloneWorldSnapshotPacket packet) + { + if (!Server.IsStandaloneServer) + return; + + if (!Player.IsPlaying) + return; + + var accepted = Server.worldData.TryAcceptStandaloneWorldSnapshot(Player, packet.tick, + packet.worldData, packet.sessionData, packet.sha256Hash); + + if (accepted) + { + ServerLog.Detail( + $"Accepted standalone world snapshot tick={packet.tick} from {Player.Username}"); + } + else + { + ServerLog.Detail( + $"Rejected standalone world snapshot tick={packet.tick} from {Player.Username}"); + } + } + + [TypedPacketHandler] + public void HandleStandaloneMapSnapshot(ClientStandaloneMapSnapshotPacket packet) + { + if (!Server.IsStandaloneServer) + return; + + if (!Player.IsPlaying) + return; + + var accepted = Server.worldData.TryAcceptStandaloneMapSnapshot(Player, packet.mapId, packet.tick, + packet.mapData, packet.sha256Hash); + + if (accepted) + { + ServerLog.Detail( + $"Accepted standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); + } + else + { + ServerLog.Detail( + $"Rejected standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); + } + } + [TypedPacketHandler] public void HandleCursor(ClientCursorPacket clientPacket) { @@ -116,7 +169,7 @@ public void HandleCursor(ClientCursorPacket clientPacket) [TypedPacketHandler] public void HandleSelected(ClientSelectedPacket packet) => - Server.SendToPlaying(new ServerSelectedPacket(Player.id, packet)); + Server.SendToPlaying(new ServerSelectedPacket(Player.id, packet), excluding: Player); [TypedPacketHandler] public void HandlePing(ClientPingLocPacket packet) => @@ -162,18 +215,27 @@ public void HandleFreeze(ClientFreezePacket packet) Player.unfrozenAt = Server.NetTimer; } - [PacketHandler(Packets.Client_Autosaving)] - public void HandleAutosaving(ByteReader data) + [TypedPacketHandler] + public void HandleAutosaving(ClientAutosavingPacket packet) { - // Host policy - if (Player.IsHost && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Autosave)) - Server.worldData.TryStartJoinPointCreation(); + var forceJoinPoint = packet.reason == JoinPointRequestReason.Save; + + ServerLog.Detail( + $"Received Client_Autosaving from {Player.Username}, standalone={Server.IsStandaloneServer}, " + + $"isHost={Player.IsHost}, reason={packet.reason}, force={forceJoinPoint}"); + + // On standalone, any playing client can trigger a join point (always, regardless of settings) + // On hosted, only the host can trigger and only if the Autosave flag is set + if (Server.IsStandaloneServer || + (Player.IsHost && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Autosave))) + Server.worldData.TryStartJoinPointCreation(forceJoinPoint, sourcePlayer: Player); } [TypedPacketHandler] public void HandleDebug(ClientDebugPacket _) { - // todo restrict handling + if (!Server.commands.CanUseDevMode(Player)) + return; Server.worldData.mapCmds.Clear(); Server.gameTimer = Server.startingTimer; diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index cc3da9284..efa871704 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -71,10 +71,10 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) ServerPlayer player = conn.serverPlayer; Players.Remove(player); - if (player.IsHost && server.worldData.CreatingJoinPoint) + if (server.worldData.CreatingJoinPoint && (player.IsHost || !Players.Any(p => p.hasJoined))) { server.worldData.AbortJoinPointCreation(); - ServerLog.Log("Aborted join point creation because the host disconnected."); + ServerLog.Log("Aborted join point creation because no players remain."); } if (player.hasJoined) @@ -136,7 +136,10 @@ public void OnDesync(ServerPlayer player, int tick, int diffAt) public void OnJoin(ServerPlayer player) { player.hasJoined = true; - player.FactionId = player.id == 0 || !server.settings.multifaction ? + var standalonePrimaryPlayer = server.IsStandaloneServer && + !server.JoinedPlayers.Any(p => p != player && !p.IsArbiter); + + player.FactionId = standalonePrimaryPlayer || player.id == 0 || !server.settings.multifaction ? server.worldData.hostFactionId : server.worldData.spectatorFactionId; diff --git a/Source/Common/Properties/AssemblyInfo.cs b/Source/Common/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..9ead8d2ec --- /dev/null +++ b/Source/Common/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Tests")] diff --git a/Source/Common/ServerInitData.cs b/Source/Common/ServerInitData.cs index aeb59f514..730a1afbb 100644 --- a/Source/Common/ServerInitData.cs +++ b/Source/Common/ServerInitData.cs @@ -6,6 +6,7 @@ namespace Multiplayer.Common; public record ServerInitData( byte[] RawData, + bool IncludeConfigs, string RwVersion, HashSet DebugOnlySyncCmds, HashSet HostOnlySyncCmds, @@ -13,20 +14,8 @@ public record ServerInitData( Dictionary DefInfos ) { - public ClientInitDataPacket ToNet() => new() - { - rwVersion = RwVersion, - debugOnlySyncCmds = DebugOnlySyncCmds.ToArray(), - hostOnlySyncCmds = HostOnlySyncCmds.ToArray(), - modCtorRoundMode = RoundModes.Item1, - staticCtorRoundMode = RoundModes.Item2, - defInfos = DefInfos.Select(kv => new KeyedDefInfo - { name = kv.Key, count = kv.Value.count, hash = kv.Value.hash }).ToArray(), - rawData = RawData - }; - public static ServerInitData FromNet(ClientInitDataPacket packet) => new( - packet.rawData, packet.rwVersion, + packet.rawMods, packet.includeConfigs, packet.rwVersion, packet.debugOnlySyncCmds.ToHashSet(), packet.hostOnlySyncCmds.ToHashSet(), (packet.modCtorRoundMode, packet.staticCtorRoundMode), diff --git a/Source/Common/ServerPlayer.cs b/Source/Common/ServerPlayer.cs index 51471d217..f1a231c97 100644 --- a/Source/Common/ServerPlayer.cs +++ b/Source/Common/ServerPlayer.cs @@ -33,6 +33,7 @@ public class ServerPlayer : IChatSource // Track which map the player is currently on public int currentMapId = -1; public bool hasReportedCurrentMap; + public bool helpOnlyUsableCommands; public string Username => conn.username; public int Latency => conn.Latency; @@ -129,6 +130,7 @@ public void ResetTimeVotes() } public void SendMsg(string msg) => SendPacket(ServerChatPacket.Create(msg)); + public void SendRawMsg(string msg) => SendPacket(ServerChatPacket.CreateRaw(msg)); } public enum PlayerStatus : byte diff --git a/Source/Common/StandalonePersistence.cs b/Source/Common/StandalonePersistence.cs new file mode 100644 index 000000000..f7bcbd1e6 --- /dev/null +++ b/Source/Common/StandalonePersistence.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; + +namespace Multiplayer.Common; + +/// +/// Manages the Saved/ directory for standalone server durable persistence. +/// Uses atomic temp-file + rename pattern for crash safety. +/// +public class StandalonePersistence +{ + public string SavedDir { get; } + + private string MapsDir => Path.Combine(SavedDir, "maps"); + private string WorldPath => Path.Combine(SavedDir, "world.dat"); + private string WorldCmdsPath => Path.Combine(SavedDir, "world_cmds.dat"); + private string SessionPath => Path.Combine(SavedDir, "session.dat"); + private string InfoPath => Path.Combine(SavedDir, "info.xml"); + private string StatePath => Path.Combine(SavedDir, "state.bin"); + + public StandalonePersistence(string baseDir) + { + SavedDir = Path.Combine(baseDir, "Saved"); + } + + public void EnsureDirectories() + { + Directory.CreateDirectory(SavedDir); + Directory.CreateDirectory(MapsDir); + } + + public bool HasValidState() + { + return File.Exists(WorldPath); + } + + /// + /// Seed the Saved/ directory from a save.zip (replay format). + /// + public void SeedFromSaveZip(string zipPath) + { + EnsureDirectories(); + + using var zip = ZipFile.OpenRead(zipPath); + + // World save + var worldEntry = zip.GetEntry("world/000_save"); + if (worldEntry != null) + { + var worldBytes = ReadEntry(worldEntry); + AtomicWrite(WorldPath, Compress(worldBytes)); + } + + // World commands + var worldCmdsEntry = zip.GetEntry("world/000_cmds"); + if (worldCmdsEntry != null) + AtomicWrite(WorldCmdsPath, ReadEntry(worldCmdsEntry)); + + // Map saves and commands + foreach (var entry in zip.Entries) + { + if (!entry.FullName.StartsWith("maps/")) continue; + + var parts = entry.FullName.Replace("maps/", "").Split('_'); + if (parts.Length < 3) continue; + + if (!int.TryParse(parts[1], out int mapId)) continue; + + if (entry.FullName.EndsWith("_save")) + AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), Compress(ReadEntry(entry))); + else if (entry.FullName.EndsWith("_cmds")) + AtomicWrite(Path.Combine(MapsDir, $"{mapId}_cmds.dat"), ReadEntry(entry)); + } + + // Info/metadata + var infoEntry = zip.GetEntry("info"); + if (infoEntry != null) + { + var infoBytes = ReadEntry(infoEntry); + AtomicWrite(InfoPath, infoBytes); + + try + { + WritePersistedTick(GetLatestTick(ReplayInfo.Read(infoBytes))); + } + catch (Exception e) + { + ServerLog.Error($"Failed to seed persisted tick from info.xml: {e.Message}"); + } + } + + // Session data (empty for replay format, but create the file) + AtomicWrite(SessionPath, Array.Empty()); + + ServerLog.Log($"Seeded Saved/ directory from {zipPath}"); + } + + /// + /// Load persisted state into WorldData and return ReplayInfo if available. + /// + public ReplayInfo? LoadInto(MultiplayerServer server) + { + if (!File.Exists(WorldPath)) + return null; + + server.worldData.savedGame = File.ReadAllBytes(WorldPath); + + server.worldData.sessionData = File.Exists(SessionPath) ? File.ReadAllBytes(SessionPath) : Array.Empty(); + + // Load maps + if (Directory.Exists(MapsDir)) + { + foreach (var mapFile in Directory.GetFiles(MapsDir, "*.dat")) + { + var fileName = Path.GetFileNameWithoutExtension(mapFile); + if (fileName.EndsWith("_cmds")) continue; + + if (int.TryParse(fileName, out int mapId)) + { + server.worldData.mapData[mapId] = File.ReadAllBytes(mapFile); + + var cmdsPath = Path.Combine(MapsDir, $"{mapId}_cmds.dat"); + if (File.Exists(cmdsPath)) + { + server.worldData.mapCmds[mapId] = ScheduledCommand.DeserializeCmds(File.ReadAllBytes(cmdsPath)) + .Select(ScheduledCommand.Serialize).ToList(); + } + else + { + server.worldData.mapCmds[mapId] = new List(); + } + } + } + } + + // Load world commands + if (File.Exists(WorldCmdsPath)) + { + server.worldData.mapCmds[-1] = ScheduledCommand.DeserializeCmds(File.ReadAllBytes(WorldCmdsPath)) + .Select(ScheduledCommand.Serialize).ToList(); + } + else + { + server.worldData.mapCmds[-1] = new List(); + } + + // Load replay info for metadata + ReplayInfo? info = null; + if (File.Exists(InfoPath)) + { + try { info = ReplayInfo.Read(File.ReadAllBytes(InfoPath)); } + catch (Exception e) { ServerLog.Error($"Failed to read info.xml: {e.Message}"); } + } + + var loadedTick = ReadPersistedTick() ?? GetLatestTick(info); + server.gameTimer = loadedTick; + server.startingTimer = loadedTick; + server.worldData.standaloneWorldSnapshot.tick = loadedTick; + + foreach (var mapId in server.worldData.mapData.Keys) + { + server.worldData.standaloneMapSnapshots[mapId] = new StandaloneMapSnapshotState + { + tick = loadedTick, + }; + } + + ServerLog.Log($"Loaded state from Saved/ directory ({server.worldData.mapData.Count} maps) at tick {loadedTick}"); + return info; + } + + public void WriteJoinPoint(WorldData worldData, int tick) + { + EnsureDirectories(); + + if (worldData.savedGame != null) + AtomicWrite(WorldPath, worldData.savedGame); + + AtomicWrite(SessionPath, worldData.sessionData ?? Array.Empty()); + AtomicWrite(WorldCmdsPath, SerializeStoredCmds(worldData.mapCmds.GetValueOrDefault(ScheduledCommand.Global) ?? [])); + + var currentMapIds = worldData.mapData.Keys.ToHashSet(); + DeleteStaleMapFiles(currentMapIds); + + foreach (var (mapId, mapData) in worldData.mapData) + { + AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), mapData); + AtomicWrite( + Path.Combine(MapsDir, $"{mapId}_cmds.dat"), + SerializeStoredCmds(worldData.mapCmds.GetValueOrDefault(mapId) ?? [])); + } + + WritePersistedTick(tick); + } + + /// + /// Write an accepted map snapshot to disk atomically. + /// + public void WriteMapSnapshot(int mapId, byte[] compressedMapData) + { + EnsureDirectories(); + AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), compressedMapData); + } + + /// + /// Write an accepted world snapshot to disk atomically. + /// + public void WriteWorldSnapshot(byte[] compressedWorldData, byte[] sessionData, int tick) + { + EnsureDirectories(); + AtomicWrite(WorldPath, compressedWorldData); + AtomicWrite(SessionPath, sessionData); + WritePersistedTick(tick); + } + + /// + /// Cleanup any leftover .tmp files from interrupted writes. + /// + public void CleanupTempFiles() + { + if (!Directory.Exists(SavedDir)) return; + + foreach (var tmp in Directory.GetFiles(SavedDir, "*.tmp", SearchOption.AllDirectories)) + { + try + { + File.Delete(tmp); + ServerLog.Detail($"Cleaned up leftover temp file: {tmp}"); + } + catch (Exception e) + { + ServerLog.Error($"Failed to clean temp file {tmp}: {e.Message}"); + } + } + } + + /// + /// Atomic write: write to .tmp, then replace/rename over the target. + /// + private static void AtomicWrite(string targetPath, byte[] data) + { + var tmpPath = targetPath + ".tmp"; + File.WriteAllBytes(tmpPath, data); + + if (File.Exists(targetPath)) + File.Replace(tmpPath, targetPath, destinationBackupFileName: null); + else + File.Move(tmpPath, targetPath); + } + + private static byte[] ReadEntry(ZipArchiveEntry entry) + { + using var stream = entry.Open(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } + + private static byte[] Compress(byte[] input) + { + using var result = new MemoryStream(); + using (var gz = new GZipStream(result, CompressionMode.Compress)) + { + gz.Write(input, 0, input.Length); + gz.Flush(); + } + return result.ToArray(); + } + + private void DeleteStaleMapFiles(HashSet currentMapIds) + { + if (!Directory.Exists(MapsDir)) + return; + + foreach (var path in Directory.GetFiles(MapsDir, "*.dat")) + { + var fileName = Path.GetFileNameWithoutExtension(path); + var mapIdText = fileName.EndsWith("_cmds") ? fileName[..^5] : fileName; + if (int.TryParse(mapIdText, out var mapId) && !currentMapIds.Contains(mapId)) + File.Delete(path); + } + } + + private void WritePersistedTick(int tick) + { + AtomicWrite(StatePath, BitConverter.GetBytes(tick)); + } + + private int? ReadPersistedTick() + { + if (!File.Exists(StatePath)) + return null; + + var data = File.ReadAllBytes(StatePath); + return data.Length >= sizeof(int) ? BitConverter.ToInt32(data, 0) : null; + } + + private static int GetLatestTick(ReplayInfo? info) + { + if (info?.sections.Count > 0) + { + var section = info.sections.Last(); + return Math.Max(section.start, section.end); + } + + return 0; + } + + private static byte[] SerializeStoredCmds(List cmds) + { + var writer = new ByteWriter(); + writer.WriteInt32(cmds.Count); + foreach (var cmd in cmds) + writer.WritePrefixedBytes(cmd); + return writer.ToArray(); + } +} diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index be9f18550..9e8b83277 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 = 54; + public const int Protocol = 56; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() diff --git a/Source/Common/WorldData.cs b/Source/Common/WorldData.cs index a76e18dfb..26e7b596f 100644 --- a/Source/Common/WorldData.cs +++ b/Source/Common/WorldData.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; using System.Threading.Tasks; namespace Multiplayer.Common; @@ -14,10 +16,13 @@ public class WorldData public Dictionary> mapCmds = new(); // Map id to serialized cmds list public Dictionary>? tmpMapCmds; - public int lastJoinPointAtWorkTicks = -1; + public int lastJoinPointAtTick = -1; public List syncInfos = new(); + public StandaloneWorldSnapshotState standaloneWorldSnapshot = new(); + public Dictionary standaloneMapSnapshots = new(); + private TaskCompletionSource? dataSource; public bool CreatingJoinPoint => tmpMapCmds != null; @@ -29,17 +34,42 @@ public WorldData(MultiplayerServer server) Server = server; } - public bool TryStartJoinPointCreation(bool force = false) + private int CurrentJoinPointTick => Server.IsStandaloneServer ? Server.gameTimer : Server.workTicks; + + public bool TryStartJoinPointCreation(bool force = false, ServerPlayer? sourcePlayer = null) { - if (!force && Server.workTicks - lastJoinPointAtWorkTicks < 30) + int currentTick = CurrentJoinPointTick; + + if (!force && lastJoinPointAtTick >= 0 && currentTick - lastJoinPointAtTick < 30) + { + ServerLog.Detail($"Join point skipped: cooldown active at tick={currentTick}, last={lastJoinPointAtTick}, standalone={Server.IsStandaloneServer}"); return false; + } if (CreatingJoinPoint) + { + ServerLog.Detail("Join point skipped: already creating one"); return false; - + } + + var issuingPlayer = sourcePlayer; + if (Server.IsStandaloneServer && issuingPlayer == null) + { + issuingPlayer = Server.PlayingPlayers.FirstOrDefault(player => player.IsHost) + ?? Server.PlayingPlayers.FirstOrDefault(); + + if (issuingPlayer == null) + { + ServerLog.Detail("Join point skipped: no playing player available for standalone creation"); + return false; + } + } + + ServerLog.Detail($"Join point started at tick={currentTick}, force={force}, standalone={Server.IsStandaloneServer}"); Server.SendChat("Creating a join point..."); - Server.commands.Send(CommandType.CreateJoinPoint, ScheduledCommand.NoFaction, ScheduledCommand.Global, Array.Empty()); + Server.commands.Send(CommandType.CreateJoinPoint, ScheduledCommand.NoFaction, ScheduledCommand.Global, Array.Empty(), + sourcePlayer: Server.IsStandaloneServer ? issuingPlayer : null); tmpMapCmds = new Dictionary>(); dataSource = new TaskCompletionSource(); @@ -48,9 +78,24 @@ public bool TryStartJoinPointCreation(bool force = false) public void EndJoinPointCreation() { + int currentTick = CurrentJoinPointTick; + ServerLog.Detail($"Join point completed at tick={currentTick}, standalone={Server.IsStandaloneServer}"); mapCmds = tmpMapCmds!; tmpMapCmds = null; - lastJoinPointAtWorkTicks = Server.workTicks; + lastJoinPointAtTick = currentTick; + + if (Server.IsStandaloneServer && Server.persistence != null) + { + try + { + Server.persistence.WriteJoinPoint(this, currentTick); + } + catch (Exception e) + { + ServerLog.Error($"Failed to persist standalone join point at tick={currentTick}: {e}"); + } + } + dataSource!.SetResult(this); dataSource = null; } @@ -69,4 +114,87 @@ public Task WaitJoinPoint() { return dataSource?.Task ?? Task.FromResult(this); } + + public bool TryAcceptStandaloneWorldSnapshot(ServerPlayer player, int tick, byte[] worldSnapshot, + byte[] sessionSnapshot, byte[] expectedHash) + { + if (tick < standaloneWorldSnapshot.tick) + return false; + + var actualHash = ComputeHash(worldSnapshot, sessionSnapshot); + if (expectedHash.Length > 0 && !actualHash.AsSpan().SequenceEqual(expectedHash)) + return false; + + savedGame = worldSnapshot; + sessionData = sessionSnapshot; + standaloneWorldSnapshot = new StandaloneWorldSnapshotState + { + tick = tick, + producerPlayerId = player.id, + producerUsername = player.Username, + sha256Hash = actualHash + }; + + // Persist to disk + Server.persistence?.WriteWorldSnapshot(worldSnapshot, sessionSnapshot, tick); + + return true; + } + + public bool TryAcceptStandaloneMapSnapshot(ServerPlayer player, int mapId, int tick, + byte[] mapSnapshot, byte[] expectedHash) + { + if (mapId < 0) + return false; + + var snapshotState = standaloneMapSnapshots.GetOrAddNew(mapId); + if (tick < snapshotState.tick) + return false; + + var actualHash = ComputeHash(mapSnapshot); + if (expectedHash.Length > 0 && !actualHash.AsSpan().SequenceEqual(expectedHash)) + return false; + + mapData[mapId] = mapSnapshot; + snapshotState.tick = tick; + snapshotState.producerPlayerId = player.id; + snapshotState.producerUsername = player.Username; + snapshotState.sha256Hash = actualHash; + standaloneMapSnapshots[mapId] = snapshotState; + + // Persist to disk + Server.persistence?.WriteMapSnapshot(mapId, mapSnapshot); + + return true; + } + + private static byte[] ComputeHash(params byte[][] payloads) + { + using var hasher = SHA256.Create(); + foreach (var payload in payloads) + { + hasher.TransformBlock(payload, 0, payload.Length, null, 0); + } + + hasher.TransformFinalBlock([], 0, 0); + return hasher.Hash; + } +} + +public struct StandaloneWorldSnapshotState +{ + public StandaloneWorldSnapshotState() { } + public int tick; + public int producerPlayerId; + public string producerUsername = ""; + public byte[] sha256Hash = Array.Empty(); +} + +public struct StandaloneMapSnapshotState +{ + public StandaloneMapSnapshotState() { } + public int tick; + public int producerPlayerId; + public string producerUsername = ""; + public byte[] sha256Hash = Array.Empty(); } diff --git a/Source/Multiplayer.sln b/Source/Multiplayer.sln index ed00c539f..8c8f90207 100644 --- a/Source/Multiplayer.sln +++ b/Source/Multiplayer.sln @@ -15,6 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiplayerLoader", "Multip EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestsOnMono", "TestsOnMono\TestsOnMono.csproj", "{B9258593-604D-4862-96EF-192B2BC6250E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGen", "SourceGen\SourceGen.csproj", "{61D773A9-7B5D-443D-893E-F5C7A56272CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatCommandContracts", "ChatCommandContracts\ChatCommandContracts.csproj", "{64FFAC22-1A74-492B-965E-7AA953F263C3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -49,6 +53,14 @@ Global {B9258593-604D-4862-96EF-192B2BC6250E}.Debug|Any CPU.Build.0 = Debug|Any CPU {B9258593-604D-4862-96EF-192B2BC6250E}.Release|Any CPU.ActiveCfg = Release|Any CPU {B9258593-604D-4862-96EF-192B2BC6250E}.Release|Any CPU.Build.0 = Release|Any CPU + {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Release|Any CPU.Build.0 = Release|Any CPU + {64FFAC22-1A74-492B-965E-7AA953F263C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64FFAC22-1A74-492B-965E-7AA953F263C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64FFAC22-1A74-492B-965E-7AA953F263C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64FFAC22-1A74-492B-965E-7AA953F263C3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Source/MultiplayerLoader/MultiplayerLoader.csproj b/Source/MultiplayerLoader/MultiplayerLoader.csproj index ba7f7a84d..018083c71 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.csproj +++ b/Source/MultiplayerLoader/MultiplayerLoader.csproj @@ -10,13 +10,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index 155c57ca1..30890e715 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -1,9 +1,8 @@ -using System.IO.Compression; -using System.Net; +using System.Net; +using System.Threading; using Multiplayer.Common; using Multiplayer.Common.Util; -ServerLog.detailEnabled = true; Directory.SetCurrentDirectory(AppContext.BaseDirectory); const string settingsFile = "settings.toml"; @@ -21,28 +20,67 @@ settings = TomlSettings.Load(settingsFile); else ServerLog.Log($"Bootstrap mode: '{settingsFile}' not found. Waiting for a client to upload it."); +ServerLog.detailEnabled = settings.debugMode; +ServerLog.verboseEnabled = settings.debugMode; if (settings.steam) ServerLog.Error("Steam is not supported in standalone server."); if (settings.arbiter) ServerLog.Error("Arbiter is not supported in standalone server."); -var savePresent = File.Exists(saveFile); -if (!savePresent) -{ - ServerLog.Log($"Bootstrap mode: '{saveFile}' not found. Server will start without a loaded save."); - ServerLog.Log("Waiting for a client to upload world data."); -} - var server = MultiplayerServer.instance = new MultiplayerServer(settings) { running = true, IsStandaloneServer = true, - BootstrapMode = settingsPresent && savePresent, }; -if (!server.BootstrapMode) +var persistence = new StandalonePersistence(AppContext.BaseDirectory); +server.persistence = persistence; + +// Cleanup leftover temp files from any previous interrupted writes +persistence.CleanupTempFiles(); + +var consoleSource = new ConsoleSource(); + +var bootstrap = !settingsPresent; + +if (!bootstrap && persistence.HasValidState()) +{ + // Prefer loading from the Saved/ directory (structured persistence) + var info = persistence.LoadInto(server); + if (info != null) + { + server.settings.gameName = info.name; + server.worldData.hostFactionId = info.playerFaction; + var spectatorFaction = info.spectatorFaction; + if (server.settings.multifaction && spectatorFaction == 0) + ServerLog.Error("Multifaction is enabled but the save doesn't contain spectator faction id."); + server.worldData.spectatorFactionId = spectatorFaction; + } + ServerLog.Log("Loaded state from Saved/ directory."); +} +else if (!bootstrap && File.Exists(saveFile)) { - LoadSave(server, saveFile); + // Seed the Saved/ directory from save.zip, then load from it + ServerLog.Log($"Seeding Saved/ directory from {saveFile}..."); + persistence.SeedFromSaveZip(saveFile); + var info = persistence.LoadInto(server); + if (info != null) + { + server.settings.gameName = info.name; + server.worldData.hostFactionId = info.playerFaction; + var spectatorFaction = info.spectatorFaction; + if (server.settings.multifaction && spectatorFaction == 0) + ServerLog.Error("Multifaction is enabled but the save doesn't contain spectator faction id."); + server.worldData.spectatorFactionId = spectatorFaction; + } } +else +{ + bootstrap = true; + ServerLog.Log($"Bootstrap mode: neither Saved/ directory nor '{saveFile}' found."); + ServerLog.Log("Waiting for a client to upload world data."); +} + +server.BootstrapMode = bootstrap; if (settings.direct) { var badEndpoint = settings.TryParseEndpoints(out var endpoints); @@ -79,80 +117,21 @@ new Thread(server.Run) { Name = "Server thread" }.Start(); -var consoleSource = new ConsoleSource(); while (server.running) { - var cmd = Console.ReadLine(); - if (cmd != null) - server.Enqueue(() => server.HandleChatCmd(consoleSource, cmd)); - - if (cmd == stopCmd) - break; -} - -static void LoadSave(MultiplayerServer server, string path) -{ - using var zip = ZipFile.OpenRead(path); - - var replayInfo = ReplayInfo.Read(zip.GetBytes("info")); - ServerLog.Detail($"Loading {path} saved in RW {replayInfo.rwVersion} with {replayInfo.modNames.Count} mods"); - - server.settings.gameName = replayInfo.name; - server.worldData.hostFactionId = replayInfo.playerFaction; - var spectatorFaction = replayInfo.spectatorFaction; - if (server.settings.multifaction && spectatorFaction == 0) - ServerLog.Error("Multifaction is enabled but the save doesn't contain spectator faction id."); - server.worldData.spectatorFactionId = spectatorFaction; - - //This parses multiple saves as long as they are named correctly - server.gameTimer = replayInfo.sections[0].start; - server.startingTimer = replayInfo.sections[0].start; - - - server.worldData.savedGame = Compress(zip.GetBytes("world/000_save")); - - // Parse cmds entry for each map - foreach (var entry in zip.GetEntries("maps/*_cmds")) - { - var parts = entry.FullName.Split('_'); - - if (parts.Length == 3) - { - int mapNumber = int.Parse(parts[1]); - server.worldData.mapCmds[mapNumber] = ScheduledCommand.DeserializeCmds(zip.GetBytes(entry.FullName)) - .Select(ScheduledCommand.Serialize).ToList(); - } - } - - // Parse save entry for each map - foreach (var entry in zip.GetEntries("maps/*_save")) + if (Console.KeyAvailable) { - var parts = entry.FullName.Split('_'); + var cmd = Console.ReadLine(); + if (cmd != null) + server.Enqueue(() => server.HandleChatCommand(consoleSource, cmd)); - if (parts.Length == 3) - { - int mapNumber = int.Parse(parts[1]); - server.worldData.mapData[mapNumber] = Compress(zip.GetBytes(entry.FullName)); - } + if (cmd == stopCmd) + break; } - - - server.worldData.mapCmds[-1] = ScheduledCommand.DeserializeCmds(zip.GetBytes("world/000_cmds")) - .Select(ScheduledCommand.Serialize).ToList(); - server.worldData.sessionData = []; -} - -static byte[] Compress(byte[] input) -{ - using var result = new MemoryStream(); - - using (var compressionStream = new GZipStream(result, CompressionMode.Compress)) + else { - compressionStream.Write(input, 0, input.Length); - compressionStream.Flush(); - + Thread.Sleep(50); } - return result.ToArray(); } class ConsoleSource : IChatSource @@ -161,4 +140,9 @@ public void SendMsg(string msg) { ServerLog.Log(msg); } + + public void SendRawMsg(string msg) + { + SendMsg(msg); + } } diff --git a/Source/Server/Server.csproj b/Source/Server/Server.csproj index 83083e6e7..322a8f5fe 100644 --- a/Source/Server/Server.csproj +++ b/Source/Server/Server.csproj @@ -17,7 +17,7 @@ - + diff --git a/Source/SourceGen/ChatCommandRegistryGenerator.cs b/Source/SourceGen/ChatCommandRegistryGenerator.cs new file mode 100644 index 000000000..a0a71bf60 --- /dev/null +++ b/Source/SourceGen/ChatCommandRegistryGenerator.cs @@ -0,0 +1,709 @@ +using System; +using System.Collections.Immutable; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Multiplayer.Common; + +namespace Multiplayer.SourceGen; + +[Generator] +public sealed class ChatCommandRegistryGenerator : IIncrementalGenerator +{ + private static readonly string ChatCommandAttributeName = AttributeMetadataName(nameof(ChatCommandAttribute)); + private const string ChatCommandGenericName = "Multiplayer.Common.ChatCommands.ChatCommand"; + private const string ChatCommandContextName = "Multiplayer.Common.ChatCommands.ChatCommandContext"; + private const string ServerPlayerName = "Multiplayer.Common.ServerPlayer"; + private static readonly string ChatArgumentAttributeName = AttributeMetadataName(nameof(ChatArgumentAttribute)); + private static readonly string ChatRestAttributeName = AttributeMetadataName(nameof(ChatRestAttribute)); + + private static readonly DiagnosticDescriptor DuplicateNameDescriptor = new( + "MPCHAT001", + "Duplicate chat command name", + "Chat command name or alias '{0}' is already registered by '{1}'", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor InvalidCommandDescriptor = new( + "MPCHAT002", + "Invalid chat command type", + "Chat command '{0}' must implement Multiplayer.Common.ChatCommands.IChatCommand", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor UnsupportedArgumentDescriptor = new( + "MPCHAT003", + "Unsupported chat command argument", + "Chat command argument '{0}' has unsupported type '{1}'", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor InvalidRestArgumentDescriptor = new( + "MPCHAT004", + "Invalid chat rest argument", + "Chat rest argument '{0}' {1}", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor InvalidArgumentParserDescriptor = new( + "MPCHAT005", + "Invalid chat command argument parser", + "Chat command arguments '{0}' {1}", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor InvalidCommandConstructorDescriptor = new( + "MPCHAT006", + "Invalid chat command constructor", + "Chat command '{0}' must have an accessible parameterless constructor", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor InvalidNameDescriptor = new( + "MPCHAT007", + "Invalid chat command name", + "Chat command name or alias cannot be blank", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly SymbolDisplayFormat FullyQualifiedNullableFormat = + SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + ); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var commands = context.SyntaxProvider + .ForAttributeWithMetadataName( + ChatCommandAttributeName, + static (node, _) => node is ClassDeclarationSyntax, + static (ctx, _) => CreateCommand(ctx) + ) + .Where(static command => command is not null) + .Select(static (command, _) => command!) + .Collect(); + + context.RegisterSourceOutput(commands, Generate); + } + + private static ChatCommandModel? CreateCommand(GeneratorAttributeSyntaxContext context) + { + var type = (INamedTypeSymbol)context.TargetSymbol; + var attribute = context.Attributes.First(attribute => + attribute.AttributeClass?.ToDisplayString() == ChatCommandAttributeName + ); + + var name = attribute.ConstructorArguments.Length > 0 + ? attribute.ConstructorArguments[0].Value?.ToString() ?? type.Name + : type.Name; + + var aliases = attribute.ConstructorArguments.Length > 1 + ? attribute.ConstructorArguments[1].Values + .Select(value => value.Value?.ToString() ?? string.Empty) + .ToArray() + : []; + + var namedArguments = attribute.NamedArguments.ToDictionary(argument => argument.Key, argument => argument.Value); + var description = GetString(namedArguments, "Description"); + var usage = GetString(namedArguments, "Usage"); + var requiresHost = namedArguments.TryGetValue("RequiresHost", out var hostValue) && (bool)(hostValue.Value ?? false); + + return new ChatCommandModel(type, name, aliases, description, usage, requiresHost); + } + + private static string GetString(IReadOnlyDictionary arguments, string key) => + arguments.TryGetValue(key, out var value) ? value.Value?.ToString() ?? string.Empty : string.Empty; + + private static void Generate(SourceProductionContext context, ImmutableArray commands) + { + if (commands.Length == 0) + return; + + var validCommands = new List(); + var seenNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var command in commands.OrderBy(command => command.Type.ToDisplayString(), StringComparer.Ordinal)) + { + if (!ImplementsInterface(command.Type, "Multiplayer.Common.ChatCommands.IChatCommand")) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidCommandDescriptor, + command.Type.Locations.FirstOrDefault(), + command.Type.ToDisplayString() + )); + continue; + } + + if (!HasAccessibleParameterlessConstructor(command.Type)) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidCommandConstructorDescriptor, + command.Type.Locations.FirstOrDefault(), + command.Type.ToDisplayString() + )); + continue; + } + + var commandType = command.Type.ToDisplayString(); + var hasInvalidName = false; + var hasDuplicateName = false; + foreach (var name in command.AllNames) + { + if (string.IsNullOrWhiteSpace(name)) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidNameDescriptor, + command.Type.Locations.FirstOrDefault() + )); + hasInvalidName = true; + continue; + } + + if (!seenNames.TryAdd(name, commandType)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateNameDescriptor, + command.Type.Locations.FirstOrDefault(), + name, + seenNames[name] + )); + hasDuplicateName = true; + } + } + + if (!hasInvalidName && !hasDuplicateName) + validCommands.Add(command); + } + + if (validCommands.Count == 0) + return; + + var source = CreateRegistrySource(context, validCommands); + context.AddSource("ChatCommandRegistry.g.cs", SourceText.From(source, Encoding.UTF8)); + } + + private static string CreateRegistrySource(SourceProductionContext context, IReadOnlyList commands) + { + var registrations = new StringBuilder(); + var parsers = new StringBuilder(); + + for (var i = 0; i < commands.Count; i++) + { + var command = commands[i]; + var variable = $"command{i}"; + var metadata = $"metadata{i}"; + var commandType = command.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var names = command.AllNames.ToArray(); + var namesExpression = "new string[] { " + string.Join(", ", names.Select(StringLiteral)) + " }"; + + registrations.AppendLine($"var {variable} = new {commandType}();"); + + var argsType = GetChatCommandArgumentType(command.Type); + if (argsType != null) + { + var parserName = $"TryParseCommand{i}Args"; + registrations.AppendLine($"{variable}.SetParser({parserName});"); + parsers.AppendLine(CreateParser(context, command, argsType, parserName)); + } + + registrations.AppendLine( + $"var {metadata} = new global::Multiplayer.Common.ChatCommands.ChatCommandInfo({variable}, {namesExpression}, {StringLiteral(command.Description)}, {StringLiteral(command.Usage)}, {command.RequiresHost.ToString().ToLowerInvariant()});" + ); + + foreach (var name in names) + registrations.AppendLine($"manager.AddCommand({StringLiteral(name)}, {variable}, {metadata});"); + + registrations.AppendLine(); + } + + return $$""" + // + #nullable enable + + namespace Multiplayer.Common.ChatCommands; + + internal static partial class ChatCommandRegistry + { + public static partial void Register(global::Multiplayer.Common.ChatCommands.ChatCommandManager manager, global::Multiplayer.Common.MultiplayerServer server) + { + {{Indent(registrations.ToString().TrimEnd(), 8)}} + } + + {{Indent(parsers.ToString().TrimEnd(), 4)}} + } + """; + } + + private static string CreateParser(SourceProductionContext context, ChatCommandModel command, ITypeSymbol argsType, string parserName) + { + var customParser = FindCustomParser(command.Type, argsType); + if (customParser != null) + return CreateCustomParser(command, argsType, parserName); + + var constructor = FindConstructor(context, argsType, out var hasValidConstructorSelection); + + if (!hasValidConstructorSelection) + return CreateInvalidParser(argsType, parserName); + + if (constructor == null) + { + if (!HasAccessibleParameterlessConstructor(argsType)) + { + ReportInvalidArgumentParser(context, argsType, "must have an accessible constructor"); + return CreateInvalidParser(argsType, parserName); + } + + return CreateParameterlessParser(argsType, parserName); + } + + if (!ValidateConstructorParameters(context, constructor)) + return CreateInvalidParser(argsType, parserName); + + var body = new StringBuilder(); + var values = new List(); + var index = 0; + + foreach (var parameter in constructor.Parameters) + { + var variable = $"arg{index}"; + var displayName = GetArgumentName(parameter); + var isRest = IsRestArgument(constructor, parameter); + var isOptional = IsOptional(parameter); + + if (!isOptional) + AppendRequiredArgumentCheck(body, command, displayName, index); + + var rawExpression = isRest + ? $"global::Multiplayer.Common.ChatCommands.ChatCommandArgumentReader.JoinRest(context.RawArgs, {index})" + : $"context.RawArgs[{index}]"; + var defaultExpression = DefaultExpression(parameter); + var parseExpression = ParseExpression(context, parameter, rawExpression, variable, out var parseStatements); + var valueExpression = variable; + if (isOptional && parseStatements.Length > 0) + { + body.AppendLine($$""" + {{parameter.Type.ToDisplayString(FullyQualifiedNullableFormat)}} {{variable}}; + if (context.RawArgs.Count > {{index}}) + { + {{Indent(parseStatements.TrimEnd(), 8)}} + {{variable}} = {{parseExpression}}; + } + else + { + {{variable}} = {{defaultExpression}}; + } + """); + } + else if (isOptional) + { + body.AppendLine($" var {variable} = context.RawArgs.Count > {index} ? {parseExpression} : {defaultExpression};"); + } + else + { + if (parseStatements.Length > 0) + { + body.Append(Indent(parseStatements.TrimEnd(), 4)); + body.AppendLine(); + } + + valueExpression = parseExpression; + } + + values.Add(valueExpression); + index++; + } + + var argsTypeName = argsType.ToDisplayString(FullyQualifiedNullableFormat); + return $$""" + private static bool {{parserName}}(global::Multiplayer.Common.ChatCommands.ChatCommandContext context, out {{argsTypeName}} args, out string? error) + { + {{body.ToString().TrimEnd()}} + args = new {{argsTypeName}}({{string.Join(", ", values)}}); + error = null; + return true; + } + """; + } + + private static string CreateCustomParser(ChatCommandModel command, ITypeSymbol argsType, string parserName) + { + return $$""" + private static bool {{parserName}}(global::Multiplayer.Common.ChatCommands.ChatCommandContext context, out {{argsType.ToDisplayString(FullyQualifiedNullableFormat)}} args, out string? error) + { + if ({{command.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}.TryParse(context, out args)) + { + error = null; + return true; + } + + error = "Invalid command arguments."; + return false; + } + """; + } + + private static string CreateParameterlessParser(ITypeSymbol argsType, string parserName) + { + return $$""" + private static bool {{parserName}}(global::Multiplayer.Common.ChatCommands.ChatCommandContext context, out {{argsType.ToDisplayString(FullyQualifiedNullableFormat)}} args, out string? error) + { + args = new {{argsType.ToDisplayString(FullyQualifiedNullableFormat)}}(); + error = null; + return true; + } + """; + } + + private static string CreateInvalidParser(ITypeSymbol argsType, string parserName) + { + return $$""" + private static bool {{parserName}}(global::Multiplayer.Common.ChatCommands.ChatCommandContext context, out {{argsType.ToDisplayString(FullyQualifiedNullableFormat)}} args, out string? error) + { + args = default; + error = "Invalid command arguments."; + return false; + } + """; + } + + private static IMethodSymbol? FindConstructor(SourceProductionContext context, ITypeSymbol argsType, out bool isValid) + { + isValid = true; + var constructors = argsType + .GetMembers(".ctor") + .OfType() + .Where(ctor => !ctor.IsStatic && ctor.Parameters.Length > 0 && IsAccessible(ctor)) + .ToArray(); + + if (constructors.Length == 0) + return null; + + var largestParameterCount = constructors.Max(ctor => ctor.Parameters.Length); + var candidates = constructors + .Where(ctor => ctor.Parameters.Length == largestParameterCount) + .ToArray(); + + if (candidates.Length == 1) + return candidates[0]; + + ReportInvalidArgumentParser(context, argsType, "has ambiguous constructors"); + isValid = false; + return null; + } + + private static bool HasAccessibleParameterlessConstructor(ITypeSymbol argsType) => + argsType.IsValueType + || argsType.GetMembers(".ctor") + .OfType() + .Any(ctor => !ctor.IsStatic && ctor.Parameters.Length == 0 && IsAccessible(ctor)); + + private static void ReportInvalidArgumentParser(SourceProductionContext context, ITypeSymbol argsType, string message) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidArgumentParserDescriptor, + argsType.Locations.FirstOrDefault(), + argsType.ToDisplayString(), + message + )); + } + + private static bool ValidateConstructorParameters(SourceProductionContext context, IMethodSymbol constructor) + { + var valid = true; + var hasRestArgument = false; + + for (var i = 0; i < constructor.Parameters.Length; i++) + { + var parameter = constructor.Parameters[i]; + if (!IsRestArgument(constructor, parameter)) + continue; + + if (hasRestArgument) + { + ReportInvalidRestArgument(context, parameter, "must be the only rest argument"); + valid = false; + } + + hasRestArgument = true; + + if (!IsValidRestType(parameter.Type)) + { + ReportInvalidRestArgument(context, parameter, "must be a string or ServerPlayer"); + valid = false; + } + + if (i != constructor.Parameters.Length - 1) + { + ReportInvalidRestArgument(context, parameter, "must be the final argument"); + valid = false; + } + } + + return valid; + } + + private static bool IsRestArgument(IMethodSymbol constructor, IParameterSymbol parameter) + { + return HasAttribute(parameter, ChatRestAttributeName) + || (constructor.Parameters.Length == 1 && IsValidRestType(parameter.Type)); + } + + private static bool IsValidRestType(ITypeSymbol type) + { + var nonNullable = NonNullableType(type); + return nonNullable.SpecialType == SpecialType.System_String + || nonNullable.ToDisplayString() == ServerPlayerName; + } + + private static void ReportInvalidRestArgument(SourceProductionContext context, IParameterSymbol parameter, string message) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidRestArgumentDescriptor, + parameter.Locations.FirstOrDefault(), + parameter.Name, + message + )); + } + + private static void AppendRequiredArgumentCheck(StringBuilder body, ChatCommandModel command, string displayName, int index) + { + body.AppendLine($$""" + if (!global::Multiplayer.Common.ChatCommands.ChatCommandArgumentReader.HasArgument(context, {{index}}, {{StringLiteral(MissingArgumentMessage(command, displayName))}}, out error)) + { + args = default; + return false; + } + """); + } + + private static string ParseExpression(SourceProductionContext context, IParameterSymbol parameter, string rawExpression, string variable, out string statements) + { + statements = string.Empty; + var type = parameter.Type; + var nonNullable = type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } named + ? named.TypeArguments[0] + : type; + + if (nonNullable.SpecialType == SpecialType.System_String) + return rawExpression; + + if (nonNullable.SpecialType == SpecialType.System_Int32) + { + statements = CreateTryParseStatements("TryParseInt", rawExpression, variable, parameter); + return $"parsed{variable}"; + } + + if (nonNullable.SpecialType == SpecialType.System_Boolean) + { + statements = CreateTryParseStatements("TryParseBool", rawExpression, variable, parameter); + return $"parsed{variable}"; + } + + if (nonNullable.SpecialType == SpecialType.System_Single) + { + statements = CreateTryParseStatements("TryParseFloat", rawExpression, variable, parameter); + return $"parsed{variable}"; + } + + if (nonNullable.TypeKind == TypeKind.Enum) + { + var typeName = nonNullable.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + statements = CreateTryParseStatements($"TryParseEnum<{typeName}>", rawExpression, variable, parameter); + return $"parsed{variable}"; + } + + if (nonNullable.ToDisplayString() == ServerPlayerName) + { + statements = CreateTryParseStatements("TryParsePlayer", $"context, {rawExpression}", variable, parameter); + return $"parsed{variable}"; + } + + context.ReportDiagnostic(Diagnostic.Create( + UnsupportedArgumentDescriptor, + parameter.Locations.FirstOrDefault(), + parameter.Name, + type.ToDisplayString() + )); + return rawExpression; + } + + private static string CreateTryParseStatements(string method, string input, string valueName, IParameterSymbol symbol) + { + return $$""" + if (!global::Multiplayer.Common.ChatCommands.ChatCommandArgumentReader.{{method}}({{input}}, {{StringLiteral(GetArgumentName(symbol))}}, out var parsed{{valueName}}, out error)) + { + args = default; + return false; + } + """; + } + + private static string MissingArgumentMessage(ChatCommandModel command, string name) => + string.IsNullOrWhiteSpace(command.Usage) + ? $"Missing argument: {name}." + : $"Usage: {command.Usage}"; + + private static string DefaultExpression(IParameterSymbol parameter) + { + if (parameter.HasExplicitDefaultValue) + { + var nonNullable = NonNullableType(parameter.Type); + if (parameter.ExplicitDefaultValue != null && nonNullable.TypeKind == TypeKind.Enum) + return $"({nonNullable.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}){Literal(parameter.ExplicitDefaultValue)}"; + + return Literal(parameter.ExplicitDefaultValue); + } + + return parameter.Type.NullableAnnotation == NullableAnnotation.Annotated + || parameter.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } + ? "null" + : "default"; + } + + private static ITypeSymbol NonNullableType(ITypeSymbol type) => + type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } named + ? named.TypeArguments[0] + : type; + + private static string Literal(object? value) + { + return value switch + { + null => "null", + string s => StringLiteral(s), + char c => "'" + c.ToString().Replace("\\", "\\\\").Replace("'", "\\'") + "'", + bool b => b ? "true" : "false", + float f => f.ToString(CultureInfo.InvariantCulture) + "f", + double d => d.ToString(CultureInfo.InvariantCulture) + "d", + decimal d => d.ToString(CultureInfo.InvariantCulture) + "m", + _ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? "default" + }; + } + + private static bool IsOptional(IParameterSymbol parameter) => + parameter.HasExplicitDefaultValue + || parameter.NullableAnnotation == NullableAnnotation.Annotated + || parameter.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }; + + private static string GetArgumentName(IParameterSymbol parameter) + { + var attribute = parameter.GetAttributes().FirstOrDefault(attribute => + attribute.AttributeClass?.ToDisplayString() == ChatArgumentAttributeName + ); + + return attribute?.ConstructorArguments.FirstOrDefault().Value?.ToString() + ?? parameter.Name; + } + + private static ITypeSymbol? GetChatCommandArgumentType(INamedTypeSymbol type) + { + for (var current = type.BaseType; current != null; current = current.BaseType) + { + if (current.OriginalDefinition.ToDisplayString() == ChatCommandGenericName) + return current.TypeArguments[0]; + } + + return null; + } + + private static IMethodSymbol? FindCustomParser(INamedTypeSymbol commandType, ITypeSymbol argsType) + { + return commandType.GetMembers("TryParse") + .OfType() + .FirstOrDefault(method => + method.IsStatic + && IsAccessible(method) + && method.ReturnType.SpecialType == SpecialType.System_Boolean + && method.Parameters.Length == 2 + && method.Parameters[0].Type.ToDisplayString() == ChatCommandContextName + && method.Parameters[1].RefKind == RefKind.Out + && SymbolEqualityComparer.Default.Equals(method.Parameters[1].Type, argsType) + ); + } + + private static bool IsAccessible(ISymbol symbol) => + symbol.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal; + + private static bool ImplementsInterface(INamedTypeSymbol type, string interfaceName) => + type.AllInterfaces.Any(@interface => @interface.ToDisplayString() == interfaceName); + + private static bool HasAttribute(ISymbol symbol, string attributeName) => + symbol.GetAttributes().Any(attribute => attribute.AttributeClass?.ToDisplayString() == attributeName); + + private static string AttributeMetadataName(string attributeTypeName) => + $"Multiplayer.Common.{attributeTypeName}"; + + private static string StringLiteral(string value) => + "@\"" + value.Replace("\"", "\"\"") + "\""; + + private static string Indent(string value, int spaces) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var prefix = new string(' ', spaces); + return string.Join( + "\n", + value.Split(["\r\n", "\n"], StringSplitOptions.None) + .Select(line => line.Length == 0 ? string.Empty : prefix + line) + ); + } + + private sealed class ChatCommandModel + { + public ChatCommandModel( + INamedTypeSymbol type, + string name, + string[] aliases, + string description, + string usage, + bool requiresHost + ) + { + Type = type; + Name = name; + Aliases = aliases; + Description = description; + Usage = usage; + RequiresHost = requiresHost; + } + + public INamedTypeSymbol Type { get; } + public string Name { get; } + public string[] Aliases { get; } + public string Description { get; } + public string Usage { get; } + public bool RequiresHost { get; } + public IEnumerable AllNames => new[] { Name }.Concat(Aliases); + } +} + +internal static class DictionaryExtensions +{ + public static bool TryAdd(this Dictionary dictionary, TKey key, TValue value) + { + if (dictionary.ContainsKey(key)) + return false; + + dictionary.Add(key, value); + return true; + } +} diff --git a/Source/SourceGen/SourceGen.csproj b/Source/SourceGen/SourceGen.csproj new file mode 100644 index 000000000..e2387a071 --- /dev/null +++ b/Source/SourceGen/SourceGen.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0 + enable + 12 + true + true + $(NoWarn);RS1041 + + + + + + + + + + + + diff --git a/Source/Tests/ChatCommandGeneratorTest.cs b/Source/Tests/ChatCommandGeneratorTest.cs new file mode 100644 index 000000000..11d7257ea --- /dev/null +++ b/Source/Tests/ChatCommandGeneratorTest.cs @@ -0,0 +1,626 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Multiplayer.Common; +using Multiplayer.Common.ChatCommands; +using Multiplayer.SourceGen; + +namespace Tests; + +public class ChatCommandGeneratorTest +{ + [Test] + public void ChatCommand_RegistersCommand() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + [ChatCommand("ping", Description = "Ping the server.", Usage = "ping")] + public sealed class PingCommand : ChatCommand + { + public override void Execute(ChatCommandContext context) + { + context.Source.SendMsg("pong"); + } + } + """ + ); + + var registry = result.GeneratedTrees.Single(tree => tree.FilePath.EndsWith("ChatCommandRegistry.g.cs")); + var source = registry.GetText().ToString(); + + Assert.That(source, Does.Contain("internal static partial class ChatCommandRegistry")); + Assert.That(source, Does.Contain("""new global::Multiplayer.Common.PingCommand()""")); + Assert.That(source, Does.Contain("""manager.AddCommand(@"ping",""")); + } + + [Test] + public void ChatCommand_GeneratesTypedArgumentParser() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct EchoArgs([ChatArgument("text")] string Text); + + [ChatCommand("echo", Usage = "echo ")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + context.Source.SendMsg(args.Text); + } + } + """ + ); + + var source = GeneratedRegistrySource(result); + + Assert.That(source, Does.Contain("command0.SetParser(TryParseCommand0Args);")); + Assert.That( + source, + Does.Contain("args = new global::Multiplayer.Common.EchoArgs(global::Multiplayer.Common.ChatCommands.ChatCommandArgumentReader.JoinRest(context.RawArgs, 0));") + ); + } + + [Test] + public void ChatCommand_GeneratesPlayerArgumentParser() + { + var result = RunGeneratorWithCompilation( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct InspectArgs([ChatArgument("username")] ServerPlayer Player); + + [ChatCommand("inspect", Usage = "inspect ")] + public sealed class InspectCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, InspectArgs args) + { + } + } + """ + ); + + var source = GeneratedRegistrySource(result.Result); + + Assert.That(source, Does.Contain("ChatCommandArgumentReader.TryParsePlayer(context, global::Multiplayer.Common.ChatCommands.ChatCommandArgumentReader.JoinRest(context.RawArgs, 0), @\"username\"")); + Assert.That( + result.Compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty + ); + } + + [Test] + public void ChatCommand_SameClassNameInDifferentNamespacesGeneratesCompilableRegistry() + { + var result = RunGeneratorWithCompilation( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace First + { + + public readonly record struct EchoArgs(string Text); + + [ChatCommand("first")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + } + + namespace Second + { + + public readonly record struct EchoArgs(string Text); + + [ChatCommand("second")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + } + """ + ); + + Assert.That( + result.Compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty + ); + } + + [Test] + public void ChatCommand_MultipleArgumentsKeepStringArgumentPositional() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct EchoArgs(string Text, int Count); + + [ChatCommand("echo", Usage = "echo ")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + var source = GeneratedRegistrySource(result); + + Assert.That(source, Does.Contain("ChatCommandArgumentReader.TryParseInt(context.RawArgs[1], @\"Count\"")); + Assert.That(source, Does.Contain("args = new global::Multiplayer.Common.EchoArgs(context.RawArgs[0], parsedarg1);")); + } + + [Test] + public void ChatCommand_UsesCustomParserWhenPresent() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct CustomArgs(string Value); + + [ChatCommand("custom")] + public sealed class CustomCommand : ChatCommand + { + public static bool TryParse(ChatCommandContext context, out CustomArgs args) + { + args = new CustomArgs("custom"); + return true; + } + + protected override void Execute(ChatCommandContext context, CustomArgs args) + { + context.Source.SendMsg(args.Value); + } + } + """ + ); + + var source = GeneratedRegistrySource(result); + + Assert.That(source, Does.Contain("global::Multiplayer.Common.CustomCommand.TryParse(context, out args)")); + } + + [Test] + public void ChatCommand_GeneratesParsersForOptionalPrimitiveEnumAndRestArguments() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public enum EchoMode + { + Normal, + Loud + } + + public readonly record struct EchoArgs( + [ChatArgument("count")] int Count = 1, + [ChatArgument("enabled")] bool Enabled = true, + [ChatArgument("mode")] EchoMode Mode = EchoMode.Normal, + [ChatRest] string? Text = null + ); + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + var source = GeneratedRegistrySource(result); + + Assert.That(source, Does.Contain("ChatCommandArgumentReader.TryParseInt(context.RawArgs[0], @\"count\"")); + Assert.That(source, Does.Contain("ChatCommandArgumentReader.TryParseBool(context.RawArgs[1], @\"enabled\"")); + Assert.That(source, Does.Contain("ChatCommandArgumentReader.TryParseEnum(context.RawArgs[2], @\"mode\"")); + Assert.That(source, Does.Contain("ChatCommandArgumentReader.JoinRest(context.RawArgs, 3)")); + Assert.That(source, Does.Contain("if (context.RawArgs.Count > 0)")); + Assert.That(source, Does.Contain("arg0 = 1;")); + } + + [Test] + public void ChatCommand_OptionalEnumDefaultGeneratesCompilableRegistry() + { + var result = RunGeneratorWithCompilation( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public enum EchoMode + { + Normal, + Loud + } + + public readonly record struct EchoArgs([ChatArgument("mode")] EchoMode Mode = EchoMode.Loud); + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That( + result.Compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty + ); + } + + [Test] + public void ChatCommand_RestArgumentMustBeString() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct EchoArgs([ChatRest] int Text); + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT004")); + } + + [Test] + public void ChatCommand_RestArgumentMustBeFinalArgument() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct EchoArgs([ChatRest] string Text, int Count); + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT004")); + } + + [Test] + public void ChatCommand_RestArgumentMustBeUnique() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct EchoArgs([ChatRest] string First, [ChatRest] string Second); + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT004")); + } + + [Test] + public void ChatCommand_AmbiguousArgumentConstructorsReportDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public sealed class EchoArgs + { + public EchoArgs(int count) + { + } + + public EchoArgs(string text) + { + } + } + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT005")); + } + + [Test] + public void ChatCommand_PrivateCustomParserDoesNotGenerateInaccessibleCall() + { + var result = RunGeneratorWithCompilation( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct EchoArgs(string Text); + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + private static bool TryParse(ChatCommandContext context, out EchoArgs args) + { + args = new EchoArgs("private"); + return true; + } + + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That( + result.Compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty + ); + } + + [Test] + public void ChatCommand_PrivateArgumentConstructorReportsDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public sealed class EchoArgs + { + private EchoArgs(string text) + { + } + } + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT005")); + } + + [Test] + public void ChatCommand_PrivateCommandConstructorReportsDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + [ChatCommand("secret")] + public sealed class SecretCommand : ChatCommand + { + private SecretCommand() + { + } + + public override void Execute(ChatCommandContext context) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT006")); + } + + [Test] + public void ChatCommand_AbstractCommandReportsDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + [ChatCommand("abstract")] + public abstract class AbstractCommand : ChatCommand + { + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT006")); + } + + [Test] + public void ChatCommand_BlankPrimaryNameReportsDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + [ChatCommand("")] + public sealed class BlankCommand : ChatCommand + { + public override void Execute(ChatCommandContext context) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT007")); + } + + [Test] + public void ChatCommand_BlankAliasReportsDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + [ChatCommand("named", "")] + public sealed class BlankAliasCommand : ChatCommand + { + public override void Execute(ChatCommandContext context) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT007")); + } + + [Test] + public void ChatCommand_DuplicateNameReportsDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + [ChatCommand("same")] + public sealed class FirstCommand : ChatCommand + { + public override void Execute(ChatCommandContext context) {} + } + + [ChatCommand("same")] + public sealed class SecondCommand : ChatCommand + { + public override void Execute(ChatCommandContext context) {} + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT001")); + } + + private static GeneratorDriverRunResult RunGenerator(string source) + { + return RunGeneratorWithCompilation(source).Result; + } + + private static GeneratorRun RunGeneratorWithCompilation(string source) + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic && !string.IsNullOrEmpty(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Append(MetadataReference.CreateFromFile(typeof(ChatCommandAttribute).Assembly.Location)) + .Cast(); + + var parseOptions = new CSharpParseOptions(LanguageVersion.CSharp12); + var registryDeclaration = CSharpSyntaxTree.ParseText( + """ + namespace Multiplayer.Common.ChatCommands; + + internal static partial class ChatCommandRegistry + { + public static partial void Register(ChatCommandManager manager, MultiplayerServer server); + } + """, + parseOptions + ); + + var compilation = CSharpCompilation.Create( + "ChatCommandGeneratorTests", + [CSharpSyntaxTree.ParseText(source, parseOptions), registryDeclaration], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new ChatCommandRegistryGenerator().AsSourceGenerator()], + parseOptions: parseOptions + ); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _); + return new GeneratorRun(driver.GetRunResult(), outputCompilation); + } + + private static string GeneratedRegistrySource(GeneratorDriverRunResult result) + { + return result.GeneratedTrees.Single(tree => tree.FilePath.EndsWith("ChatCommandRegistry.g.cs")).GetText().ToString(); + } + + private sealed record GeneratorRun(GeneratorDriverRunResult Result, Compilation Compilation); +} diff --git a/Source/Tests/ChatCommandManagerTest.cs b/Source/Tests/ChatCommandManagerTest.cs new file mode 100644 index 000000000..6dc33c554 --- /dev/null +++ b/Source/Tests/ChatCommandManagerTest.cs @@ -0,0 +1,656 @@ +using Multiplayer.Common; +using Multiplayer.Common.ChatCommands; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[TestFixture] +public class ChatCommandManagerTest +{ + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + [Test] + public void GeneratedRegistry_RegistersAliasesAsOneCommandInfo() + { + var server = MakeServer(); + + Assert.That(server.chatCmdManager.TryGetCommandInfo("help", out var help), Is.True); + Assert.That(server.chatCmdManager.TryGetCommandInfo("?", out var alias), Is.True); + + Assert.That(alias, Is.SameAs(help)); + Assert.That(help.Names, Is.EqualTo(["help", "?"])); + Assert.That(help.Command, Is.SameAs(alias.Command)); + } + + [Test] + public void GeneratedRegistry_StoresCommandMetadata() + { + var server = MakeServer(); + + Assert.That(server.chatCmdManager.TryGetCommandInfo("kick", out var info), Is.True); + Assert.That(info.Description, Is.EqualTo("Disconnect a player by username.")); + Assert.That(info.Usage, Is.EqualTo("kick ")); + Assert.That(info.RequiresHost, Is.True); + } + + [Test] + public void ManualRegistration_GroupsAliasesForTheSameCommand() + { + var server = MakeServer(); + var command = new RecordingCommand(); + + server.RegisterChatCommand("alias-one", command); + server.RegisterChatCommand("alias-two", command); + + Assert.That(server.chatCmdManager.TryGetCommandInfo("alias-one", out var first), Is.True); + Assert.That(server.chatCmdManager.TryGetCommandInfo("alias-two", out var second), Is.True); + Assert.That(second, Is.SameAs(first)); + Assert.That(first.Names, Is.EqualTo(["alias-one", "alias-two"])); + Assert.That(first.DisplayNames, Is.EqualTo("alias-one, alias-two")); + Assert.That(server.chatCmdManager.GetCommandInfos().Count(info => info.Command == command), Is.EqualTo(1)); + } + + [Test] + public void ManualRegistration_CanProvideMetadataForAliases() + { + var server = MakeServer(); + var command = new RecordingCommand(); + + server.RegisterChatCommand( + ["metadata", "meta"], + command, + "Manual description.", + "metadata ", + true + ); + + Assert.That(server.chatCmdManager.TryGetCommandInfo("metadata", out var primary), Is.True); + Assert.That(server.chatCmdManager.TryGetCommandInfo("meta", out var alias), Is.True); + Assert.That(alias, Is.SameAs(primary)); + Assert.That(primary.Names, Is.EqualTo(["metadata", "meta"])); + Assert.That(primary.DisplayNames, Is.EqualTo("metadata, meta")); + Assert.That(primary.Description, Is.EqualTo("Manual description.")); + Assert.That(primary.Usage, Is.EqualTo("metadata ")); + Assert.That(primary.RequiresHost, Is.True); + } + + [Test] + public void Commands_DispatchCaseInsensitively() + { + var server = MakeServer(); + var command = new RecordingCommand(); + var source = new RecordingChatSource(); + + server.RegisterChatCommand("mixed", command); + server.HandleChatCommand(source, "MIXED"); + + Assert.That(command.ExecutionCount, Is.EqualTo(1)); + Assert.That(source.Messages, Is.Empty); + } + + [Test] +#pragma warning disable CS0618 + public void LegacyRegistration_UsesOldHandlerMetadataAndDispatch() + { + var server = MakeServer(); + var command = new LegacyRecordingCommand(); + var source = new RecordingChatSource(); + + server.RegisterChatCmd("legacy", command); + server.HandleChatCmd(source, "legacy hello"); + + Assert.That(command.Args, Is.EqualTo(["hello"])); + Assert.That(server.chatCmdManager.TryGetCommandInfo("legacy", out var info), Is.True); + Assert.That(info.Description, Is.EqualTo("Legacy description.")); + Assert.That(info.Usage, Is.EqualTo("legacy ")); + Assert.That(info.RequiresHost, Is.True); + } +#pragma warning restore CS0618 + + [Test] + public void HelpCommand_PrintsMetadataForRequestedCommand() + { + var server = MakeServer(); + server.RegisterChatCommand( + ["documented", "doc"], + new RecordingCommand(), + "Manual description.", + "documented ", + true + ); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "help documented"); + + Assert.That( + source.Messages, + Is.EqualTo([ + "Command: documented, doc", + "Description: Manual description.", + "Usage: documented ", + "Requires host permissions." + ]) + ); + } + + [Test] + public void HostOnlyCommand_DoesNotExecuteForNonHostPlayer() + { + var server = MakeServer(); + var command = new RecordingCommand(); + server.RegisterChatCommand(["host-only"], command, requiresHost: true); + + var conn = new DummyConnection("guest"); + var player = new ServerPlayer(1, conn); + conn.serverPlayer = player; + server.hostUsername = "host"; + + server.HandleChatCommand(player, "host-only"); + + Assert.That(command.ExecutionCount, Is.Zero); + } + + [Test] + public void GeneratedTypedCommand_MissingRequiredArgumentRepliesWithUsage() + { + var server = MakeServer(); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "kick"); + + Assert.That(source.Messages, Is.EqualTo(["Usage: kick "])); + Assert.That(source.RawMessages, Is.EqualTo(["Usage: kick "])); + } + + [Test] + public void GeneratedTypedCommand_MissingWhoisArgumentRepliesWithUsernameUsage() + { + var server = MakeServer(); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois"); + + Assert.That(source.Messages, Is.EqualTo(["Usage: whois "])); + Assert.That(source.RawMessages, Is.EqualTo(["Usage: whois "])); + } + + [Test] + public void GeneratedTypedCommand_MissingResyncArgumentRepliesWithUsernameUsage() + { + var server = MakeServer(); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "resync"); + + Assert.That(source.Messages, Is.EqualTo(["Usage: resync "])); + Assert.That(source.RawMessages, Is.EqualTo(["Usage: resync "])); + } + + [Test] + public void GeneratedTypedCommand_MissingArgumentSentToPlayerKeepsRawUsageText() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "host", isHost: true); + + server.HandleChatCommand(player, "resync"); + + var conn = (RecordingConnection)player.conn; + Assert.That(conn.ChatMessages, Is.EqualTo(["Usage: resync "])); + Assert.That(conn.RawChatMessages, Is.EqualTo(["Usage: resync "])); + } + + [Test] + public void PlayersCommand_ListsConnectedPlayers() + { + var server = MakeServer(); + AddPlayingPlayer(server, "host", isHost: true, factionId: 4, currentMapId: 0); + AddPlayingPlayer(server, "guest", factionId: 7, currentMapId: 2); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "players"); + + Assert.That(source.Messages[0], Is.EqualTo("Players (2):")); + Assert.That(source.Messages[1], Does.Contain("host")); + Assert.That(source.Messages[1], Does.Contain("[Playing]")); + Assert.That(source.Messages[1], Does.Contain("faction=4")); + Assert.That(source.Messages[1], Does.Contain("map=0")); + Assert.That(source.Messages[2], Does.Contain("guest")); + Assert.That(source.Messages[2], Does.Contain("player")); + Assert.That(source.Messages[2], Does.Contain("faction=7")); + Assert.That(source.Messages[2], Does.Contain("map=2")); + } + + [Test] + public void WhoisCommand_ShowsPlayerDetails() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "guest", factionId: 7, currentMapId: 2); + player.ticksBehind = 12; + player.steamId = 12345; + player.steamPersonaName = "Steam Guest"; + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois guest"); + + Assert.That(source.Messages, Does.Contain($"Player: guest (#{player.id})")); + Assert.That(source.Messages, Does.Contain("Status: Playing")); + Assert.That(source.Messages, Does.Contain("Role: player")); + Assert.That(source.Messages, Does.Contain("Faction: 7")); + Assert.That(source.Messages, Does.Contain("Map: 2")); + Assert.That(source.Messages, Does.Contain("Ticks behind: 12")); + Assert.That(source.Messages, Does.Contain("Steam: Steam Guest (12345)")); + } + + [Test] + public void PlayerArgument_ResolvesUniquePartialName() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "NemuruYama", factionId: 7, currentMapId: 2); + AddPlayingPlayer(server, "OtherPlayer"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois Nemu"); + + Assert.That(source.Messages, Does.Contain($"Player: NemuruYama (#{player.id})")); + } + + [Test] + public void PlayerArgument_RejectsAmbiguousPartialNameBeforeExecute() + { + var server = MakeServer(); + AddPlayingPlayer(server, "NemuruYama1"); + AddPlayingPlayer(server, "NemuruYama2"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois Nemu"); + + Assert.That( + source.Messages, + Is.EqualTo(["Player name 'Nemu' is ambiguous: NemuruYama1, NemuruYama2."]) + ); + } + + [Test] + public void PlayerArgument_ExactNameWinsOverAmbiguousPartialName() + { + var server = MakeServer(); + var exact = AddPlayingPlayer(server, "Nemu"); + AddPlayingPlayer(server, "NemuruYama1"); + AddPlayingPlayer(server, "NemuruYama2"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois Nemu"); + + Assert.That(source.Messages, Does.Contain($"Player: Nemu (#{exact.id})")); + } + + [Test] + public void PlayerArgument_ResolvesQuotedPlayerName() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "Player Name", factionId: 7, currentMapId: 2); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois \"Player Name\""); + + Assert.That(source.Messages, Does.Contain($"Player: Player Name (#{player.id})")); + } + + [Test] + public void SinglePlayerArgument_UsesRemainingTextAsPlayerName() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "Player Name", factionId: 7, currentMapId: 2); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois Player Name"); + + Assert.That(source.Messages, Does.Contain($"Player: Player Name (#{player.id})")); + } + + [Test] + public void QuotedArgument_MissingClosingQuoteStopsBeforeDispatch() + { + var server = MakeServer(); + var command = new RecordingCommand(); + var source = new RecordingChatSource(); + + server.RegisterChatCommand("record", command); + server.HandleChatCommand(source, "record \"unfinished"); + + Assert.That(command.ExecutionCount, Is.Zero); + Assert.That(source.Messages, Is.EqualTo(["Invalid command arguments: missing closing quote."])); + } + + [Test] + public void StatusCommand_ShowsServerSummary() + { + var server = MakeServer(); + server.running = true; + server.gameTimer = 120; + server.workTicks = 45; + server.worldData.savedGame = [1, 2, 3]; + server.worldData.sessionData = [4, 5, 6]; + server.worldData.mapData[10] = [7, 8, 9]; + server.worldData.lastJoinPointAtTick = 90; + AddPlayingPlayer(server, "host", isHost: true); + AddPlayingPlayer(server, "guest"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "status"); + + Assert.That(source.Messages, Does.Contain("Server: running")); + Assert.That(source.Messages, Does.Contain("World: loaded, maps=1, join point=last at tick 90")); + Assert.That(source.Messages, Does.Contain("Ticks: game=120, net=0, work=45")); + Assert.That(source.Messages, Does.Contain("Players: connected=2, joined=2, playing=2")); + } + + [Test] + public void ModsCommand_ShowsServerModList() + { + var server = MakeServer(); + server.StartInitData().SetResult(new ServerInitData( + ClientInitDataPacket.ModData.ListBinder.Serialize([ + ModData("Core", "ludeon.rimworld"), + ModData("Multiplayer", "rwmt.multiplayer") + ]), + false, + "1.6.4633", + [], + [], + default, + [] + )); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "mods"); + + Assert.That(source.Messages, Does.Contain("RimWorld: 1.6.4633")); + Assert.That(source.Messages, Does.Contain("Mods (2):")); + Assert.That(source.Messages, Does.Contain("- Core (ludeon.rimworld)")); + Assert.That(source.Messages, Does.Contain("- Multiplayer (rwmt.multiplayer)")); + } + + [Test] + public void ModsCommand_CanPageThroughServerModList() + { + var server = MakeServer(); + server.StartInitData().SetResult(new ServerInitData( + ClientInitDataPacket.ModData.ListBinder.Serialize([ + ModData("Core", "ludeon.rimworld"), + ModData("Royalty", "ludeon.rimworld.royalty"), + ModData("Ideology", "ludeon.rimworld.ideology"), + ModData("Biotech", "ludeon.rimworld.biotech"), + ModData("Multiplayer", "rwmt.multiplayer") + ]), + false, + "1.6.4633", + [], + [], + default, + [] + )); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "mods 2 2"); + + Assert.That(source.Messages, Does.Contain("Mods (5), page 2/3:")); + Assert.That(source.Messages, Does.Contain("- Ideology (ludeon.rimworld.ideology)")); + Assert.That(source.Messages, Does.Contain("- Biotech (ludeon.rimworld.biotech)")); + Assert.That(source.Messages, Does.Not.Contain("- Core (ludeon.rimworld)")); + Assert.That(source.Messages, Does.Not.Contain("- Multiplayer (rwmt.multiplayer)")); + } + + [Test] + public void ResyncCommand_SendsWorldDataReloadRequest() + { + var server = MakeServer(); + server.worldData.savedGame = []; + server.worldData.sessionData = []; + var player = AddPlayingPlayer(server, "guest"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "resync guest"); + + var connection = (RecordingConnection)player.conn; + Assert.That(connection.PacketIds, Does.Contain(Packets.Server_RequestRejoin)); + Assert.That(connection.PacketIds, Does.Not.Contain(Packets.Server_WorldDataStart)); + Assert.That(connection.PacketIds, Does.Not.Contain(Packets.Server_WorldData)); + Assert.That(source.Messages, Does.Contain("Resync requested for guest.")); + } + + [Test] + public void TimeControlCommands_SchedulePlayerScopedCommands() + { + var server = MakeServer(); + var source = AddPlayingPlayer(server, "guest", factionId: 7); + + server.HandleChatCommand(source, "pause"); + var pauseCommand = LastGlobalCommand(server); + + server.HandleChatCommand(source, "unpause"); + var unpauseCommand = LastGlobalCommand(server); + + server.HandleChatCommand(source, "speed 3"); + var speedCommand = LastGlobalCommand(server); + + Assert.That(pauseCommand.type, Is.EqualTo(CommandType.GlobalTimeSpeed)); + Assert.That(pauseCommand.factionId, Is.EqualTo(7)); + Assert.That(pauseCommand.playerId, Is.EqualTo(source.id)); + Assert.That(pauseCommand.data, Is.EqualTo([(byte)TimeVote.Paused])); + Assert.That(unpauseCommand.type, Is.EqualTo(CommandType.GlobalTimeSpeed)); + Assert.That(unpauseCommand.factionId, Is.EqualTo(7)); + Assert.That(unpauseCommand.playerId, Is.EqualTo(source.id)); + Assert.That(unpauseCommand.data, Is.EqualTo([(byte)TimeVote.Normal])); + Assert.That(speedCommand.type, Is.EqualTo(CommandType.GlobalTimeSpeed)); + Assert.That(speedCommand.factionId, Is.EqualTo(7)); + Assert.That(speedCommand.playerId, Is.EqualTo(source.id)); + Assert.That(speedCommand.data, Is.EqualTo([(byte)TimeVote.Superfast])); + Assert.That(((RecordingConnection)source.conn).ChatMessages, Does.Contain("Speed set to Paused.")); + Assert.That(((RecordingConnection)source.conn).ChatMessages, Does.Contain("Speed set to Normal.")); + Assert.That(((RecordingConnection)source.conn).ChatMessages, Does.Contain("Speed set to Superfast.")); + } + + [Test] + public void TimeControlCommands_UseLowestWinsVoteWhenEnabled() + { + var server = MakeServer(); + server.settings.timeControl = TimeControl.LowestWins; + var source = AddPlayingPlayer(server, "guest", factionId: 7); + + server.HandleChatCommand(source, "pause"); + + var command = LastGlobalCommand(server); + var data = new ByteReader(command.data); + Assert.That(command.type, Is.EqualTo(CommandType.TimeSpeedVote)); + Assert.That(command.factionId, Is.EqualTo(7)); + Assert.That(command.playerId, Is.EqualTo(source.id)); + Assert.That((TimeVote)data.ReadByte(), Is.EqualTo(TimeVote.Paused)); + Assert.That(data.ReadInt32(), Is.EqualTo(ScheduledCommand.Global)); + } + + [Test] + public void TimeControlCommands_RespectHostOnlyTimeControlSetting() + { + var server = MakeServer(); + server.settings.timeControl = TimeControl.HostOnly; + AddPlayingPlayer(server, "host", isHost: true); + var guest = AddPlayingPlayer(server, "guest"); + + server.HandleChatCommand(guest, "pause"); + + Assert.That(server.worldData.mapCmds.ContainsKey(ScheduledCommand.Global), Is.False); + Assert.That(((RecordingConnection)guest.conn).ChatMessages, Does.Contain("No permission")); + } + + [Test] + public void HelpCommand_CanShowOnlyCommandsThePlayerCanUse() + { + var server = MakeServer(); + server.settings.timeControl = TimeControl.HostOnly; + AddPlayingPlayer(server, "host", isHost: true); + var guest = AddPlayingPlayer(server, "guest"); + guest.helpOnlyUsableCommands = true; + + server.HandleChatCommand(guest, "help"); + + var messages = ((RecordingConnection)guest.conn).ChatMessages; + Assert.That(messages, Does.Contain("Available commands you can use:")); + Assert.That(messages, Does.Contain("- help, ?: Show available commands or detailed help for one command.")); + Assert.That(messages, Does.Contain("- whois: Show details for a connected player.")); + Assert.That(messages.Any(message => message.StartsWith("- kick:")), Is.False); + Assert.That(messages.Any(message => message.StartsWith("- pause:")), Is.False); + } + + [Test] + public void AnnounceCommand_BroadcastsServerAnnouncement() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "guest"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "announce raid soon"); + + Assert.That(player.conn, Is.TypeOf()); + Assert.That(((RecordingConnection)player.conn).ChatMessages, Does.Contain("[Announcement] raid soon")); + } + + [Test] + public void AnnounceCommand_PreservesQuotedMessageAsOneArgument() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "guest"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "announce \"raid soon\""); + + Assert.That(player.conn, Is.TypeOf()); + Assert.That(((RecordingConnection)player.conn).ChatMessages, Does.Contain("[Announcement] raid soon")); + } + + private static MultiplayerServer MakeServer() + { + return MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + } + + private static ServerPlayer AddPlayingPlayer( + MultiplayerServer server, + string username, + bool isHost = false, + int factionId = 0, + int currentMapId = -1 + ) + { + var player = server.playerManager.OnConnected(new RecordingConnection(username)); + player.FactionId = factionId; + player.currentMapId = currentMapId; + player.hasJoined = true; + player.status = PlayerStatus.Playing; + player.conn.ChangeState(ConnectionStateEnum.ServerPlaying); + + if (isHost) + server.hostUsername = username; + + return player; + } + + private static ScheduledCommand LastGlobalCommand(MultiplayerServer server) + { + return ScheduledCommand.Deserialize(new ByteReader(server.worldData.mapCmds[ScheduledCommand.Global].Last())); + } + + private static ClientInitDataPacket.ModData ModData(string name, string packageId) + { + return new ClientInitDataPacket.ModData + { + name = name, + packageIdNonUnique = packageId, + files = [] + }; + } + + private sealed class RecordingChatSource : IChatSource + { + public List Messages { get; } = []; + public List RawMessages { get; } = []; + + public void SendMsg(string msg) + { + Messages.Add(msg); + } + + public void SendRawMsg(string msg) + { + Messages.Add(msg); + RawMessages.Add(msg); + } + } + + private sealed class RecordingCommand : ChatCommand + { + public int ExecutionCount { get; private set; } + + public override void Execute(ChatCommandContext context) + { + ExecutionCount++; + } + } + + private sealed class RecordingConnection : ConnectionBase + { + public List ChatMessages { get; } = []; + public List RawChatMessages { get; } = []; + public List PacketIds { get; } = []; + + public RecordingConnection(string username) + { + this.username = username; + } + + public override int Latency { get => 0; set { } } + + protected override void SendRaw(byte[] raw, bool reliable) + { + var packetId = (Packets)(raw[0] & 0x3F); + PacketIds.Add(packetId); + if (packetId != Packets.Server_Chat) + return; + + var packet = new ServerChatPacket(); + packet.Bind(new PacketReader(new ByteReader(raw[1..]))); + ChatMessages.Add(packet.msg ?? ""); + if (packet.rawMessage) + RawChatMessages.Add(packet.msg ?? ""); + } + + protected override void OnClose(ServerDisconnectPacket? goodbye) { } + } + +#pragma warning disable CS0618 + private sealed class LegacyRecordingCommand : ChatCmdHandler + { + public string[] Args { get; private set; } = []; + public override string Description => "Legacy description."; + public override string Usage => "legacy "; + + public LegacyRecordingCommand() + { + requiresHost = true; + } + + public override void Handle(IChatSource source, string[] args) + { + Args = args; + } + } +#pragma warning restore CS0618 +} diff --git a/Source/Tests/CommandTokenizerTest.cs b/Source/Tests/CommandTokenizerTest.cs new file mode 100644 index 000000000..3060e5937 --- /dev/null +++ b/Source/Tests/CommandTokenizerTest.cs @@ -0,0 +1,46 @@ +using Multiplayer.Common.ChatCommands; + +namespace Tests; + +[TestFixture] +public class CommandTokenizerTest +{ + [TestCase("", new string[0])] + [TestCase(" ", new string[0])] + [TestCase("speed 3", new[] { "speed", "3" })] + [TestCase(" speed 3 ", new[] { "speed", "3" })] + [TestCase("whois PlayerName", new[] { "whois", "PlayerName" })] + [TestCase("announce raid soon", new[] { "announce", "raid", "soon" })] + [TestCase("help ?", new[] { "help", "?" })] + public void Tokenize_PlainCommandText_ReturnsWhitespaceSeparatedArguments(string input, string[] expected) + { + Assert.That(CommandTokenizer.TryTokenize(input, out var tokens, out var error), Is.True); + Assert.That(error, Is.Null); + Assert.That(tokens, Is.EqualTo(expected)); + } + + [TestCase("whois \"Player Name\"", new[] { "whois", "Player Name" })] + [TestCase("announce \"raid soon\"", new[] { "announce", "raid soon" })] + [TestCase("kick \"\"", new[] { "kick", "" })] + [TestCase("announce \"hello \\\"world\\\"\"", new[] { "announce", "hello \"world\"" })] + [TestCase("announce \"C:\\\\Games\\\\RimWorld\"", new[] { "announce", "C:\\Games\\RimWorld" })] + [TestCase("command pre\"quoted middle\"post", new[] { "command", "prequoted middlepost" })] + [TestCase("command \"quoted\"plain", new[] { "command", "quotedplain" })] + [TestCase("command plain\"quoted\"", new[] { "command", "plainquoted" })] + public void Tokenize_QuotedCommandText_ReturnsQuotedContentAsArgument(string input, string[] expected) + { + Assert.That(CommandTokenizer.TryTokenize(input, out var tokens, out var error), Is.True); + Assert.That(error, Is.Null); + Assert.That(tokens, Is.EqualTo(expected)); + } + + [TestCase("whois \"Player Name")] + [TestCase("announce raid \"soon")] + [TestCase("\"")] + public void Tokenize_MissingClosingQuote_ReturnsHelpfulError(string input) + { + Assert.That(CommandTokenizer.TryTokenize(input, out var tokens, out var error), Is.False); + Assert.That(tokens, Is.Empty); + Assert.That(error, Is.EqualTo("Invalid command arguments: missing closing quote.")); + } +} diff --git a/Source/Tests/Helper/TestJoiningState.cs b/Source/Tests/Helper/TestJoiningState.cs index e1f0bb6f9..5121b0cc5 100644 --- a/Source/Tests/Helper/TestJoiningState.cs +++ b/Source/Tests/Helper/TestJoiningState.cs @@ -9,6 +9,9 @@ public TestJoiningState(ConnectionBase connection) : base(connection) { } + [TypedPacketHandler] + public void HandleKeepAlive(ServerKeepAlivePacket packet) { } + private const string RwVersion = "1.0.0"; protected override async Task RunState() @@ -27,7 +30,7 @@ protected override async Task RunState() modCtorRoundMode = RoundModeEnum.ToNearest, staticCtorRoundMode = RoundModeEnum.ToNearest, defInfos = [], - rawData = [] + rawMods = [] }); var p = await Packet(Packets.Server_UsernameOk); diff --git a/Source/Tests/Helper/TestLoadingKeepAliveState.cs b/Source/Tests/Helper/TestLoadingKeepAliveState.cs index f8c5db33d..c0bb37ef4 100644 --- a/Source/Tests/Helper/TestLoadingKeepAliveState.cs +++ b/Source/Tests/Helper/TestLoadingKeepAliveState.cs @@ -9,6 +9,9 @@ public TestLoadingKeepAliveState(ConnectionBase connection) : base(connection) { } + [TypedPacketHandler] + public void HandleKeepAlive(ServerKeepAlivePacket packet) { } + private const string RwVersion = "1.0.0"; protected override async Task RunState() @@ -27,7 +30,7 @@ protected override async Task RunState() modCtorRoundMode = RoundModeEnum.ToNearest, staticCtorRoundMode = RoundModeEnum.ToNearest, defInfos = [], - rawData = [] + rawMods = [] }); var packet = await Packet(Packets.Server_UsernameOk); diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index b1bd0e726..fbe1eac84 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -164,14 +164,15 @@ private static IEnumerable RoundtripPackets() yield return ServerChatPacket.Create(""); yield return ServerChatPacket.Create("ABC123!@#"); + yield return ServerChatPacket.CreateRaw("Usage: whois "); yield return ClientChatPacket.Create(""); yield return ClientChatPacket.Create("ABC123!@#"); yield return new ClientProtocolPacket(50); - yield return new ServerProtocolOkPacket(true); - yield return new ServerProtocolOkPacket(false); + yield return new ServerProtocolOkPacket(true, true) { autosaveInterval = 5f, autosaveUnit = AutosaveUnit.Minutes }; + yield return new ServerProtocolOkPacket(false, false); yield return new ClientUsernamePacket("username"); yield return new ClientUsernamePacket("username", "password"); @@ -187,6 +188,15 @@ private static IEnumerable RoundtripPackets() ] }; + var mpModData = new ClientInitDataPacket.ModData + { + name = "Multiplayer", + packageIdNonUnique = "rwmt.multiplayer", + source = ClientInitDataPacket.ModSource.SteamWorkshop, + config = null, + files = [], + publishedFileId = 0, + }; yield return new ServerJoinDataPacket { gameName = "GameName", @@ -198,7 +208,8 @@ private static IEnumerable RoundtripPackets() DefCheckStatus.Ok, DefCheckStatus.Ok, DefCheckStatus.Count_Diff, DefCheckStatus.Hash_Diff, DefCheckStatus.Not_Found ], - rawServerInitData = [1, 2, 3, 4, 5] + configsIncluded = false, + ServerInitData = [mpModData], }; yield return new ClientFrameTimePacket(0f); @@ -220,7 +231,7 @@ private static IEnumerable RoundtripPackets() new KeyedDefInfo { name = "key", count = 1, hash = 123 }, new KeyedDefInfo { name = "key2", count = 0, hash = 0 } ], - rawData = [1, 2, 3, 4, 5] + Mods = [mpModData], }; // real code is using GZip compressed content for the traces, but we are only testing on the wire representation @@ -244,6 +255,13 @@ private static IEnumerable RoundtripPackets() yield return new ClientDesyncedPacket(100, 0); } + private static readonly List UnstablePackets = [ + // Uses deflate compression which *is* lossless, but it is not + // guaranteed to always be represented by the same bytes + typeof(ClientInitDataPacket), + typeof(ServerJoinDataPacket) + ]; + [TestCaseSource(nameof(RoundtripPackets))] public void TestRoundtrip(IPacket original) { @@ -262,6 +280,11 @@ public async Task SnapshotBinaryRepresentation() { var binder = RuntimeBinderOf(packetsOfType.Key); var text = new StringBuilder(); + var stable = !UnstablePackets.Contains(packetsOfType.Key); + if (!stable) + text.Append("This packet is not byte-stable while serialized, meaning it can be serialized" + + " differently due to various factors, but it does deserialize into the same object\n\n"); + foreach (var packet in packetsOfType) { var serialized = binder.Serialize(packet); @@ -272,7 +295,7 @@ public async Task SnapshotBinaryRepresentation() } await Verify(text).UseDirectory("packet-serializations").UseFileName(packetsOfType.Key.Name).DisableDiff() - .AutoVerify(includeBuildServer: false); + .AutoVerify(includeBuildServer: !stable); } } diff --git a/Source/Tests/ServerTest.cs b/Source/Tests/ServerTest.cs index 611ee3dfb..4cfff8bd5 100644 --- a/Source/Tests/ServerTest.cs +++ b/Source/Tests/ServerTest.cs @@ -96,6 +96,35 @@ public void LoadingStateHandlesKeepAliveWhileWaitingForJoinPoint() } } + [Test] + public void StandaloneJoinWithExistingPlayer_DoesNotStartJoinPoint() + { + var server = MakeServer(out var port); + server.IsStandaloneServer = true; + + var existingConn = new RecordingConnection("existing"); + existingConn.ChangeState(ConnectionStateEnum.ServerPlaying); + var existingPlayer = new ServerPlayer(100, existingConn); + existingConn.serverPlayer = existingPlayer; + server.playerManager.Players.Add(existingPlayer); + + ConnectClient(port, typeof(TestJoiningState)); + + var timeoutWatch = Stopwatch.StartNew(); + while (true) + { + if (server.playerManager.Players.Count == 1) + break; + + if (timeoutWatch.ElapsedMilliseconds > 2000) + Assert.Fail("Timeout"); + + Thread.Sleep(50); + } + + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + } + private void ConnectClient(int port, Type joiningStateType) { var clientListener = new TestNetListener(joiningStateType); @@ -124,7 +153,8 @@ private MultiplayerServer MakeServer(out int port) gameName = "Test", direct = true, directAddress = "127.0.0.1:0", // 0 makes the OS choose any free port - lan = false + lan = false, + autoJoinPoint = 0 // Disable: test server has no game simulation to complete join points }) { running = true diff --git a/Source/Tests/StandaloneMapStreamingTest.cs b/Source/Tests/StandaloneMapStreamingTest.cs index 37cf4393d..6676875b1 100644 --- a/Source/Tests/StandaloneMapStreamingTest.cs +++ b/Source/Tests/StandaloneMapStreamingTest.cs @@ -140,4 +140,38 @@ public void MapToMapTransition_DoesNotSendMapResponseWhenStreamingDisabled() Assert.That(player.currentMapId, Is.EqualTo(5)); Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_MapResponse)); } + + [Test] + public void HandleDebug_IgnoredWhenDevModeDisabled() + { + server.gameTimer = 123; + server.startingTimer = 5; + server.worldData.mapCmds[1] = [[1]]; + var (player, conn) = AddPlayer("player", 1); + + var state = player.conn.GetState()!; + state.HandleDebug(new ClientDebugPacket()); + + Assert.That(server.gameTimer, Is.EqualTo(123)); + Assert.That(server.worldData.mapCmds[1], Has.Count.EqualTo(1)); + Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_Debug)); + } + + [Test] + public void HandleDebug_AllowsPlayersWhenDevModeEnabled() + { + server.settings.debugMode = true; + server.settings.devModeScope = DevModeScope.Everyone; + server.gameTimer = 123; + server.startingTimer = 5; + server.worldData.mapCmds[1] = [[1]]; + var (player, conn) = AddPlayer("player", 1); + + var state = player.conn.GetState()!; + state.HandleDebug(new ClientDebugPacket()); + + Assert.That(server.gameTimer, Is.EqualTo(5)); + Assert.That(server.worldData.mapCmds, Is.Empty); + Assert.That(conn.SentPackets, Does.Contain(Packets.Server_Debug)); + } } diff --git a/Source/Tests/StandalonePersistenceTest.cs b/Source/Tests/StandalonePersistenceTest.cs new file mode 100644 index 000000000..dd421a361 --- /dev/null +++ b/Source/Tests/StandalonePersistenceTest.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Multiplayer.Common; + +namespace Tests; + +[TestFixture] +public class StandalonePersistenceTest +{ + private string tempDir = null!; + + [SetUp] + public void SetUp() + { + tempDir = Path.Combine(Path.GetTempPath(), $"mp-standalone-persistence-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + + [Test] + public void WriteJoinPoint_PersistsCommandsAndTickForReload() + { + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = new StandalonePersistence(tempDir), + }; + + server.worldData.savedGame = [1, 2, 3]; + server.worldData.sessionData = [4, 5, 6]; + server.worldData.mapData[7] = [7, 8, 9]; + + var worldCmd = ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Sync, 1234, 1, ScheduledCommand.Global, 5, [10])); + var mapCmd = ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 1234, 1, 7, 5, [11])); + server.worldData.mapCmds[ScheduledCommand.Global] = [worldCmd]; + server.worldData.mapCmds[7] = [mapCmd]; + + server.persistence.WriteJoinPoint(server.worldData, 1234); + + var reloadedServer = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = new StandalonePersistence(tempDir), + }; + + var info = reloadedServer.persistence.LoadInto(reloadedServer); + + Assert.That(info, Is.Null); + Assert.That(reloadedServer.gameTimer, Is.EqualTo(1234)); + Assert.That(reloadedServer.startingTimer, Is.EqualTo(1234)); + Assert.That(reloadedServer.worldData.savedGame, Is.EqualTo(new byte[] { 1, 2, 3 })); + Assert.That(reloadedServer.worldData.sessionData, Is.EqualTo(new byte[] { 4, 5, 6 })); + Assert.That(reloadedServer.worldData.mapData[7], Is.EqualTo(new byte[] { 7, 8, 9 })); + Assert.That(reloadedServer.worldData.mapCmds[ScheduledCommand.Global], Has.Count.EqualTo(1)); + Assert.That(reloadedServer.worldData.mapCmds[7], Has.Count.EqualTo(1)); + + var reloadedWorldCmd = ScheduledCommand.Deserialize(new ByteReader(reloadedServer.worldData.mapCmds[ScheduledCommand.Global][0])); + var reloadedMapCmd = ScheduledCommand.Deserialize(new ByteReader(reloadedServer.worldData.mapCmds[7][0])); + + Assert.That(reloadedWorldCmd.ticks, Is.EqualTo(1234)); + Assert.That(reloadedMapCmd.ticks, Is.EqualTo(1234)); + Assert.That(reloadedServer.worldData.standaloneWorldSnapshot.tick, Is.EqualTo(1234)); + Assert.That(reloadedServer.worldData.standaloneMapSnapshots[7].tick, Is.EqualTo(1234)); + } + + [Test] + public void LoadInto_FallsBackToLatestReplaySectionTick() + { + var persistence = new StandalonePersistence(tempDir); + persistence.EnsureDirectories(); + + File.WriteAllBytes(Path.Combine(tempDir, "Saved", "world.dat"), [1]); + File.WriteAllBytes(Path.Combine(tempDir, "Saved", "session.dat"), []); + File.WriteAllBytes(Path.Combine(tempDir, "Saved", "world_cmds.dat"), ScheduledCommand.SerializeCmds(new List())); + File.WriteAllBytes(Path.Combine(tempDir, "Saved", "info.xml"), ReplayInfo.Write(new ReplayInfo + { + sections = new List + { + new(100, 100), + new(999, 999), + } + })); + + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = persistence, + }; + + persistence.LoadInto(server); + + Assert.That(server.gameTimer, Is.EqualTo(999)); + Assert.That(server.startingTimer, Is.EqualTo(999)); + } +} \ No newline at end of file diff --git a/Source/Tests/Tests.csproj b/Source/Tests/Tests.csproj index 202f24066..17b0b2fce 100644 --- a/Source/Tests/Tests.csproj +++ b/Source/Tests/Tests.csproj @@ -12,18 +12,21 @@ + - + + + diff --git a/Source/Tests/packet-serializations/ClientChatPacket.verified.txt b/Source/Tests/packet-serializations/ClientChatPacket.verified.txt index a263e7895..167f64a0c 100644 --- a/Source/Tests/packet-serializations/ClientChatPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientChatPacket.verified.txt @@ -1,2 +1,2 @@ -00-00-00-00 -09-00-00-00-41-42-43-31-32-33-21-40-23 +00-00-00-00-00 +09-00-00-00-41-42-43-31-32-33-21-40-23-00 diff --git a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt index 04a42934b..b87199a17 100644 --- a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt @@ -1 +1,3 @@ -05-00-00-00-31-2E-30-2E-30-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-01-02-03-04-05 (81 bytes) +This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object + +05-00-00-00-31-2E-30-2E-30-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-00-24-00-00-00-63-64-60-60-10-00-E2-A2-F2-DC-12-BD-DC-D2-9C-92-CC-82-9C-C4-CA-D4-22-6E-A0-98-2F-82-0B-E4-81-01-33-98-04-00 (117 bytes) diff --git a/Source/Tests/packet-serializations/ServerChatPacket.verified.txt b/Source/Tests/packet-serializations/ServerChatPacket.verified.txt index a263e7895..472e88178 100644 --- a/Source/Tests/packet-serializations/ServerChatPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerChatPacket.verified.txt @@ -1,2 +1,3 @@ -00-00-00-00 -09-00-00-00-41-42-43-31-32-33-21-40-23 +00-00-00-00-00 +09-00-00-00-41-42-43-31-32-33-21-40-23-00 +17-00-00-00-55-73-61-67-65-3A-20-77-68-6F-69-73-20-3C-75-73-65-72-6E-61-6D-65-3E-01 diff --git a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt index e7915cf1e..98e950920 100644 --- a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt @@ -1 +1,3 @@ -08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-05-00-00-00-00-00-02-03-01-01-02-03-04-05 (59 bytes) +This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object + +08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-05-00-00-00-00-00-02-03-01-00-24-00-00-00-63-64-60-60-10-00-E2-A2-F2-DC-12-BD-DC-D2-9C-92-CC-82-9C-C4-CA-D4-22-6E-A0-98-2F-82-0B-E4-81-01-33-98-04-00 (95 bytes) diff --git a/Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt b/Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt index 4f32769c1..db302e132 100644 --- a/Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt @@ -1,2 +1,2 @@ -01 -00 +01-01-00-00-A0-40-01-00-00-00 +00-00-00-00-00-00-00-00-00-00 diff --git a/Source/TestsOnMono/TestsOnMono.csproj b/Source/TestsOnMono/TestsOnMono.csproj index 5c89b2d87..187ca8c74 100644 --- a/Source/TestsOnMono/TestsOnMono.csproj +++ b/Source/TestsOnMono/TestsOnMono.csproj @@ -10,7 +10,7 @@ - +