diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 32233940b..0346502e3 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using HarmonyLib; using Multiplayer.Client.Comp; +using Multiplayer.Client.Desyncs; using Multiplayer.Client.Factions; using Multiplayer.Client.Patches; using Multiplayer.Client.Saving; @@ -64,18 +65,17 @@ public TimeSpeed DesiredTimeSpeed public int TickableId => map.uniqueID; - public int GameStartAbsTick - { - get - { - if (gameStartAbsTickMap == 0) - { - gameStartAbsTickMap = Find.TickManager?.gameStartAbsTick ?? 0; - } - - return gameStartAbsTickMap; - } - } + // Pure: this is read from UI-reachable context installs + // (TimeSnapshot.GetAndSetFromMap), and a read that wrote the ambient + // global into the scribed field made render order and viewer identity + // inputs to TicksAbs - an RNG seed in pawn think trees + // (RandSeedForHour), wind and sky glow - so a render bug could become + // a desync and a save diff. The scribed field is resolved once in + // FinalizeInit instead; the fallback covers reads before that point + // without writing anything. + public int GameStartAbsTick => gameStartAbsTickMap != 0 + ? gameStartAbsTickMap + : Find.TickManager?.gameStartAbsTick ?? 0; public Map map; public int mapTicks; @@ -95,7 +95,31 @@ public int GameStartAbsTick public Queue cmds = new(); - public int CurrentPlayerCount { get; private set; } + private int cachedPlayerCount; + private int cachedPlayerCountVersion = -1; + + // Derived from the synced view table on demand, never incremented. + // The version gate makes the count self-healing: comps recreated by a + // reload start stale and re-derive on first read, so there is no + // window where a viewed map sits at the no-viewer rate. + public int CurrentPlayerCount + { + get + { + var gameComp = Multiplayer.GameComp; + if (cachedPlayerCountVersion != gameComp.playerViewsVersion) + { + cachedPlayerCount = 0; + foreach (var viewedMapId in gameComp.playerViewedMaps.Values) + if (viewedMapId == map.uniqueID) + cachedPlayerCount++; + cachedPlayerCountVersion = gameComp.playerViewsVersion; + } + + return cachedPlayerCount; + } + } + public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; public AsyncTimeComp(Map map, int gameStartAbsTick = 0) @@ -174,7 +198,17 @@ public void UpdateManagers() map.glowGrid.GlowGridUpdate_First(); } - private TimeSnapshot? prevTime; + // Contexts nest on the same comp by design (the quest brackets in + // MultiplayerAsyncQuest run inside Tick(), SetContextForAccept inside + // ExecuteCmd), so the restore state is a stack like the Rand and + // faction state around it: a single field let an inner bracket clobber + // the outer snapshot, leaking the inner clock for the rest of the frame. + private readonly Stack prevTimes = new(); + + // Expected faction stack depth per nested bracket, recorded at + // PreContext exit - PostContext unwinds any entries stranded above it + // by an exception before doing its own pop (see UnwindFactionStack) + private readonly Stack prevFactionDepths = new(); public void PreContext() { @@ -184,7 +218,9 @@ public void PreContext() : Multiplayer.WorldComp.spectatorFaction, force: true); - prevTime = TimeSnapshot.GetAndSetFromMap(map); + prevFactionDepths.Push(FactionContext.stack.Count); + + prevTimes.Push(TimeSnapshot.GetAndSetFromMap(map)); Rand.PushState(); Rand.StateCompressed = randState; @@ -195,11 +231,19 @@ public void PreContext() public void PostContext() { - prevTime?.Set(); + if (prevTimes.Count == 0) + Log.Error($"MP: unbalanced PostContext on {this}"); + else + prevTimes.Pop()?.Set(); randState = Rand.StateCompressed; Rand.PopState(); + if (prevFactionDepths.Count == 0) + Log.Error($"MP: unbalanced faction depth on {this}"); + else + FactionExtensions.UnwindFactionStack(map, prevFactionDepths.Pop(), $"map {map.uniqueID} bracket"); + map.PopFaction(); } @@ -208,18 +252,24 @@ public void ExposeData() Scribe_Values.Look(ref mapTicks, "mapTicks"); Scribe_Values.Look(ref timeSpeedInt, "timeSpeed"); - Scribe_Values.Look(ref gameStartAbsTickMap, "gameStartAbsTickMap"); + Scribe_Values.Look(ref gameStartAbsTickMap, "gameStartAbsTickMap", 0); Scribe_Custom.LookULong(ref randState, "randState", 1); } - public int IncreasePlayerCount() => CurrentPlayerCount += 1; - // This should never go below 0, this is just defensive programming. Hopefully not needed anymore, but - // nevertheless still left. - public int DecreasePlayerCount() => CurrentPlayerCount = Math.Max(0, CurrentPlayerCount - 1); - public void FinalizeInit() { + // Only saves predating the per-map field (3bfa487) and the + // comp-missing error path in SavingPatches can still hold 0 here. + // Both are load-time, and this runs at a fixed point of the load + // path on every client (Map.FinalizeLoading -> Map.FinalizeInit -> + // MapComponentUtility.FinalizeInit), where the ambient + // gameStartAbsTick is the scribed global - the correct value for + // those saves. Normal play never constructs a comp with 0 + // (MapSetup always passes a non-zero source). + if (gameStartAbsTickMap == 0) + gameStartAbsTickMap = Find.TickManager?.gameStartAbsTick ?? 0; + cmds = new Queue( Multiplayer.session.dataSnapshot?.MapCmds.GetValueSafe(map.uniqueID) ?? new List() ); @@ -290,7 +340,7 @@ public void ExecuteCmd(ScheduledCommand cmd) } catch (Exception e) { - MpLog.Error($"Map cmd exception ({cmdType}): {e}"); + SimulationFailures.Handle($"Map cmd exception ({cmdType})", e); } finally { diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index 10a8bb051..0c908b9be 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -3,6 +3,7 @@ using System.Reflection; using HarmonyLib; using Multiplayer.Client.Factions; +using Multiplayer.Client.Util; using RimWorld; using RimWorld.Planet; using Verse; @@ -50,26 +51,87 @@ static IEnumerable TargetMethods() static bool Prefix() => Multiplayer.Client == null || !MapUpdateMarker.updating; } + // Season notifications. The old patch pushed Multiplayer.RealPlayerFaction + // and that client's own faction's map clock into a method that runs in the + // synced world tick and writes scribed state (lastSeason) - per-client + // inputs in the deterministic sim. Under async, clients' chosen maps cross + // season boundaries at different world ticks and lastSeason diverges (the + // "summer arrived twice" desync); a spectating client diverged even in + // single-faction games. Replaced with a per-faction pass over synced state + // only: each ownable player faction, in deterministic order, gets its + // season computed from ITS min-timezone home map's async clock, with its + // own scribed lastSeason (FactionWorldData) and its letters routed by the + // pushed context. Identical inputs on every client by construction. [HarmonyPatch(typeof(DateNotifier), nameof(DateNotifier.DateNotifierTick))] static class DateNotifierPatch { - static void Prefix(DateNotifier __instance, ref int? __state) + static bool Prefix(DateNotifier __instance) { - if (Multiplayer.Client == null && Multiplayer.RealPlayerFaction != null) return; + if (Multiplayer.Client == null) + return true; - Map map = __instance.FindPlayerHomeWithMinTimezone(); - if (map == null) return; + foreach (var kv in Multiplayer.WorldComp.factionData) + { + var faction = Find.FactionManager.GetById(kv.Key); + if (!QuestFactionOwnership.IsOwnablePlayerFaction(faction)) + continue; + + ((Map)null).PushFaction(faction); + try + { + TickForFaction(__instance, kv.Value); + } + finally + { + FactionExtensions.PopFaction(); + } + } - __state = Find.TickManager.TicksGame; - FactionContext.Push(Multiplayer.RealPlayerFaction); - Find.TickManager.DebugSetTicksGame(map.AsyncTime().mapTicks); + return false; } - static void Finalizer(int? __state) + // Vanilla DateNotifierTick body (1.6.4871) against per-faction state, + // under the faction's context and its map's clock + static void TickForFaction(DateNotifier notifier, FactionWorldData data) { - if (!__state.HasValue) return; - Find.TickManager.DebugSetTicksGame(__state.Value); - FactionContext.Pop(); + // First run on an existing save: adopt the global pre-fix state so + // the transition already seen isn't re-announced + if (data.lastSeason == Season.Undefined && notifier.lastSeason != Season.Undefined) + data.lastSeason = notifier.lastSeason; + + Map map = notifier.FindPlayerHomeWithMinTimezone(); + + int prevTicks = Find.TickManager.TicksGame; + if (map != null) + Find.TickManager.DebugSetTicksGame(map.AsyncTime().mapTicks); + + try + { + float latitude = map != null ? Find.WorldGrid.LongLatOf(map.Tile).y : 0f; + float longitude = map != null ? Find.WorldGrid.LongLatOf(map.Tile).x : 0f; + Season season = GenDate.Season(Find.TickManager.TicksAbs, latitude, longitude); + + if (season == data.lastSeason || + (data.lastSeason != Season.Undefined && season == data.lastSeason.GetPreviousSeason())) + return; + + if (data.lastSeason != Season.Undefined && notifier.AnyPlayerHomeSeasonsAreMeaningful()) + { + if (GenDate.YearsPassed == 0 && season == Season.Summer && + notifier.AnyPlayerHomeAvgTempIsLowInWinter()) + Find.LetterStack.ReceiveLetter("LetterLabelFirstSummerWarning".Translate(), + "FirstSummerWarning".Translate(), LetterDefOf.NeutralEvent); + else if (GenDate.DaysPassed > 5) + Messages.Message("MessageSeasonBegun".Translate(season.Label()).CapitalizeFirst(), + MessageTypeDefOf.NeutralEvent); + } + + data.lastSeason = season; + } + finally + { + Find.TickManager.DebugSetTicksGame(prevTicks); + } } } @@ -149,6 +211,7 @@ static void Postfix(ref bool __result) if (Multiplayer.Client == null) return; if (WorldRendererUtility.WorldSelected) return; if (FactionCreator.generatingMap) return; + if (Find.CurrentMap == null) return; var asyncTime = Find.CurrentMap.AsyncTime(); var timeSpeed = Multiplayer.IsReplay ? TickPatch.replayTimeSpeed : asyncTime.DesiredTimeSpeed; diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 8184163ab..23c97ca7c 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -44,13 +44,16 @@ public float TickRateMultiplier(TimeSpeed speed) }; } - // Run at the speed of the fastest map or at chosen speed if there are no maps + // Run at the speed of the fastest map or at chosen speed if there are no maps. + // A map can be in Find.Maps before its AsyncTimeComp is registered (map + // generation, singleplayer conversion) - a comp-less map isn't a running map, + // so skip it rather than throw. public TimeSpeed DesiredTimeSpeed { get => !Find.Maps.Any() ? timeSpeedInt : Find.Maps.Select(m => m.AsyncTime()) - .Where(a => a.ActualRateMultiplier(a.DesiredTimeSpeed) != 0f) + .Where(a => a != null && a.ActualRateMultiplier(a.DesiredTimeSpeed) != 0f) .Max(a => a?.DesiredTimeSpeed) ?? TimeSpeed.Paused; set => timeSpeedInt = value; } @@ -58,8 +61,40 @@ public TimeSpeed DesiredTimeSpeed public Queue Cmds => cmds; public Queue cmds = new(); - public int CurrentPlayerCount { get; private set; } - public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; + private int cachedPlanetViewerCount; + private int cachedPlayerCountVersion = -1; + + // Players currently in the planet view, derived from the synced view + // table (see MultiplayerGameComp.playerViewedMaps). The old incremental + // count had no floor on this comp and a post-reload disconnect could + // drive it negative, pinning world objects at the no-viewer rate even + // with the planet view open. + public int CurrentPlayerCount + { + get + { + var gameComp = Multiplayer.GameComp; + if (cachedPlayerCountVersion != gameComp.playerViewsVersion) + { + cachedPlanetViewerCount = 0; + foreach (var viewedMapId in gameComp.playerViewedMaps.Values) + if (viewedMapId == VTRSync.WorldMapId) + cachedPlanetViewerCount++; + cachedPlayerCountVersion = gameComp.playerViewsVersion; + } + + return cachedPlanetViewerCount; + } + } + + // World objects run at full rate while anyone is connected, not only + // while someone has the planet view open. Vanilla's rate-15 default for + // an unwatched world assumes nobody can see it; in multiplayer caravans + // and world motion are visible from map view edges and the session is + // always watched by someone, and a permanently lumping world was field- + // reported as "world rendering strangeness". Derived from synced state, + // identical on every client. + public int VTR => Multiplayer.GameComp.playerViewedMaps.Count > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; public int TickableId => -1; @@ -68,6 +103,15 @@ public TimeSpeed DesiredTimeSpeed public int worldTicks; + // The global slot fields the world previously had no owner for. TimeSlower + // is transient in vanilla (never scribed), so a fresh instance is correct; + // gameStartAbsTick is captured at construction, which runs after + // ExposeSmallComponents has loaded the TickManager on every path + // (deserialization via SaveWorldComp, its comp-missing fallback, and the + // singleplayer conversion in HostUtil). + public TimeSlower slower = new(); + public int worldGameStartAbsTick; + public AsyncWorldTimeComp(World world) { this.world = world; @@ -75,6 +119,8 @@ public AsyncWorldTimeComp(World world) // Use the world's constant rand seed as our initial randState. // Only fill the seed part, leave the iterations out. randState = (uint)world.ConstantRandSeed; + + worldGameStartAbsTick = Find.TickManager?.gameStartAbsTick ?? 0; } public void ExposeData() @@ -86,18 +132,34 @@ public void ExposeData() Scribe_Values.Look(ref timeSpeedInt, "timeSpeed"); Scribe_Custom.LookULong(ref randState, "randState", 2); - TimeSpeed timeSpeed = Find.TickManager.CurTimeSpeed; - Scribe_Values.Look(ref timeSpeed, "timeSpeed"); + // Read the world's own speed, not the global TickManager - the global only + // held it because PreContext used to leave it installed, so a save taken from + // a UI context would persist the viewed map's speed instead. Guarded on + // Saving: DesiredTimeSpeed walks Find.Maps, empty during LoadingVars. + // Own node: sharing "timeSpeed" with the field above made this a no-op, since + // Look resolves a label to the first matching child. timeSpeedInt is the + // default, so saves without the node fall back to the speed loaded above. + TimeSpeed globalTimeSpeed = Scribe.mode == LoadSaveMode.Saving ? DesiredTimeSpeed : timeSpeedInt; + Scribe_Values.Look(ref globalTimeSpeed, "globalTimeSpeed", timeSpeedInt); if (Scribe.mode == LoadSaveMode.LoadingVars) - Find.TickManager.CurTimeSpeed = timeSpeed; + Find.TickManager.CurTimeSpeed = globalTimeSpeed; if (Scribe.mode == LoadSaveMode.LoadingVars) Multiplayer.game.worldComp = new MultiplayerWorldComp(world); Multiplayer.game.worldComp.ExposeData(); - if (Scribe.mode == LoadSaveMode.LoadingVars) + // World-basis tick stamps (CooldownClockPatches) survive reload only if + // the world clock itself does; absent node (older saves) falls back to + // the old rebuild-from-TicksGame behavior + Scribe_Values.Look(ref worldTicks, "worldTicks", -1); + if (Scribe.mode == LoadSaveMode.LoadingVars && worldTicks < 0) worldTicks = Find.TickManager.TicksGame; + + // Not scribed - always the global TickManager's value, re-derived on + // load in case the ctor ran before the meta components were current + if (Scribe.mode == LoadSaveMode.LoadingVars) + worldGameStartAbsTick = Find.TickManager.gameStartAbsTick; } public void Tick() @@ -109,6 +171,13 @@ public void Tick() { Find.TickManager.DoSingleTick(); worldTicks++; + + // PreContext installed worldTicks and DoSingleTick incremented the + // ambient, so the two can only disagree if something else moved one + // of them - which would silently shift every world-clock read + if (MpVersion.IsDebug && Find.TickManager.ticksGameInt != worldTicks) + Log.Error($"MP: world clock mismatch: ambient {Find.TickManager.ticksGameInt} != worldTicks {worldTicks}"); + Multiplayer.WorldComp.TickWorldSessions(); if (ModsConfig.BiotechActive) @@ -141,9 +210,21 @@ public void Tick() } } +// The world's clock lives on the global TickManager only while installed + // here; the world tick is self-contained. PreContext installs the full + // world snapshot - ticksGameInt = worldTicks, the scribed mirror that + // DoSingleTick's increment tracks - and PostContext restores whatever the + // frame had. Readers that want world time between ticks (letters, alerts, + // world render, saving) install it explicitly. A stack because world + // commands can nest a world context inside the world tick. + private readonly Stack prevTimes = new(); + + // Expected faction stack depth per nested bracket - see AsyncTimeComp + private readonly Stack prevFactionDepths = new(); + public void PreContext() { - Find.TickManager.CurTimeSpeed = DesiredTimeSpeed; + prevTimes.Push(TimeSnapshot.GetAndSetFromWorld()); Rand.PushState(); Rand.StateCompressed = randState; @@ -153,19 +234,45 @@ public void PreContext() foreach (var map in Find.Maps) map.MpComp().SetFaction(Multiplayer.WorldComp.spectatorFaction); } + + // Recorded unconditionally: the command path pushes the command's + // faction after PreContext even outside multifaction, and a throw in + // the handler must not strand it + prevFactionDepths.Push(FactionContext.stack.Count); } public void PostContext() { + if (prevFactionDepths.Count == 0) + Log.Error("MP: unbalanced faction depth on the world clock"); + else + FactionExtensions.UnwindFactionStack(null, prevFactionDepths.Pop(), "world bracket"); + if (Multiplayer.GameComp.multifaction) { - var f = FactionExtensions.PopFaction(); + // Restore must be unconditional: PreContext swapped EVERY map onto + // the spectator faction's data (resourceCounter, zone/area/ + // designation managers), and leaving any map on it corrupts every + // UI read until something else swaps it back (alternating only on + // frames that ran a world tick - a per-frame flicker/re-ping + // shape). A balanced pop returns the pre-world-tick OfPlayer, + // which is the client-local faction, so falling back to + // RealPlayerFaction on an unbalanced pop restores the same thing + // the balanced path would have. This is the faction half of the + // same PreContext/PostContext imbalance whose speed half caused + // the paused-map time-context bug. + var f = FactionExtensions.PopFaction() ?? Multiplayer.RealPlayerFaction; foreach (var map in Find.Maps) map.MpComp().SetFaction(f); } randState = Rand.StateCompressed; Rand.PopState(); + + if (prevTimes.Count == 0) + Log.Error("MP: unbalanced PostContext on the world clock"); + else + prevTimes.Pop()?.Set(); } public void ExecuteCmd(ScheduledCommand cmd) @@ -231,28 +338,20 @@ public void ExecuteCmd(ScheduledCommand cmd) if (cmdType == CommandType.PlayerCount) { - int previousMapId = data.ReadInt32(); - int newMapId = data.ReadInt32(); - int mapCount = Find.Maps.Count; - - var prev = -1; - if (previousMapId >= 0) - prev = Find.Maps.FirstOrDefault(x => x.uniqueID == previousMapId)?.AsyncTime()?.DecreasePlayerCount() ?? -1; - else if (previousMapId == VTRSync.WorldMapId) - prev = Multiplayer.AsyncWorldTime.CurrentPlayerCount -= 1; - - var curr = -1; - if (newMapId >= 0) - curr = Find.Maps.FirstOrDefault(x => x.uniqueID == newMapId)?.AsyncTime()?.IncreasePlayerCount() ?? -1; - else if (newMapId == VTRSync.WorldMapId) - curr = Multiplayer.AsyncWorldTime.CurrentPlayerCount += 1; - - MpLog.Debug($"[{worldTicks}|{Multiplayer.session.remoteTickUntil}] Player count change: previousMapId={previousMapId} ({prev}), newMapId={newMapId} ({curr}), mapCount={mapCount}"); + // Payload: (playerId, viewedMapId). InvalidMapId removes the + // entry (server-sent when the player disconnects). The VTR + // counts derive from the table - see + // MultiplayerGameComp.playerViewedMaps. + int playerId = data.ReadInt32(); + int viewedMapId = data.ReadInt32(); + Multiplayer.GameComp.SetPlayerViewedMap(playerId, viewedMapId); + + MpLog.Debug($"[{worldTicks}|{Multiplayer.session.remoteTickUntil}] Player view: player={playerId}, map={viewedMapId}, views={Multiplayer.GameComp.playerViewedMaps.Count}"); } } catch (Exception e) { - Log.Error($"World cmd exception ({cmdType}): {e}"); + SimulationFailures.Handle($"World cmd exception ({cmdType})", e); } finally { diff --git a/Source/Client/AsyncTime/CooldownClockPatches.cs b/Source/Client/AsyncTime/CooldownClockPatches.cs new file mode 100644 index 000000000..038634962 --- /dev/null +++ b/Source/Client/AsyncTime/CooldownClockPatches.cs @@ -0,0 +1,178 @@ +using HarmonyLib; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.AsyncTime +{ + // #964: async time stamps cooldowns under one map's clock and compares them + // under another's after map/world transfer (world context never swaps the + // clock at all). Fix: stamp and read against the world clock — it runs at + // the fastest map's speed and pauses only when everything pauses, so it's a + // monotone session clock defined in every context. Lockstep (non-async MP) + // makes this an identity transform. + static class CooldownClock + { + public static int Now => Multiplayer.AsyncWorldTime.worldTicks; + + // game/comp can lag Client during the join+load window + public static bool Active => Multiplayer.Client != null && Multiplayer.game?.asyncWorldTimeComp != null; + } + + // Vanilla Ability: cooldownEndTick's only readers all funnel through the + // CooldownTicksRemaining getter (gizmo disable, tick-side end check, + // per-charge recharge), so rebasing the stamp and the getter covers every + // path, including Psycast subclasses and cooldownPerCharge. + [HarmonyPatch(typeof(Ability), nameof(Ability.StartCooldown))] + static class AbilityCooldownWorldClockStamp + { + static void Postfix(Ability __instance, int ticks) + { + if (CooldownClock.Active) + __instance.cooldownEndTick = CooldownClock.Now + ticks; + } + } + + [HarmonyPatch(typeof(Ability), nameof(Ability.CooldownTicksRemaining), MethodType.Getter)] + static class AbilityCooldownWorldClockRead + { + static bool Prefix(Ability __instance, ref int __result) + { + if (!CooldownClock.Active) return true; + + __result = __instance.inCooldown + ? Mathf.Max(__instance.cooldownEndTick - CooldownClock.Now, 0) + : 0; + return false; + } + } + + // Daily skill growth: the midnight reset is gated by a 30k-tick window + // against TicksGame. Across maps (or in caravan context, where the clock is + // whatever map ticked last) the window goes incoherent and the reset is + // skipped or fires early — saturation sticks. Body replicated from + // 1.6.4871 with the guard and stamp moved to the world clock; the pawn's + // local-midnight gate and per-skill Interval loop are untouched. + [HarmonyPatch(typeof(Pawn_SkillTracker), nameof(Pawn_SkillTracker.SkillsTickInterval))] + static class SkillDailyResetWorldClock + { + static bool Prefix(Pawn_SkillTracker __instance, int delta) + { + if (!CooldownClock.Active) return true; + + var pawn = __instance.pawn; + if (!pawn.IsHashIntervalTick(200, delta) || !CanGainXP(pawn)) + return false; + + if (GenLocalDate.HourInteger(pawn) == 0 && + (__instance.lastXpSinceMidnightResetTimestamp < 0 || + CooldownClock.Now - __instance.lastXpSinceMidnightResetTimestamp >= 30000)) + { + for (int i = 0; i < __instance.skills.Count; i++) + __instance.skills[i].xpSinceMidnight = 0f; + + __instance.lastXpSinceMidnightResetTimestamp = CooldownClock.Now; + } + + for (int j = 0; j < __instance.skills.Count; j++) + __instance.skills[j].Interval(); + + return false; + } + + // Pawn_SkillTracker.CanGainXP is private; replicated (1.6.4871) + static bool CanGainXP(Pawn pawn) + { + if (ModsConfig.AnomalyActive && pawn.IsMutant && !pawn.mutant.Def.canGainXP) + return false; + return true; + } + } + + // Shuttle cooldown: the shuttle physically leaves the stamping map, so the + // stamp is rebased at the write sites and the compare sites read under the + // world clock. TryLaunch is NOT wrapped in a clock swap — it spawns + // skyfallers and motes that must keep stamping their own map ticks. + [HarmonyPatch(typeof(CompLaunchable), nameof(CompLaunchable.TryLaunch))] + static class LaunchCooldownWorldClockStamp + { + static void Prefix(CompLaunchable __instance, ref int __state) + => __state = __instance.lastLaunchTick; + + static void Postfix(CompLaunchable __instance, int __state) + { + // Rebase only if this call actually stamped (TryLaunch has + // early-out paths that leave the field untouched) + if (CooldownClock.Active && __instance.lastLaunchTick != __state) + __instance.lastLaunchTick = CooldownClock.Now; + } + } + + [HarmonyPatch(typeof(CompLaunchable), nameof(CompLaunchable.Notify_Arrived))] + static class LaunchCooldownArrivalWorldClockStamp + { + static void Postfix(CompLaunchable __instance) + { + if (CooldownClock.Active) + __instance.lastLaunchTick = CooldownClock.Now; + } + } + + // Read swaps: both methods are read-only w.r.t. game state, so swapping + // ticksGameInt for their duration only affects the cooldown compare. The + // finalizer restores unconditionally, exception or not. + [HarmonyPatch(typeof(CompLaunchable), nameof(CompLaunchable.CanLaunch))] + static class LaunchCooldownCanLaunchWorldClock + { + static void Prefix(ref int? __state) + { + if (!CooldownClock.Active) return; + __state = Find.TickManager.TicksGame; + Find.TickManager.DebugSetTicksGame(CooldownClock.Now); + } + + static void Finalizer(int? __state) + { + if (__state is { } prev) + Find.TickManager.DebugSetTicksGame(prev); + } + } + + [HarmonyPatch(typeof(CompLaunchable), nameof(CompLaunchable.CompInspectStringExtra))] + static class LaunchCooldownInspectWorldClock + { + static void Prefix(ref int? __state) + { + if (!CooldownClock.Active) return; + __state = Find.TickManager.TicksGame; + Find.TickManager.DebugSetTicksGame(CooldownClock.Now); + } + + static void Finalizer(int? __state) + { + if (__state is { } prev) + Find.TickManager.DebugSetTicksGame(prev); + } + } + + // CompTick's only job here is the cooldown-ended message; its == compare + // must run against the same clock the stamp uses. A slow map can tick past + // the exact world tick and miss the message — cosmetic, accepted. + [HarmonyPatch(typeof(CompLaunchable), nameof(CompLaunchable.CompTick))] + static class LaunchCooldownEndedMessageWorldClock + { + static bool Prefix(CompLaunchable __instance) + { + if (!CooldownClock.Active) return true; + + if (!__instance.Props.cooldownEndedMessage.NullOrEmpty() && + __instance.lastLaunchTick > 0 && + __instance.lastLaunchTick + __instance.Props.cooldownTicks == CooldownClock.Now) + { + Messages.Message(__instance.Props.cooldownEndedMessage.Formatted(__instance.parent.LabelCap), + __instance.parent, MessageTypeDefOf.NeutralEvent, historical: false); + } + return false; + } + } +} diff --git a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs index e9332b7fb..8cc75e6c0 100644 --- a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs +++ b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs @@ -248,11 +248,15 @@ public static void TickMapQuests(AsyncTimeComp mapAsyncTimeComp) /// Runs QuestTick() on all quests passed /// /// Quests to run QuestTick() on - private static void TickQuests(IEnumerable quests) + private static void TickQuests(List quests) { - foreach (var quest in quests.ToList()) + // Index loop, not foreach: a quest ending during its own tick + // removes itself from this list via RemoveQuestFromCacheOnQuestEnd + // (live find: InvalidOperationException in AsyncTimeComp ticking). + // Removals are deterministic, so any skip is identical on all clients. + for (int i = 0; i < quests.Count; i++) { - quest.QuestTick(); + quests[i].QuestTick(); } } diff --git a/Source/Client/AsyncTime/SetMapTime.cs b/Source/Client/AsyncTime/SetMapTime.cs index 538f482c7..5bd2567f5 100644 --- a/Source/Client/AsyncTime/SetMapTime.cs +++ b/Source/Client/AsyncTime/SetMapTime.cs @@ -21,10 +21,8 @@ static IEnumerable TargetMethods() yield return AccessTools.Method(typeof(MapInterface), nameof(MapInterface.HandleMapClicks)); yield return AccessTools.Method(typeof(MapInterface), nameof(MapInterface.HandleLowPriorityInput)); yield return AccessTools.Method(typeof(MapInterface), nameof(MapInterface.MapInterfaceUpdate)); - yield return AccessTools.Method(typeof(AlertsReadout), nameof(AlertsReadout.AlertsReadoutUpdate)); yield return AccessTools.Method(typeof(SoundRoot), nameof(SoundRoot.Update)); yield return AccessTools.Method(typeof(FloatMenuMakerMap), nameof(FloatMenuMakerMap.GetOptions)); - yield return AccessTools.Method(typeof(Hediff), nameof(Hediff.GetTooltip)); } [HarmonyPriority(MpPriority.MpFirst)] @@ -38,6 +36,47 @@ internal static void Prefix(ref TimeSnapshot? __state) internal static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } + // The hediff's pawn may live on another map or in a caravan, so its + // elapsed-time strings must come from the pawn's own clock, not the + // viewed map's (it used to sit in the SetMapTimeForUI list above). + [HarmonyPatch(typeof(Hediff), nameof(Hediff.GetTooltip))] + static class HediffTooltipMapTime + { + static void Prefix(Hediff __instance, ref TimeSnapshot? __state) + { + if (Multiplayer.Client == null) return; + __state = TimeSnapshot.GetAndSetFromMap(__instance.pawn?.MapHeld); + } + + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); + } + + // Alerts iterate all maps, so no single map's clock is right for them; + // the session-wide world clock keeps countdowns advancing regardless of + // which map is viewed and keeps the update and draw halves consistent + // with each other (previously AlertsReadoutUpdate ran under the viewed + // map's clock and AlertsReadoutOnGUI under the frame ambient - the same + // alert evaluated under two clocks in one frame). + [HarmonyPatch] + static class AlertsReadoutWorldTime + { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(AlertsReadout), nameof(AlertsReadout.AlertsReadoutUpdate)); + yield return AccessTools.Method(typeof(AlertsReadout), nameof(AlertsReadout.AlertsReadoutOnGUI)); + } + + [HarmonyPriority(MpPriority.MpFirst)] + static void Prefix(ref TimeSnapshot? __state) + { + if (Multiplayer.Client == null) return; + __state = TimeSnapshot.GetAndSetFromWorld(); + } + + [HarmonyPriority(MpPriority.MpLast)] + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); + } + [HarmonyPatch] static class MapUpdateTimePatch { @@ -75,14 +114,20 @@ static void Prefix(Pawn pawn, ref TimeSnapshot? __state) static void Postfix(TimeSnapshot? __state) => __state?.Set(); } - // TODO 1.3: set time on the new renderer - //[HarmonyPatch(typeof(PawnRenderer), nameof(PawnRenderer.RenderPortrait))] + // Portraits render through PawnCacheRenderer.RenderPawn since the 1.3 + // renderer rewrite (PawnRenderer.RenderPortrait no longer exists); + // camera.Render() is synchronous, so this pair brackets OnPostRender -> + // PawnRenderer.RenderCache, which reads the clock for the damage flasher + // and render-tree animation frames. Without it a portrait renders under + // whatever clock the frame has ambient while PortraitsCache.IsAnimated + // (patched above) decides re-renders under the pawn's own map clock. + [HarmonyPatch(typeof(PawnCacheRenderer), nameof(PawnCacheRenderer.RenderPawn))] static class PawnRenderPortraitMapTime { - static void Prefix(PawnRenderer __instance, ref TimeSnapshot? __state) + static void Prefix(Pawn pawn, ref TimeSnapshot? __state) { if (Multiplayer.Client == null || Current.ProgramState != ProgramState.Playing) return; - __state = TimeSnapshot.GetAndSetFromMap(__instance.pawn.MapHeld); + __state = TimeSnapshot.GetAndSetFromMap(pawn.MapHeld); } static void Postfix(TimeSnapshot? __state) => __state?.Set(); @@ -94,7 +139,11 @@ static class PreDrawPosCalculationMapTime static void Prefix(PawnTweener __instance, ref TimeSnapshot? __state) { if (Multiplayer.Client == null || Current.ProgramState != ProgramState.Playing) return; - __state = TimeSnapshot.GetAndSetFromMap(__instance.pawn.Map); + // MapHeld, not Map: a carried/transported pawn has no map of its + // own, and with no install its tween ran under the viewer's clock + // (stamping lastDrawTick with it), then hard-snapped when a real + // clock returned. The holder's map clock is the motion's basis. + __state = TimeSnapshot.GetAndSetFromMap(__instance.pawn.MapHeld); } static void Postfix(TimeSnapshot? __state) => __state?.Set(); @@ -124,7 +173,16 @@ static IEnumerable TargetMethods() static void Prefix(Sustainer __instance, ref TimeSnapshot? __state) { if (Multiplayer.game == null) return; - __state = TimeSnapshot.GetAndSetFromMap(__instance.info.Maker.Map); + // Most sustainers have no map: the Sustainer constructor + // downgrades any def without world sub-sounds to OnCamera, making + // Maker invalid. Falling through with no install left those + // running under the viewed map's clock while Maintain() stamps + // the maintainer's ambient tick - a cross-clock pair that ends + // (and respawns) the sustainer every frame when the viewer's + // clock is ahead. The world clock is the only session-wide + // monotone basis, so install it for map-less sound code. + __state = TimeSnapshot.GetAndSetFromMap(__instance.info.Maker.Map) + ?? TimeSnapshot.GetAndSetFromWorld(); } static void Postfix(TimeSnapshot? __state) => __state?.Set(); @@ -136,12 +194,36 @@ static class SampleUpdateMapTime static void Prefix(Sample __instance, ref TimeSnapshot? __state) { if (Multiplayer.game == null) return; - __state = TimeSnapshot.GetAndSetFromMap(__instance.Map); + // World-clock fallback for map-less samples - see + // SustainerUpdateMapTime above + __state = TimeSnapshot.GetAndSetFromMap(__instance.Map) + ?? TimeSnapshot.GetAndSetFromWorld(); } static void Postfix(TimeSnapshot? __state) => __state?.Set(); } + // The one-shot reaper decides "finished" partly from Find.TickManager.Paused, + // which under the viewer install is the VIEWED map's pause state: a paused + // viewer never reaps finished tempo-affected one-shots, they accumulate, and + // the voice limiter then cuts/restarts sounds on every new play. The world + // clock pauses only when the whole session does, so reaping tracks actual + // sim activity. Per-sample Update calls nest their own map/world snapshots + // inside this bracket (SampleUpdateMapTime). + [HarmonyPatch(typeof(SampleOneShotManager), nameof(SampleOneShotManager.SampleOneShotManagerUpdate))] + static class OneShotReaperWorldTime + { + [HarmonyPriority(MpPriority.MpFirst)] + static void Prefix(ref TimeSnapshot? __state) + { + if (Multiplayer.game == null) return; + __state = TimeSnapshot.GetAndSetFromWorld(); + } + + [HarmonyPriority(MpPriority.MpLast)] + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); + } + [HarmonyPatch(typeof(TipSignal), MethodType.Constructor, new[] { typeof(Func), typeof(int) })] static class TipSignalCtor { @@ -230,18 +312,92 @@ public static TimeSnapshot Current() { if (map == null) return null; + // A map mid-generation is in Find.Maps before its AsyncTimeComp is + // registered - no comp means no map clock to install yet, same + // contract as the null-world guard in GetAndSetFromWorld + var mapComp = map.AsyncTime(); + if (mapComp == null) return null; + TimeSnapshot prev = Current(); var tickManager = Find.TickManager; - var mapComp = map.AsyncTime(); + // Field, not the property: the vanilla setter silently drops + // writes (and can emit a RejectInput message) when + // !PlayerCanControl, e.g. during gravship landing confirmation, + // and a context install must never be lossy - Set() already + // writes the field on restore tickManager.ticksGameInt = mapComp.mapTicks; tickManager.slower = mapComp.slower; - tickManager.CurTimeSpeed = mapComp.DesiredTimeSpeed; + tickManager.curTimeSpeed = mapComp.DesiredTimeSpeed; tickManager.gameStartAbsTick = mapComp.GameStartAbsTick; return prev; } + + // The world half of GetAndSetFromMap. Writes the curTimeSpeed field, + // not the property: the vanilla setter silently drops writes (and can + // emit a RejectInput message) when !PlayerCanControl, e.g. during + // gravship landing confirmation, and a context install must never be + // lossy. + public static TimeSnapshot? GetAndSetFromWorld() + { + var worldComp = Multiplayer.AsyncWorldTime; + if (worldComp == null) return null; + + TimeSnapshot prev = Current(); + var tickManager = Find.TickManager; + + tickManager.ticksGameInt = worldComp.worldTicks; + tickManager.slower = worldComp.slower; + tickManager.curTimeSpeed = worldComp.DesiredTimeSpeed; + tickManager.gameStartAbsTick = worldComp.worldGameStartAbsTick; + + return prev; + } + } + + // World rendering and world-object updates key caches and motion deltas + // on the ambient clock: WorldDrawLayer_Satellites regenerates whenever + // lastUpdate != TicksGame and integrates orbit rotation by the delta, + // WorldObject.DrawPos caches per tick. The world clock is their correct + // basis - caravans and orbits move on world ticks - while an ambient that + // alternates with the viewer's clock regenerates the layer every frame + // (the observed world-render degradation) or jumps deltas + // backwards. Plain snapshot bracket, deliberately not PreContext: no Rand + // or faction state may change on a render path. Runs even while a surface + // map is viewed (background world render). + [HarmonyPatch(typeof(World), nameof(World.WorldUpdate))] + static class WorldUpdateWorldTime + { + [HarmonyPriority(MpPriority.MpFirst)] + static void Prefix(ref TimeSnapshot? __state) + { + if (Multiplayer.Client == null) return; + __state = TimeSnapshot.GetAndSetFromWorld(); + } + + [HarmonyPriority(MpPriority.MpLast)] + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); + } + + // Letters are world-scoped state read between ticks: arrival and timeout + // stamps must be compared under one session-wide clock rather than + // whatever the frame left ambient. This preserves the clock the + // method effectively ran under before the viewer context owned the frame. + // Letters.cs patches the same method for unrelated reasons. + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.LetterStackUpdate))] + static class LetterStackUpdateWorldTime + { + [HarmonyPriority(MpPriority.MpFirst)] + static void Prefix(ref TimeSnapshot? __state) + { + if (Multiplayer.Client == null) return; + __state = TimeSnapshot.GetAndSetFromWorld(); + } + + [HarmonyPriority(MpPriority.MpLast)] + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } } diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index f8068341e..ae15aa875 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -39,7 +39,8 @@ static void Postfix(List __result) if (Multiplayer.MapContext != null) { __result.Clear(); - __result.Add(Multiplayer.MapContext); + if (FactionCanReceiveIncidentsOn(Multiplayer.MapContext, Faction.OfPlayer)) + __result.Add(Multiplayer.MapContext); } else if (AsyncWorldTimeComp.tickingWorld) { @@ -52,6 +53,22 @@ static void Postfix(List __result) __result.Add(Find.World); } } + + // #659: FactionRepeater runs this once per player faction; without an ownership + // check every faction's storyteller fires on every map (wrong-faction raids and + // letters, storyteller pressure multiplied by player count) + static bool FactionCanReceiveIncidentsOn(Map map, Faction faction) + { + if (!Multiplayer.GameComp.multifaction) + return true; + + if (map.ParentFaction == faction) + return true; + + // Unowned maps (sites, temp maps): target factions with humanlike pawns present + return map.ParentFaction is not { IsPlayer: true } && + map.mapPawns.SpawnedPawnsInFaction(faction).Any(p => p.RaceProps.Humanlike); + } } // The MP Mod's ticker calls Storyteller.StorytellerTick() on both the World and each Map, each tick @@ -106,6 +123,11 @@ static class MapContextIncidentExecute { static void Prefix(IncidentParms parms, ref Map __state) { + // This may be running inside a context already: TryExecute nests on + // quest-spawn incidents, and tickingWorld stays true throughout + if (AsyncTimeComp.tickingMap != null) + return; + if (AsyncWorldTimeComp.tickingWorld && parms.target is Map map) { AsyncTimeComp.tickingMap = map; diff --git a/Source/Client/AsyncTime/TimeControlUI.cs b/Source/Client/AsyncTime/TimeControlUI.cs index 79444d14f..9f3bba19c 100644 --- a/Source/Client/AsyncTime/TimeControlUI.cs +++ b/Source/Client/AsyncTime/TimeControlUI.cs @@ -19,6 +19,11 @@ public static class TimeControlPatch { private static TimeVote[] GameSpeeds = { TimeVote.Paused, TimeVote.Normal, TimeVote.Fast, TimeVote.Superfast }; + // Vanilla parity: show the Ultrafast button in dev mode (the vote enum, + // tick rates and hotkey already support it) + private static TimeVote[] GameSpeedsDev = + { TimeVote.Paused, TimeVote.Normal, TimeVote.Fast, TimeVote.Superfast, TimeVote.Ultrafast }; + private static bool ShouldReset => Event.current.shift && Multiplayer.GameComp.IsLowestWins; private static ITickable Tickable => @@ -84,7 +89,7 @@ private static void DoTimeControlsGUI(Rect timerRect) SendTimeVote(TimeVote.ResetTickable); } - foreach (var speed in GameSpeeds) + foreach (var speed in Prefs.DevMode ? GameSpeedsDev : GameSpeeds) { if (Widgets.ButtonImage(rect, TexButton.SpeedButtonTextures[(uint)speed])) { diff --git a/Source/Client/Comp/Game/MultiplayerGameComp.cs b/Source/Client/Comp/Game/MultiplayerGameComp.cs index 2f3909cc7..8939873e6 100644 --- a/Source/Client/Comp/Game/MultiplayerGameComp.cs +++ b/Source/Client/Comp/Game/MultiplayerGameComp.cs @@ -19,10 +19,30 @@ public class MultiplayerGameComp : IExposable, IHasSessionData public Dictionary playerData = new(); // player id to player data public int nextSessionId; + // Synced view table: player id -> viewed map uniqueID, with + // VTRSync.WorldMapId for the planet view. Source of truth for the VTR + // player counts - counts are derived from this table, never + // incremented, so no send/wipe/disconnect ordering can drift them. + // Written only from CommandType.PlayerCount commands (client view + // announces and the server's disconnect removal) and scribed so join + // points and rehosts keep the views instead of resetting every map to + // the no-viewer rate. + public Dictionary playerViewedMaps = new(); + public int playerViewsVersion; + public string idBlockBase64; public bool IsLowestWins => timeControl == TimeControl.LowestWins; + public void SetPlayerViewedMap(int playerId, int mapId) + { + if (mapId == Patches.VTRSync.InvalidMapId) + playerViewedMaps.Remove(playerId); + else + playerViewedMaps[playerId] = mapId; + playerViewsVersion++; + } + public PlayerData LocalPlayerDataOrNull => playerData.GetValueOrDefault(Multiplayer.session.playerId); public void ExposeData() @@ -35,6 +55,15 @@ public void ExposeData() Scribe_Values.Look(ref timeControl, "timeControl"); Scribe_Values.Look(ref nextSessionId, "nextSessionId"); + Scribe_Collections.Look(ref playerViewedMaps, "playerViewedMaps", LookMode.Value, LookMode.Value); + if (Scribe.mode == LoadSaveMode.PostLoadInit) + { + // Absent on saves predating the table; version bump invalidates + // every comp's cached count after a (re)load + playerViewedMaps ??= new Dictionary(); + playerViewsVersion++; + } + // Store for back-compat conversion in GameExposeComponentsPatch if (Scribe.mode == LoadSaveMode.LoadingVars) Scribe_Values.Look(ref idBlockBase64, "globalIdBlock"); diff --git a/Source/Client/Comp/Map/MultiplayerMapComp.cs b/Source/Client/Comp/Map/MultiplayerMapComp.cs index 541a9233e..c04d34199 100644 --- a/Source/Client/Comp/Map/MultiplayerMapComp.cs +++ b/Source/Client/Comp/Map/MultiplayerMapComp.cs @@ -5,6 +5,7 @@ using Multiplayer.Client.Factions; using Multiplayer.Client.Persistent; using Multiplayer.Client.Saving; +using Multiplayer.Client.Util; using Multiplayer.Common; using RimWorld; using RimWorld.Planet; @@ -110,9 +111,20 @@ public void DoTick() foreach (var data in factionData) { map.PushFaction(data.Key); - data.Value.listerHaulables.ListerHaulablesTick(); - data.Value.resourceCounter.ResourceCounterTick(); - map.PopFaction(); + try + { + data.Value.listerHaulables.ListerHaulablesTick(); + data.Value.resourceCounter.ResourceCounterTick(); + } + finally + { + // A throw here still aborts the remaining factions + // (caught and surfaced at the tickable level), but it + // must not strand this faction's data as the map's + // installed managers - that shifts the faction context + // stack and corrupts UI reads for the whole session + map.PopFaction(); + } } } finally @@ -121,10 +133,22 @@ public void DoTick() } } + private static readonly HashSet warnedMissingFactionData = new(); + public void SetFaction(Faction faction) { if (!factionData.TryGetValue(faction.loadID, out FactionMapData data)) + { + // Skipping the swap leaves the map on whatever faction's data + // is currently installed - if that was a transient context + // (e.g. the world tick's spectator swap), the map stays wrong + // until the next successful SetFaction. Never fail this + // silently: name the map and faction once so a leak is + // attributable. + if (warnedMissingFactionData.Add(((long)map.uniqueID << 32) | (uint)faction.loadID)) + MpLog.Warn($"SetFaction skipped: map {map.uniqueID} has no FactionMapData for faction {faction.loadID} ({faction.Name}) - map keeps the previously installed faction data"); return; + } map.designationManager = data.designationManager; map.areaManager = data.areaManager; diff --git a/Source/Client/Comp/World/FactionBossgroupData.cs b/Source/Client/Comp/World/FactionBossgroupData.cs new file mode 100644 index 000000000..8607a6496 --- /dev/null +++ b/Source/Client/Comp/World/FactionBossgroupData.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using HarmonyLib; +using RimWorld; +using Verse; + +namespace Multiplayer.Client; + +// Multifaction: GameComponent_Bossgroup state is a game singleton - one player's +// bossgroup call escalated threat difficulty and cooldown for every faction. +// Dict/list swap by reference on faction switch; the cooldown int is copied in +// and written back by the postfix on its single writer. Copy-in and write-back +// are both multifaction-gated: plain MP leaves the vanilla component untouched. +public class FactionBossgroupData : IExposable +{ + public int lastBossgroupCalled = -9999999; + public Dictionary timesCalledBossgroups = new(); + public List killedBosses = new(); + + public void ExposeData() + { + Scribe_Values.Look(ref lastBossgroupCalled, "lastBossgroupCalled", -9999999); + Scribe_Collections.Look(ref timesCalledBossgroups, "timesCalledBossgroups", LookMode.Def, LookMode.Value); + Scribe_Collections.Look(ref killedBosses, "killedBosses", LookMode.Def); + + if (Scribe.mode == LoadSaveMode.LoadingVars) + { + timesCalledBossgroups ??= new Dictionary(); + killedBosses ??= new List(); + } + } + + public static FactionBossgroupData New() + { + return new FactionBossgroupData(); + } + + public static FactionBossgroupData FromCurrent() + { + var comp = Current.Game?.GetComponent(); + if (comp == null) + return new FactionBossgroupData(); + + return new FactionBossgroupData + { + lastBossgroupCalled = comp.lastBossgroupCalled, + timesCalledBossgroups = comp.timesCalledBossgroups, + killedBosses = comp.killedBosses, + }; + } +} + +[HarmonyPatch(typeof(GameComponent_Bossgroup), nameof(GameComponent_Bossgroup.Notify_BossgroupCalled))] +static class BossgroupLastCalledWriteBack +{ + static void Postfix(GameComponent_Bossgroup __instance) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + if (Multiplayer.WorldComp.factionData.TryGetValue(Faction.OfPlayer.loadID, out var data)) + data.bossgroup.lastBossgroupCalled = __instance.lastBossgroupCalled; + } +} diff --git a/Source/Client/Comp/World/FactionWorldData.cs b/Source/Client/Comp/World/FactionWorldData.cs index 20b43fe7b..19c672706 100644 --- a/Source/Client/Comp/World/FactionWorldData.cs +++ b/Source/Client/Comp/World/FactionWorldData.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using RimWorld; using Verse; @@ -17,6 +18,35 @@ public class FactionWorldData : IExposable public Storyteller storyteller; public StoryWatcher storyWatcher; + public AnalysisManager analysisManager; + public FactionBossgroupData bossgroup; + + // Per-faction season notification state: each faction's transitions are + // computed from its own min-timezone home map's async clock. See the + // DateNotifier patch in AsyncTime/AsyncTimePatches.cs. + public Season lastSeason; + + // Per-faction goodwill caps cache (runtime-only, rebuilds deterministically) + // and per-NPC drift timers replacing vanilla's single OfPlayer-bound timer + public GoodwillSituationManager goodwillSituationManager; + public Dictionary naturalGoodwillTimers; + + // Vanilla IdeoManager keeps these as single globals, so + // the per-faction storyteller/thought fan turns them into cross-faction + // bleed (one faction drains everyone's pod counter, one faction's settle + // or psychic ritual moves every faction's precept moods). -1 = never + // stamped for this faction; readers treat that as vanilla's default 0, + // except the gauranlen counter, which seeds from the shared global on + // first sim use so an existing save keeps its cycle progress. + public int ticksToNextGauranlenSpawn = -1; + public int lastResettledTick = -1; + public int lastPsychicRitualPerformedTick = -1; + + // Military-aid lockout per NPC faction loadID, + // stamped on the async world clock (CooldownClock.Now). Vanilla's single + // Faction.lastMilitaryAidRequestTick stays written for the SP path. + public Dictionary militaryAidStamps; + public FactionWorldData() { } public void ExposeData() @@ -33,8 +63,22 @@ public void ExposeData() Scribe_Deep.Look(ref storyteller, "storyteller"); Scribe_Deep.Look(ref storyWatcher, "storyWatcher"); + Scribe_Deep.Look(ref analysisManager, "analysisManager"); + Scribe_Deep.Look(ref bossgroup, "bossgroup"); + + Scribe_Values.Look(ref lastSeason, "lastSeason", Season.Undefined); + Scribe_Collections.Look(ref naturalGoodwillTimers, "naturalGoodwillTimers", LookMode.Value, LookMode.Value); + + Scribe_Values.Look(ref ticksToNextGauranlenSpawn, "ticksToNextGauranlenSpawn", -1); + Scribe_Values.Look(ref lastResettledTick, "lastResettledTick", -1); + Scribe_Values.Look(ref lastPsychicRitualPerformedTick, "lastPsychicRitualPerformedTick", -1); + Scribe_Collections.Look(ref militaryAidStamps, "militaryAidStamps", LookMode.Value, LookMode.Value); + if (Scribe.mode == LoadSaveMode.LoadingVars) { + goodwillSituationManager ??= new GoodwillSituationManager(); + naturalGoodwillTimers ??= new Dictionary(); + militaryAidStamps ??= new Dictionary(); researchManager ??= new ResearchManager(); drugPolicyDatabase ??= new DrugPolicyDatabase(); outfitDatabase ??= new OutfitDatabase(); @@ -45,6 +89,9 @@ public void ExposeData() storyteller ??= new Storyteller(Find.Storyteller.def, Find.Storyteller.difficultyDef, Find.Storyteller.difficulty); storyWatcher ??= new StoryWatcher(); + + analysisManager ??= new AnalysisManager(); + bossgroup ??= FactionBossgroupData.New(); } } @@ -74,7 +121,14 @@ public static FactionWorldData New(int factionId) history = new History(), storyteller = new Storyteller(Find.Storyteller.def, Find.Storyteller.difficultyDef, Find.Storyteller.difficulty), - storyWatcher = new StoryWatcher() + storyWatcher = new StoryWatcher(), + + analysisManager = new AnalysisManager(), + bossgroup = FactionBossgroupData.New(), + + goodwillSituationManager = new GoodwillSituationManager(), + naturalGoodwillTimers = new Dictionary(), + militaryAidStamps = new Dictionary() }; } @@ -92,7 +146,14 @@ public static FactionWorldData FromCurrent(int factionId) history = Find.History, storyteller = Find.Storyteller, - storyWatcher = Find.StoryWatcher + storyWatcher = Find.StoryWatcher, + + analysisManager = Current.Game.analysisManager, + bossgroup = FactionBossgroupData.FromCurrent(), + + goodwillSituationManager = Find.FactionManager.goodwillSituationManager, + naturalGoodwillTimers = new Dictionary(), + militaryAidStamps = new Dictionary() }; } } diff --git a/Source/Client/Comp/World/MultiplayerWorldComp.cs b/Source/Client/Comp/World/MultiplayerWorldComp.cs index a505856af..cbe5273b9 100644 --- a/Source/Client/Comp/World/MultiplayerWorldComp.cs +++ b/Source/Client/Comp/World/MultiplayerWorldComp.cs @@ -22,6 +22,18 @@ public class MultiplayerWorldComp : IHasSessionData public Faction spectatorFaction; + // Multifaction: quest.id -> faction.loadID owning the quest (see QuestFactionOwnership) + public Dictionary questOwnership = new(); + + // Multifaction: wastepack thingIDNumber -> faction.loadID of the dumper. + // Consulted when a spawned pack rots on a non-player-home map; pruned on + // destroy. See Factions/WastepackAttribution.cs. + public Dictionary wastepackDumpers = new(); + + // Multifaction: per-faction ritual repeat-penalty windows. + // Key = (precept.Id << 32) | faction.loadID -> finish tick (see IdeoSharedStatePatches) + public Dictionary ritualLastFinished = new(); + private int currentFactionId; public MultiplayerWorldComp(World world) @@ -35,6 +47,18 @@ public void ExposeData() { ExposeFactionData(); + Scribe_Collections.Look(ref questOwnership, "questOwnership", LookMode.Value, LookMode.Value); + Scribe_Collections.Look(ref ritualLastFinished, "ritualLastFinished", LookMode.Value, LookMode.Value); + if (Scribe.mode != LoadSaveMode.Saving) + questOwnership ??= new Dictionary(); + + Scribe_Collections.Look(ref wastepackDumpers, "wastepackDumpers", LookMode.Value, LookMode.Value); + if (Scribe.mode != LoadSaveMode.Saving) + { + wastepackDumpers ??= new Dictionary(); + ritualLastFinished ??= new Dictionary(); + } + sessionManager.ExposeSessions(); // Ensure a pause lock session exists if there's any pause locks registered if (!PauseLockSession.pauseLocks.NullOrEmpty() && Scribe.mode == LoadSaveMode.PostLoadInit) @@ -79,6 +103,9 @@ void RemoveOpponentFaction() RemoveOpponentFaction(); } + if (Multiplayer.GameComp.multifaction) + Factions.QuestFactionOwnership.BackfillOwnership(); + // Fix old save files by ensuring all factions have access to Anomaly research if // it was enabled. This needs to be done since Anomaly state is shared by all players. var anomaly = Find.Anomaly; @@ -164,6 +191,26 @@ public void SetFaction(Faction faction) game.history = data.history; game.storyteller = data.storyteller; game.storyWatcher = data.storyWatcher; + + if (data.analysisManager != null) + game.analysisManager = data.analysisManager; + + // Goodwill caps/natural-goodwill cache per faction: workers read + // Faction.OfPlayer's ideo, so each faction's queries hit its own instance + if (Multiplayer.GameComp.multifaction && data.goodwillSituationManager != null) + Find.FactionManager.goodwillSituationManager = data.goodwillSituationManager; + + // Bossgroup component state: dict/list swap by reference, the cooldown + // int is copied in (written back by BossgroupLastCalledWriteBack). + // Multifaction only - in plain MP the vanilla component stays live and + // this would stamp a stale cooldown over it every forced context push + if (Multiplayer.GameComp.multifaction && + data.bossgroup != null && game.GetComponent() is { } bossgroups) + { + bossgroups.timesCalledBossgroups = data.bossgroup.timesCalledBossgroups; + bossgroups.killedBosses = data.bossgroup.killedBosses; + bossgroups.lastBossgroupCalled = data.bossgroup.lastBossgroupCalled; + } } public void DirtyColonyTradeForMap(Map map) diff --git a/Source/Client/ConstantTicker.cs b/Source/Client/ConstantTicker.cs index 214ce1e33..30abf8c94 100644 --- a/Source/Client/ConstantTicker.cs +++ b/Source/Client/ConstantTicker.cs @@ -137,10 +137,21 @@ private static void TickShipCountdown() { if (ShipCountdown.timeLeft > 0f) { - ShipCountdown.timeLeft -= 1f / GenTicks.TicksPerRealSecond; + // CountdownEnded does sim work but the constant tick runs + // under whatever clock the frame installed (the viewer's + // since the viewer-clock install) - pin it to the world clock + var prev = TimeSnapshot.GetAndSetFromWorld(); + try + { + ShipCountdown.timeLeft -= 1f / GenTicks.TicksPerRealSecond; - if (ShipCountdown.timeLeft <= 0f) - ShipCountdown.CountdownEnded(); + if (ShipCountdown.timeLeft <= 0f) + ShipCountdown.CountdownEnded(); + } + finally + { + prev?.Set(); + } } } } diff --git a/Source/Client/Desyncs/LogGenerator.cs b/Source/Client/Desyncs/LogGenerator.cs index 192bb3110..58094403a 100644 --- a/Source/Client/Desyncs/LogGenerator.cs +++ b/Source/Client/Desyncs/LogGenerator.cs @@ -13,6 +13,11 @@ namespace Multiplayer.Client public static class LogGenerator { private const int MaxLogLineCount = 10000; + // The incident is always at the end of the log, so when over budget + // keep the newest lines; a small head preserves startup/env context + // (mod list details live in local_metadata anyway) + private const int HeadLineCount = 2000; + private const int TailLineCount = MaxLogLineCount - HeadLineCount; internal static string PrepareLogData() { @@ -49,11 +54,17 @@ private static string NormalizeLineEndings(string log) private static string TrimExcessLines(string log) { var indexOfLastNewline = log.IndexOfOccurrence('\n', MaxLogLineCount); - if (indexOfLastNewline >= 0) - { - log = $"{log.Substring(0, indexOfLastNewline + 1)}(log trimmed to {MaxLogLineCount:N0} lines.)"; - } - return log; + if (indexOfLastNewline < 0) + return log; + + var lines = log.Split('\n'); + var omitted = lines.Length - HeadLineCount - TailLineCount; + if (omitted <= 0) + return log; + + return string.Join("\n", lines.Take(HeadLineCount)) + + $"\n(... {omitted:N0} lines omitted ...)\n" + + string.Join("\n", lines.Skip(lines.Length - TailLineCount)); } private static string RedactUselessLines(string log) diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 200c3dfee..2f582c688 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -114,10 +114,16 @@ private string GetDesyncDetails() .AppendLine("\n###Version Data###") .AppendLine($"Multiplayer Mod Version|||{MpVersion.Version}") .AppendLine($"Rimworld Version and Rev|||{VersionControl.CurrentVersionStringWithRev}") + // Whether the builds matched is the first forensic question; null + // means the join-data mismatch window never opened + .AppendLine($"Join Data Diff|||{Multiplayer.session.joinDataDiff ?? "clean (no mismatch window shown)"}") .AppendLine("\n###Debug Options###") .AppendLine($"Multiplayer Debug Build - Client|||{MpVersion.IsDebug}") .AppendLine($"Multiplayer Debug Mode - Host|||{Multiplayer.GameComp.debugMode}") .AppendLine($"Rimworld Developer Mode - Client|||{Prefs.DevMode}") + .AppendLine("\n###Simulation Failures###") + .AppendLine($"Skipped Commands/Ticks This Session|||{Multiplayer.session.simulationFailures}") + .AppendLine($"First Failure|||{Multiplayer.session.firstSimulationFailure ?? "none"}") .AppendLine("\n###Server Info###") .AppendLine($"Player Count|||{Multiplayer.session.players.Count}") .AppendLine($"Async time active|||{Multiplayer.GameComp.asyncTime}") diff --git a/Source/Client/Desyncs/SimulationFailures.cs b/Source/Client/Desyncs/SimulationFailures.cs new file mode 100644 index 000000000..329a1ee62 --- /dev/null +++ b/Source/Client/Desyncs/SimulationFailures.cs @@ -0,0 +1,35 @@ +using System; +using Multiplayer.Client.Util; +using RimWorld; +using Verse; + +namespace Multiplayer.Client.Desyncs +{ + // A command/tick exception is caught and skipped so the simulation keeps + // running, but the skip means this client may no longer match the others - + // record it and warn the player once instead of only burying it in the log + public static class SimulationFailures + { + public static void Handle(string context, Exception e) + { + MpLog.Error($"{context}: {e}"); + + var session = Multiplayer.session; + if (session == null) + return; + + session.simulationFailures++; + if (session.simulationFailures > 1) + return; + + session.firstSimulationFailure = $"[{TickPatch.Timer}] {context}: {e.GetType().Name}: {e.Message}"; + + // historical: false - archiving mutates game state, and the failure + // may have happened on this client only + Messages.Message( + "MP: a synced command or tick failed on this client and was skipped. " + + "This can lead to a desync - check the log for the error.", + MessageTypeDefOf.NegativeEvent, historical: false); + } + } +} diff --git a/Source/Client/Desyncs/UserReadableDesyncInfo.cs b/Source/Client/Desyncs/UserReadableDesyncInfo.cs index 7d269984e..27c8c45f1 100644 --- a/Source/Client/Desyncs/UserReadableDesyncInfo.cs +++ b/Source/Client/Desyncs/UserReadableDesyncInfo.cs @@ -51,8 +51,9 @@ public static string GenerateFromReplay(Replay replay) text.AppendLine("[info]"); text.AppendLine(zip.GetString("info")); } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -63,8 +64,9 @@ public static string GenerateFromReplay(Replay replay) //Local Client Opinion data local = DeserializeAndPrintSyncInfo(text, "sync_local", zip.GetBytes("sync_local")); } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -75,8 +77,9 @@ public static string GenerateFromReplay(Replay replay) //Remote Client Opinion data remote = DeserializeAndPrintSyncInfo(text, "sync_remote", zip.GetBytes("sync_remote")); } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -104,8 +107,9 @@ public static string GenerateFromReplay(Replay replay) text.AppendLine($"Game debug mode: {desyncInfo.ReadBool()}"); } } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -140,8 +144,9 @@ public static string GenerateFromReplay(Replay replay) foreach (var cmd in ScheduledCommand.DeserializeCmds(zip.GetBytes("maps/000_0_cmds"))) PrintCmdInfo(text, cmd); } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -153,8 +158,9 @@ public static string GenerateFromReplay(Replay replay) foreach (var cmd in ScheduledCommand.DeserializeCmds(zip.GetBytes("world/000_cmds"))) PrintCmdInfo(text, cmd); } - catch + catch (Exception e) { + text.AppendLine($""); } } diff --git a/Source/Client/EarlyInit.cs b/Source/Client/EarlyInit.cs index 35d89bb7e..cc8e23182 100644 --- a/Source/Client/EarlyInit.cs +++ b/Source/Client/EarlyInit.cs @@ -30,8 +30,7 @@ internal static void EarlyPatches(Harmony harmony) if (type.IsDefined(typeof(EarlyPatchAttribute))) harmony.CreateClassProcessor(type).Patch(); } catch (Exception e) { - Log.Error($"FAIL: {type} with {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"FAIL: {type} with {e}"); } }); diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index c26ea721e..03488d615 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -110,6 +110,20 @@ private static Map GenerateNewMap(PlanetTile tile, Scenario scenario, bool setup { // This has to be null, otherwise, during map generation, Faction.OfPlayer returns it which breaks FactionContext Find.GameInitData.playerFaction = null; + + // PrepForMapGen's Biotech tail reads the CLIENT-LOCAL static + // StartingPawnUtility generation requests to add custom xenotypes to + // the game-global customXenotypeDatabase - only the issuer's client + // has them populated, so the database (and later factionless-pawn + // xenotype Rand rolls) would diverge across clients. Clear the + // requests on every client so the loop no-ops identically everywhere. + // Accepted cost: custom xenotypes of faction-creation starting pawns + // aren't registered in the database (the pawns keep their xenotype; + // the entry only feeds the UI list and factionless spawn weighting). + if (StartingPawnUtility.StartingAndOptionalPawnGenerationRequests.Any(r => r.ForcedCustomXenotype != null)) + Log.Message("MP: faction creation dropped custom-xenotype database registration (kept deterministic)"); + StartingPawnUtility.StartingAndOptionalPawnGenerationRequests.Clear(); + Find.GameInitData.PrepForMapGen(); // ScenPart_PlayerFaction --> PreMapGenerate @@ -223,13 +237,16 @@ private static Faction NewFactionWithIdeo(string name, Color color, FactionDef d faction.ideos = new FactionIdeosTracker(faction); - if (!ModsConfig.IdeologyActive || Find.IdeoManager.classicMode || chooseIdeoInfo.SelectedIdeo == null) + if (!ModsConfig.IdeologyActive || Find.IdeoManager.classicMode || + (chooseIdeoInfo.SelectedIdeo == null && chooseIdeoInfo.CustomIdeoData == null)) { faction.ideos.SetPrimary(Faction.OfPlayer.ideos.PrimaryIdeo); } else { - var newIdeo = GenerateIdeo(chooseIdeoInfo); + var newIdeo = chooseIdeoInfo.CustomIdeoData != null + ? ReconstructCustomIdeo(chooseIdeoInfo.CustomIdeoData) + : GenerateIdeo(chooseIdeoInfo); faction.ideos.SetPrimary(newIdeo); Find.IdeoManager.Add(newIdeo); } @@ -254,6 +271,29 @@ private static Faction NewFactionWithIdeo(string name, Color color, FactionDef d return faction; } + // Deserializes a player-provided custom ideo (.rid file) inside the synced + // creation command; ids from the issuer's local game are reassigned here, + // where allocation is deterministic across clients + private static Ideo ReconstructCustomIdeo(byte[] data) + { + var ideo = ScribeUtil.ReadExposable(data); + + ideo.id = Find.UniqueIDsManager.GetNextIdeoID(); + foreach (var precept in ideo.PreceptsListForReading) + { + precept.ID = Find.UniqueIDsManager.GetNextPreceptID(); + precept.ideo = ideo; + } + + // Same back-reference fixups as FixIdeoAfterCopy. development is null + // for non-fluid ideos; style always exists + if (ideo.development != null) + ideo.development.ideo = ideo; + ideo.style.ideo = ideo; + + return ideo; + } + private static Ideo GenerateIdeo(IdeologyData chooseIdeoInfo) { List list = chooseIdeoInfo.SelectedIdeo.memes.ToList(); diff --git a/Source/Client/Factions/FactionExtensions.cs b/Source/Client/Factions/FactionExtensions.cs index a520c8cf0..2fd09afae 100644 --- a/Source/Client/Factions/FactionExtensions.cs +++ b/Source/Client/Factions/FactionExtensions.cs @@ -1,4 +1,5 @@ using System.Linq; +using Multiplayer.Client.Util; using RimWorld; using RimWorld.Planet; using Verse; @@ -7,6 +8,42 @@ namespace Multiplayer.Client.Factions; public static class FactionExtensions { + private static float lastUnwindReport; + private static int unwoundEntries; + + // FactionContext is strict always-push/always-pop, so a push whose pop was + // skipped (an exception inside a faction bracket) permanently shifts every + // later pop onto the wrong entry: the restored OfPlayer and installed + // faction data then depend on tick history instead of scope pairing, and + // UI/render reads alternate between factions' data. Brackets record the + // stack depth on entry and unwind to it here before their own pop - + // popping stranded entries in LIFO order lands on the oldest one's saved + // value, which is what the balanced path would have restored. + public static void UnwindFactionStack(Map map, int expectedDepth, string site) + { + if (FactionContext.stack.Count < expectedDepth) + { + // Someone popped past our bracket - can't repair from here, but + // never silent: the bracket's own pop below is now misaligned + MpLog.Error($"Faction stack underflow at {site}: depth {FactionContext.stack.Count} < expected {expectedDepth}"); + return; + } + + if (FactionContext.stack.Count == expectedDepth) + return; + + unwoundEntries += FactionContext.stack.Count - expectedDepth; + while (FactionContext.stack.Count > expectedDepth) + map.PopFaction(); + + var now = UnityEngine.Time.realtimeSinceStartup; + if (now - lastUnwindReport < 30f) return; + lastUnwindReport = now; + MpLog.Warn($"Faction stack unwound: {unwoundEntries} stranded entr(y/ies), last at {site} - " + + "an exception inside a faction bracket skipped its pop (see errors above for the thrower)"); + unwoundEntries = 0; + } + // Sets the current Faction.OfPlayer // Applies faction's world components // Applies faction's map components if map not null @@ -43,7 +80,13 @@ public static bool TryGetPlayerFaction(this Quest quest, out Faction faction) faction = quest.QuestLookTargets .Where(t => t.HasWorldObject && t.WorldObject is Settlement) .Select(t => ((Settlement)t.WorldObject).Faction) - .FirstOrDefault(f => f != null); + .FirstOrDefault(f => f != null) + // Pawn-targeted quests (bestowing ceremonies, rescues) have no + // settlement target - the targeted pawn's faction is the owner + ?? quest.QuestLookTargets + .Where(t => t.HasThing && t.Thing is Pawn) + .Select(t => ((Pawn)t.Thing).Faction) + .FirstOrDefault(f => f is { IsPlayer: true }); return faction != null; } diff --git a/Source/Client/Factions/FactionRepeater.cs b/Source/Client/Factions/FactionRepeater.cs index 7fd1a8110..3bb4bf185 100644 --- a/Source/Client/Factions/FactionRepeater.cs +++ b/Source/Client/Factions/FactionRepeater.cs @@ -196,6 +196,71 @@ ref ignore ); } + // An impassable building spawned while another faction's (or the + // spectator's) zoneManager is installed - e.g. from a quest signal during + // the world tick - clears zone cells only from that copy, leaving stale + // zone cells under the building in the owner's zones. Objective event with + // a single caller (Thing.SpawnSetup), so repeat it across every copy. + [HarmonyPatch(typeof(ZoneManager), nameof(ZoneManager.Notify_NoZoneOverlapThingSpawned))] + static class ZoneOverlapSpawnedPatch + { + static bool ignore; + + static bool Prefix(ZoneManager __instance, Thing thing) => + FactionRepeater.Template( + __instance.map.MpComp()?.factionData, + d => d.zoneManager.Notify_NoZoneOverlapThingSpawned(thing), + __instance.map, + ref ignore + ); + } + + // Destroying a designated thing cleans designations only from each map's + // installed designationManager - other factions' copies keep a stale + // designation on a dead thing. RemoveAllDesignationsOn itself also backs + // player-intent designators (cancel/tame), where repeating would delete + // other factions' designations, so hook the objective call sites instead: + // Thing's destroy-time cleanup and faction changes (recruit/capture). + [HarmonyPatch(typeof(Thing), nameof(Thing.RemoveAllReservationsAndDesignationsOnThis))] + static class ThingDestroyedDesignationsPatch + { + static void Postfix(Thing __instance) + { + if (Multiplayer.Client == null || __instance.def.category == ThingCategory.Mote) + return; + + // Mirrors the patched method: designations can live on any map's copies + foreach (var map in Find.Maps) + { + var dummy = false; + FactionRepeater.Template( + map.MpComp()?.factionData, + d => d.designationManager.RemoveAllDesignationsOn(__instance), + map, + ref dummy + ); + } + } + } + + [HarmonyPatch(typeof(Pawn), nameof(Pawn.SetFaction))] + static class PawnSetFactionDesignationsPatch + { + static void Postfix(Pawn __instance) + { + if (Multiplayer.Client == null || !__instance.Spawned) + return; + + var dummy = false; + FactionRepeater.Template( + __instance.Map.MpComp()?.factionData, + d => d.designationManager.RemoveAllDesignationsOn(__instance), + __instance.Map, + ref dummy + ); + } + } + // Fix for RimWorld 1.6 bug where HistoryAutoRecorder.Tick() calls .Last() on empty collection [HarmonyPatch(typeof(HistoryAutoRecorder), nameof(HistoryAutoRecorder.Tick))] static class HistoryAutoRecorderTickPatch diff --git a/Source/Client/Factions/FactionSidebar.cs b/Source/Client/Factions/FactionSidebar.cs index c530eadb1..b6c072b04 100644 --- a/Source/Client/Factions/FactionSidebar.cs +++ b/Source/Client/Factions/FactionSidebar.cs @@ -360,6 +360,44 @@ private static void DrawFactionCreator(Rect inRect) { Find.WindowStack.Add(new Dialog_FactionDuringLanding()); } + + // Multifaction creation skips the vanilla naming prompts - offer them + // here (the dialogs' accept paths are synced in SyncMethods) + if (Multiplayer.RealPlayerFaction is { } myFaction && + myFaction != Multiplayer.WorldComp.spectatorFaction) + { + Rect renameFactionRect = new Rect( + viewRect.x, + worldFactionsRect.yMax + spacing, + viewRect.width, + buttonHeight + ); + if (Widgets.ButtonText(renameFactionRect, "Rename faction")) + Find.WindowStack.Add(new Dialog_NamePlayerFaction()); + + var mySettlements = Find.WorldObjects.Settlements + .Where(s => s.Faction == myFaction).ToList(); + + if (mySettlements.Any()) + { + Rect nameColonyRect = new Rect( + viewRect.x, + renameFactionRect.yMax + spacing, + viewRect.width, + buttonHeight + ); + if (Widgets.ButtonText(nameColonyRect, "Name colony")) + { + if (mySettlements.Count == 1) + Find.WindowStack.Add(new Dialog_NamePlayerSettlement(mySettlements[0])); + else + Find.WindowStack.Add(new FloatMenu(mySettlements + .Select(s => new FloatMenuOption(s.Name, + () => Find.WindowStack.Add(new Dialog_NamePlayerSettlement(s)))) + .ToList())); + } + } + } // END Buttons } diff --git a/Source/Client/Factions/GoodwillPairPatches.cs b/Source/Client/Factions/GoodwillPairPatches.cs new file mode 100644 index 000000000..dfe76be56 --- /dev/null +++ b/Source/Client/Factions/GoodwillPairPatches.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using HarmonyLib; +using Multiplayer.Client.Factions; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.Patches; + +// Vanilla funnels all goodwill through Faction.OfPlayer - the spectator during +// the multifaction world tick - so natural drift and ideo-derived caps only +// ever touched the spectator pair. Faction.relations is already pairwise; +// these patches fix the access paths: per-faction GoodwillSituationManager +// instances (swapped in SetFaction) and per-NPC drift timers, with the drift +// check and 1000-tick caps recalc fanned per ownable faction under context. + +[HarmonyPatch(typeof(Faction), "CheckReachNaturalGoodwill")] +static class CheckReachNaturalGoodwillPatch +{ + static bool Prefix(Faction __instance) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return true; + + // Vanilla's early-outs, replicated so the fan below only runs for + // factions the original would have processed + if (__instance.IsPlayer || !__instance.HasGoodwill || __instance.def.permanentEnemy) + return false; + + foreach (var kv in Multiplayer.WorldComp.factionData) + { + var faction = Find.FactionManager.GetById(kv.Key); + if (!QuestFactionOwnership.IsOwnablePlayerFaction(faction)) + continue; + + ((Map)null).PushFaction(faction); + try + { + DriftForPair(__instance, kv.Value); + } + finally + { + FactionExtensions.PopFaction(); + } + } + + return false; + } + + // Vanilla CheckReachNaturalGoodwill body (1.6.4871) against the pushed + // faction's manager instance and this pair's own timer. OfPlayer is the + // pushed faction here, so BaseGoodwillWith/TryAffectGoodwillWith land on + // the right FactionRelation pair. + private static void DriftForPair(Faction npc, FactionWorldData data) + { + data.naturalGoodwillTimers.TryGetValue(npc.loadID, out int timer); + + int current = npc.BaseGoodwillWith(Faction.OfPlayer); + int natural = data.goodwillSituationManager.GetNaturalGoodwill(npc); + var window = new IntRange(natural - 50, natural + 50); + + if (window.Includes(current)) + { + data.naturalGoodwillTimers[npc.loadID] = 0; + return; + } + + timer++; + + if (current < window.min) + { + if (timer >= 3000000) + { + npc.TryAffectGoodwillWith(Faction.OfPlayer, Mathf.Min(10, window.min - current), + canSendMessage: true, canSendHostilityLetter: !npc.temporary, + HistoryEventDefOf.ReachNaturalGoodwill); + timer = 0; + } + } + else if (current > window.max && timer >= 3000000) + { + npc.TryAffectGoodwillWith(Faction.OfPlayer, -Mathf.Min(10, current - window.max), + canSendMessage: true, canSendHostilityLetter: !npc.temporary, + HistoryEventDefOf.ReachNaturalGoodwill); + timer = 0; + } + + data.naturalGoodwillTimers[npc.loadID] = timer; + } +} + +[HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GoodwillManagerTick))] +static class GoodwillManagerTickPatch +{ + static bool Prefix() + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return true; + + if (Find.TickManager.TicksGame % 1000 != 0) + return false; + + foreach (var kv in Multiplayer.WorldComp.factionData) + { + var faction = Find.FactionManager.GetById(kv.Key); + if (!QuestFactionOwnership.IsOwnablePlayerFaction(faction)) + continue; + + ((Map)null).PushFaction(faction); + try + { + kv.Value.goodwillSituationManager.RecalculateAll(canSendHostilityChangedLetter: true); + } + finally + { + FactionExtensions.PopFaction(); + } + } + + return false; + } +} + +// Cache fills from UI context must not persist: workers read live sim state, +// so a client whose UI filled an entry at tick T holds different cached +// values than one whose entry filled at the 1000-tick recalc - and the drift +// check trusts the cache. Pre-existing upstream hazard on stable MP too +// (faction dialog explanations fill lazily), worse with per-faction caches. +// UI reads get a fresh uncached computation; sim-context fills persist as +// vanilla. Persisting fills also fire CheckHostilityChanged (relation-kind +// flips + letters), so this gate keeps that sim-only as a side effect. +[HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GetSituations))] +static class GetSituationsUIGuard +{ + private static readonly AccessTools.FieldRef>> + cachedDataRef = AccessTools.FieldRefAccess>>("cachedData"); + + private static readonly FastInvokeHandler recalculateInto = MethodInvoker.GetHandler( + AccessTools.Method(typeof(GoodwillSituationManager), "Recalculate", + new[] { typeof(Faction), typeof(List) })); + + static bool Prefix(GoodwillSituationManager __instance, Faction other, + ref List __result) + { + if (Multiplayer.Client == null || Multiplayer.Ticking || Multiplayer.ExecutingCmds) + return true; + + if (other == null || other.IsPlayer) + return true; + + if (cachedDataRef(__instance).TryGetValue(other, out var cached)) + { + __result = cached; + return false; + } + + var fresh = new List(); + recalculateInto(__instance, other, fresh); + __result = fresh; + return false; + } +} diff --git a/Source/Client/Factions/IdeoCounterPatches.cs b/Source/Client/Factions/IdeoCounterPatches.cs new file mode 100644 index 000000000..88f81ec76 --- /dev/null +++ b/Source/Client/Factions/IdeoCounterPatches.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using Verse; +using Verse.AI.Group; + +namespace Multiplayer.Client.Factions; + +// IdeoManager keeps three scalars as single globals. Under +// the per-faction storyteller/thought fan they become cross-faction bleed: +// every faction drains the one gauranlen pod counter (and the reset value's +// divisibility parks the zero-crossing on the same faction every cycle), and +// one faction's resettle or psychic ritual moves every faction's precept +// moods. Per-faction state lives on FactionWorldData; the vanilla globals +// stay written so SP and single-faction MP are untouched. +static class IdeoCounterPatches +{ + public static FactionWorldData ContextFactionData() + { + var f = Faction.OfPlayer; + if (f == null || !QuestFactionOwnership.IsOwnablePlayerFaction(f)) + return null; + return Multiplayer.WorldComp.factionData.TryGetValue(f.loadID, out var data) ? data : null; + } +} + +[HarmonyPatch(typeof(StorytellerComp_GauranlenPodSpawn), nameof(StorytellerComp_GauranlenPodSpawn.MakeIntervalIncidents))] +static class PerFactionGauranlenPodCounter +{ + static bool Prefix(StorytellerComp_GauranlenPodSpawn __instance, IIncidentTarget target, + ref IEnumerable __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return true; + + __result = PerFaction(__instance, target); + return false; + } + + static IEnumerable PerFaction(StorytellerComp_GauranlenPodSpawn comp, IIncidentTarget target) + { + // Spectator context (or a faction with no data) must not drain anyone + var data = IdeoCounterPatches.ContextFactionData(); + if (data == null) + yield break; + + // Replicated from StorytellerComp_GauranlenPodSpawn.MakeIntervalIncidents + // (1.6.4871) with the counter swapped to the context faction's. + // Re-verify against the decompile on game update. + var props = (StorytellerCompProperties_GauranlenPodSpawn)comp.props; + if (!ModsConfig.IdeologyActive || props.daysBetweenPodSpawns == 0f || + Faction.OfPlayer.ideos == null || (float)GenDate.DaysPassed < props.minDaysPassed) + yield break; + + int num = 1; + if (props.countdownFactorAnyConnectors > 1) + { + foreach (Ideo ideo in Faction.OfPlayer.ideos.AllIdeos) + { + if (ideo.HasMeme(MemeDefOf.TreeConnection)) + { + num = props.countdownFactorAnyConnectors; + break; + } + } + } + + // First sim use for this faction: take over the shared global's + // progress instead of restarting a full cycle. Sim-only path, so the + // seed write is deterministic on every client. + if (data.ticksToNextGauranlenSpawn < 0) + data.ticksToNextGauranlenSpawn = Find.IdeoManager.ticksToNextGauranlenSpawn; + + data.ticksToNextGauranlenSpawn -= 1000 * num; + if (data.ticksToNextGauranlenSpawn <= 0) + { + data.ticksToNextGauranlenSpawn = (int)(props.daysBetweenPodSpawns * 60000f); + yield return new FiringIncident(props.incident, comp, comp.GenerateParms(props.incident.category, target)); + } + } +} + +// Writers: stamp the acting faction's copy whenever vanilla stamped the +// global this tick. Comparing against the global inherits vanilla's own +// gating (e.g. AddNewHome only stamps for faction == Faction.OfPlayer) +// without replicating it. +[HarmonyPatch(typeof(SettleUtility), nameof(SettleUtility.AddNewHome))] +static class PerFactionResettledStampSettle +{ + static void Postfix() + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + if (Find.IdeoManager != null && Find.IdeoManager.lastResettledTick == GenTicks.TicksGame && + IdeoCounterPatches.ContextFactionData() is { } data) + data.lastResettledTick = GenTicks.TicksGame; + } +} + +[HarmonyPatch(typeof(GravshipUtility), nameof(GravshipUtility.ArriveNewMap))] +static class PerFactionResettledStampGravship +{ + static void Postfix() + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + if (Find.IdeoManager != null && Find.IdeoManager.lastResettledTick == GenTicks.TicksGame && + IdeoCounterPatches.ContextFactionData() is { } data) + data.lastResettledTick = GenTicks.TicksGame; + } +} + +[HarmonyPatch(typeof(LordToil_PsychicRitual), "RitualCompleted")] +static class PerFactionPsychicRitualStamp +{ + static void Postfix() + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + if (Find.IdeoManager != null && Find.IdeoManager.lastPsychicRitualPerformedTick == Find.TickManager.TicksGame && + IdeoCounterPatches.ContextFactionData() is { } data) + data.lastPsychicRitualPerformedTick = Find.TickManager.TicksGame; + } +} + +// Readers: swap the context faction's values into the globals around the +// thought-worker bodies, restore unconditionally. -1 (never stamped for this +// faction) reads as vanilla's default 0 WITHOUT writing anything - reads can +// run from UI context (mood tooltips), where a lazily-seeded write would be a +// per-client mutation of scribed state. +[HarmonyPatch] +static class PerFactionIdeoStampReads +{ + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(ThoughtWorker_Precept_ResettledRecently), "ShouldHaveThought"); + yield return AccessTools.Method(typeof(ThoughtWorker_Precept_ResettledRecently), "CurrentStateInternal"); + yield return AccessTools.Method(typeof(ThoughtWorker_Precept_NoPsychicRituals), "ShouldHaveThought"); + } + + static void Prefix(ref (int resettled, int psychic)? __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || Find.IdeoManager == null) + return; + if (IdeoCounterPatches.ContextFactionData() is not { } data) + return; + + __state = (Find.IdeoManager.lastResettledTick, Find.IdeoManager.lastPsychicRitualPerformedTick); + Find.IdeoManager.lastResettledTick = Math.Max(0, data.lastResettledTick); + Find.IdeoManager.lastPsychicRitualPerformedTick = Math.Max(0, data.lastPsychicRitualPerformedTick); + } + + static void Finalizer((int resettled, int psychic)? __state) + { + if (__state is not { } saved) + return; + Find.IdeoManager.lastResettledTick = saved.resettled; + Find.IdeoManager.lastPsychicRitualPerformedTick = saved.psychic; + } +} + +// GetDescriptionArgs is an iterator, so a field swap around the call would be +// restored before the body ever runs - replace the one-line body instead. +// Replicated from ThoughtWorker_Precept_ResettledRecently.GetDescriptionArgs +// (1.6.4871). +[HarmonyPatch(typeof(ThoughtWorker_Precept_ResettledRecently), nameof(ThoughtWorker_Precept_ResettledRecently.GetDescriptionArgs))] +static class PerFactionResettledDescription +{ + static bool Prefix(ref IEnumerable __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return true; + if (IdeoCounterPatches.ContextFactionData() is not { } data) + return true; + + int ticksSince = GenTicks.TicksGame - Math.Max(0, data.lastResettledTick); + __result = new List { ticksSince.ToStringTicksToPeriod().Named("DURATION") }; + return false; + } +} diff --git a/Source/Client/Factions/IdeoSharedStatePatches.cs b/Source/Client/Factions/IdeoSharedStatePatches.cs new file mode 100644 index 000000000..1074f9430 --- /dev/null +++ b/Source/Client/Factions/IdeoSharedStatePatches.cs @@ -0,0 +1,98 @@ +using HarmonyLib; +using RimWorld; +using Verse; +using Verse.AI.Group; + +namespace Multiplayer.Client.Patches; + +// Shared ideos keep "one church shares its state" semantics; these patches +// fix only the cross-faction pitfalls: reformation applies only for the +// ideo's primary follower faction, and ritual repeat-penalty windows are +// per (precept, faction) so one faction's ritual doesn't burn another's +// 20-day quality window. The shared field keeps vanilla non-MP behavior. + +static class RitualPairs +{ + public static long Key(Precept precept, Faction faction) => + ((long)precept.Id << 32) | (uint)faction.loadID; + + public static bool Active => + Multiplayer.Client != null && Multiplayer.GameComp.multifaction; + + // Finish tick for the context faction's pair, or -1 (never performed) + public static int PairFinishTick(Precept_Ritual ritual) + { + var faction = Faction.OfPlayer; + if (faction == null) + return -1; + return Multiplayer.WorldComp.ritualLastFinished + .TryGetValue(Key(ritual, faction), out var tick) ? tick : -1; + } +} + +[HarmonyPatch(typeof(IdeoDevelopmentUtility), nameof(IdeoDevelopmentUtility.ApplyChangesToIdeo))] +static class SharedIdeoReformationGate +{ + static bool Prefix(Ideo ideo) + { + if (!RitualPairs.Active) + return true; + + var owner = IdeoContextUtil.PrimaryPlayerFollower(ideo); + if (owner == null || Faction.OfPlayer == owner) + return true; + + Log.Message($"MP: reform of shared ideo '{ideo?.name}' skipped - only its primary follower faction may reform it"); + return false; + } +} + +[HarmonyPatch(typeof(LordJob_Ritual), nameof(LordJob_Ritual.ApplyOutcome))] +static class RitualFinishRecordsPair +{ + static void Postfix(LordJob_Ritual __instance) + { + if (!RitualPairs.Active) + return; + + var ritual = __instance.Ritual; + // The shared field is stamped with the current tick only on the + // success path - that stamp is the signal this run finished + if (ritual == null || ritual.lastFinishedTick != GenTicks.TicksGame) + return; + + if (__instance.lord?.faction is not { IsPlayer: true } faction) + return; + + Multiplayer.WorldComp.ritualLastFinished[RitualPairs.Key(ritual, faction)] = GenTicks.TicksGame; + } +} + +[HarmonyPatch(typeof(Precept_Ritual), nameof(Precept_Ritual.TicksSinceLastPerformed), MethodType.Getter)] +static class TicksSinceLastPerformedPerFaction +{ + static bool Prefix(Precept_Ritual __instance, ref int __result) + { + if (!RitualPairs.Active) + return true; + + __result = GenTicks.TicksGame - RitualPairs.PairFinishTick(__instance); + return false; + } +} + +[HarmonyPatch(typeof(Precept_Ritual), nameof(Precept_Ritual.RepeatPenaltyActive), MethodType.Getter)] +static class RepeatPenaltyActivePerFaction +{ + static bool Prefix(Precept_Ritual __instance, ref bool __result) + { + if (!RitualPairs.Active) + return true; + + // Vanilla body against the pair's finish tick + __result = __instance.isAnytime && __instance.def.useRepeatPenalty && + RitualPairs.PairFinishTick(__instance) != -1 && + __instance.TicksSinceLastPerformed < 1200000; + return false; + } +} diff --git a/Source/Client/Factions/IncidentRouting.cs b/Source/Client/Factions/IncidentRouting.cs new file mode 100644 index 000000000..c4fb208d6 --- /dev/null +++ b/Source/Client/Factions/IncidentRouting.cs @@ -0,0 +1,63 @@ +using HarmonyLib; +using Multiplayer.Client.Util; +using Multiplayer.Common; +using RimWorld; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Safety nets for multifaction incident routing (the root fix is in +// StorytellerTargetsPatch): log anything that still slips through. + +[HarmonyPatch(typeof(IncidentWorker), nameof(IncidentWorker.TryExecute))] +static class SuppressCrossFactionIncidents +{ + static bool Prefix(IncidentWorker __instance, IncidentParms parms, ref bool __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return true; + + // Explicit player orders always execute: an incident fired inside a + // synced command (e.g. a Call-Aid permit used while standing on + // another faction's map) is deliberate, not a routing bug - only + // storyteller/quest/tick-driven incidents keep the safety net + if (TickPatch.currentExecutingCmdType == CommandType.Sync) + return true; + + if (parms.target is Map { ParentFaction: { IsPlayer: true } owner } && owner != Faction.OfPlayer) + { + // QuestFactionOwnership now routes quest-fired incidents through + // the owner's context - spectator context here is a residual gap. + // Log it but let it through rather than eat a quest raid. + if (Faction.OfPlayer == Multiplayer.WorldComp.spectatorFaction) + { + MpLog.Log( + $"Spectator-context incident {__instance.def?.defName} targeting map of " + + $"{owner.Name} - unrouted generation source, letting it through"); + return true; + } + + MpLog.Error( + $"Suppressed cross-faction incident {__instance.def?.defName} targeting map of " + + $"{owner.Name} while executing as {Faction.OfPlayer?.Name} - report this, the " + + "root-cause filter should have prevented it"); + // Report "fired" so the storyteller/queue doesn't retry it elsewhere + __result = true; + return false; + } + + return true; + } +} + +[HarmonyPatch(typeof(Storyteller), nameof(Storyteller.TryFire))] +static class WarnSpectatorStorytellerFire +{ + static void Prefix(FiringIncident fi) + { + if (Multiplayer.Client != null && Multiplayer.GameComp.multifaction && + Faction.OfPlayer == Multiplayer.WorldComp.spectatorFaction) + MpLog.Error($"Storyteller fired {fi?.def?.defName} in spectator faction context - " + + "an unrouted generation source"); + } +} diff --git a/Source/Client/Factions/MilitaryAidPatches.cs b/Source/Client/Factions/MilitaryAidPatches.cs new file mode 100644 index 000000000..f867bc0d3 --- /dev/null +++ b/Source/Client/Factions/MilitaryAidPatches.cs @@ -0,0 +1,90 @@ +using HarmonyLib; +using Multiplayer.Client.AsyncTime; +using RimWorld; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Faction.lastMilitaryAidRequestTick lives on the NPC +// faction, so faction A calling military aid locks faction B out of that ally +// for a day. It is also a cross-clock stamp (absolute tick written +// under the comms console map's clock, read under whatever clock is installed +// at dialog build). Per-(player faction, NPC faction) stamps live on +// FactionWorldData, on the async world clock; the vanilla field stays written +// so SP is untouched. +// +// Sync-safety: the dialog is built inside the synced comms job on every +// client and NodeTreeDialogSync replays the chosen DiaOption BY INDEX, so +// this patch may change an option's disabled state but must never change +// whether an option appears. RequestMilitaryAidOption always returns exactly +// one option (enabled or disabled with "WaitTime"), and the field swap below +// cannot change that. The swap brackets only that builder and the finalizer +// restores unconditionally. +static class MilitaryAidPatches +{ + // Gated on CooldownClock.Active (all MP, not just multifaction): the + // clock-skew half applies to single-faction async games too; there the + // dict simply has one paying faction. + public static System.Collections.Generic.Dictionary ContextStamps() + { + var f = Faction.OfPlayer; + if (f == null) + return null; + return Multiplayer.WorldComp.factionData.TryGetValue(f.loadID, out var data) ? data.militaryAidStamps : null; + } +} + +[HarmonyPatch(typeof(FactionDialogMaker), "CallForAid")] +static class MilitaryAidStampComms +{ + static void Postfix(Faction faction) + { + if (!CooldownClock.Active) + return; + var stamps = MilitaryAidPatches.ContextStamps(); + if (stamps != null) + stamps[faction.loadID] = CooldownClock.Now; + } +} + +[HarmonyPatch(typeof(RoyalTitlePermitWorker_CallAid), "CallAid")] +static class MilitaryAidStampPermit +{ + static void Postfix(Faction faction) + { + if (!CooldownClock.Active) + return; + var stamps = MilitaryAidPatches.ContextStamps(); + if (stamps != null) + stamps[faction.loadID] = CooldownClock.Now; + } +} + +[HarmonyPatch(typeof(FactionDialogMaker), "RequestMilitaryAidOption")] +static class MilitaryAidReadPerPair +{ + static void Prefix(Faction faction, ref int? __state) + { + if (!CooldownClock.Active) + return; + var stamps = MilitaryAidPatches.ContextStamps(); + if (stamps == null) + return; + + __state = faction.lastMilitaryAidRequestTick; + + // Vanilla compares lastMilitaryAidRequestTick + 60000 against the + // ambient TicksGame, so install a value that reproduces this pair's + // world-clock remaining time under that comparison. No stamp for this + // pair = available (any value older than a day works). + faction.lastMilitaryAidRequestTick = stamps.TryGetValue(faction.loadID, out var stamp) + ? Find.TickManager.TicksGame - (CooldownClock.Now - stamp) + : -60000; + } + + static void Finalizer(Faction faction, int? __state) + { + if (__state is { } saved) + faction.lastMilitaryAidRequestTick = saved; + } +} diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index 9eea55518..9e5b12be9 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -862,6 +862,12 @@ static class Map_IsPlayerHome_Spectator_Patch { static bool Prefix(Map __instance, ref bool __result) { + // Spectator override shapes UI only: in sim contexts OfPlayer can be + // the spectator faction on one client but not others, and IsPlayerHome + // feeds sim-side consumers - all clients must take vanilla there + if (Multiplayer.Ticking || Multiplayer.ExecutingCmds) + return true; + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || Faction.OfPlayer != Multiplayer.WorldComp.spectatorFaction) return true; diff --git a/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs b/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs index 5a5b940bb..d0fdfeb0d 100644 --- a/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs +++ b/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs @@ -13,6 +13,10 @@ public class Page_ChooseIdeo_Multifaction : Page public Page_ChooseIdeoPreset pageChooseIdeo = new(); + // Custom ideology from a saved .rid file, loaded as a detached object + // (TryLoadIdeo registers nothing) and serialized into the creation command + private Ideo customIdeo; + public override void DoWindowContents(Rect inRect) { DrawPageTitle(inRect); @@ -27,6 +31,13 @@ public override void DoWindowContents(Rect inRect) pageChooseIdeo.DrawStructureAndStyleSelection(inRect); + var loadRect = new Rect(inRect.xMax - 260f, inRect.y, 250f, 32f); + var loadLabel = customIdeo == null + ? "Load custom ideoligion..." + : $"Custom: {customIdeo.name} (click to change)"; + if (Widgets.ButtonText(loadRect, loadLabel)) + OpenCustomIdeoMenu(); + Rect outRect = mainRect; outRect.width = 954f; outRect.yMin += totalHeight; @@ -49,25 +60,58 @@ public override void DoWindowContents(Rect inRect) DoBottomButtons(inRect); } + private void OpenCustomIdeoMenu() + { + var ideosDir = GenFilePaths.FolderUnderSaveData("Ideos"); + var files = new System.IO.DirectoryInfo(ideosDir).GetFiles("*.rid"); + + if (files.Length == 0) + { + Messages.Message( + "No saved ideoligions found. Create one in singleplayer's ideoligion editor and save it, then load it here.", + MessageTypeDefOf.RejectInput, historical: false); + return; + } + + var options = files + .Select(f => new FloatMenuOption(System.IO.Path.GetFileNameWithoutExtension(f.Name), () => + { + if (GameDataSaveLoader.TryLoadIdeo(f.FullName, out var loaded)) + customIdeo = loaded; + else + Messages.Message($"Failed to load {f.Name} - see log.", + MessageTypeDefOf.RejectInput, historical: false); + })) + .Append(new FloatMenuOption("Clear custom ideoligion", () => customIdeo = null)) + .ToList(); + + Find.WindowStack.Add(new FloatMenu(options)); + } + public override bool CanDoNext() { - if (pageChooseIdeo.selectedIdeo == null) + if (customIdeo == null && pageChooseIdeo.selectedIdeo == null) { - Messages.Message("Please select a preset.", MessageTypeDefOf.RejectInput, historical: false); + Messages.Message("Please select a preset or load a custom ideoligion.", MessageTypeDefOf.RejectInput, historical: false); return false; } return base.CanDoNext(); - } + } public IdeologyData GetIdeologyData() { - return new IdeologyData(pageChooseIdeo.selectedIdeo, pageChooseIdeo.selectedStructure, pageChooseIdeo.selectedStyles); + return new IdeologyData( + pageChooseIdeo.selectedIdeo, + pageChooseIdeo.selectedStructure, + pageChooseIdeo.selectedStyles, + customIdeo != null ? ScribeUtil.WriteExposable(customIdeo) : null); } } public record IdeologyData( IdeoPresetDef SelectedIdeo = null, MemeDef SelectedStructure = null, - List SelectedStyles = null + List SelectedStyles = null, + byte[] CustomIdeoData = null ) : ISyncSimple; diff --git a/Source/Client/Factions/QuestFactionOwnership.cs b/Source/Client/Factions/QuestFactionOwnership.cs new file mode 100644 index 000000000..1994cabfa --- /dev/null +++ b/Source/Client/Factions/QuestFactionOwnership.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using Multiplayer.Client.Util; +using RimWorld; +using RimWorld.Planet; +using RimWorld.QuestGen; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Multifaction: vanilla quests have no owning faction, so quest parts executed +// in whatever context the tick held (usually spectator) - reward pawns joined +// the Spectator faction (#546), letters and ideo checks hit the wrong faction. +// Quests are now stamped with an owner whose context wraps their execution. +public static class QuestFactionOwnership +{ + public static Faction GetOwner(Quest quest) + { + if (quest == null || + !Multiplayer.WorldComp.questOwnership.TryGetValue(quest.id, out var factionId)) + return null; + return Find.FactionManager.GetById(factionId); + } + + public static void Stamp(Quest quest, Faction faction) + { + if (quest != null && faction != null) + Multiplayer.WorldComp.questOwnership[quest.id] = faction.loadID; + } + + public static bool IsOwnablePlayerFaction(Faction f) => + f is { IsPlayer: true } && f != Multiplayer.WorldComp.spectatorFaction; + + public static Faction ResolveOwner(Quest quest, Faction contextFaction) + { + // 1. Generation context (per-faction storyteller loop, synced command) + if (IsOwnablePlayerFaction(contextFaction)) + return contextFaction; + + // 2. Infer from quest look targets (owning settlement) + if (quest.TryGetPlayerFaction(out var inferred) && IsOwnablePlayerFaction(inferred)) + return inferred; + + // 3. Deterministic fallback: lowest-loadID player faction (the host's) + return Find.FactionManager.AllFactionsListForReading + .Where(IsOwnablePlayerFaction) + .OrderBy(f => f.loadID) + .FirstOrDefault(); + } + + // Old saves have no ownership data + public static void BackfillOwnership() + { + var backfilled = 0; + foreach (var quest in Find.QuestManager.QuestsListForReading) + { + if (Multiplayer.WorldComp.questOwnership.ContainsKey(quest.id)) + continue; + Stamp(quest, ResolveOwner(quest, null)); + backfilled++; + } + + if (backfilled > 0) + MpLog.Log($"Backfilled faction ownership for {backfilled} quests"); + } +} + +[HarmonyPatch(typeof(QuestGen), nameof(QuestGen.Generate))] +static class StampQuestFactionOnGeneration +{ + static void Postfix(Quest __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || __result == null) + return; + + var contextFaction = Faction.OfPlayer; + var owner = QuestFactionOwnership.ResolveOwner(__result, contextFaction); + + // Logs identify generation sources that still run unrouted + if (!QuestFactionOwnership.IsOwnablePlayerFaction(contextFaction)) + MpLog.Log($"Quest {__result.root?.defName} generated outside a player faction " + + $"context ({contextFaction?.Name}); assigned to {owner?.Name}"); + + QuestFactionOwnership.Stamp(__result, owner); + } +} + +[HarmonyPatch(typeof(Quest), nameof(Quest.Accept))] +static class StampQuestFactionOnAccept +{ + // Ownership follows whoever accepts (the synced command's context faction) + static void Prefix(Quest __instance) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + if (QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer)) + QuestFactionOwnership.Stamp(__instance, Faction.OfPlayer); + } +} + +[HarmonyPatch] +static class PushQuestOwnerContext +{ + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(Quest), nameof(Quest.QuestTick)); + yield return AccessTools.Method(typeof(Quest), nameof(Quest.Notify_SignalReceived)); + yield return AccessTools.Method(typeof(Quest), nameof(Quest.CleanupQuestParts)); + } + + static void Prefix(Quest __instance, ref bool __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + var owner = QuestFactionOwnership.GetOwner(__instance); + if (owner == null) + return; + + // Push/pop are null-balanced, safe when the owner already holds context + ((Map)null).PushFaction(owner); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +[HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Remove))] +static class RemoveQuestOwnership +{ + static void Postfix(Quest quest) + { + if (Multiplayer.Client != null && quest != null) + Multiplayer.WorldComp?.questOwnership.Remove(quest.id); + } +} + +// Vanilla QuestNode_GetMap picks any IsPlayerHome map, so one faction's +// quest could bind another player's colony. Generation context = owner; +// reject other player factions' maps (neutral/site maps stay acceptable). +[HarmonyPatch(typeof(RimWorld.QuestGen.QuestNode_GetMap), "IsAcceptableMap")] +static class QuestMapMatchesGeneratingFaction +{ + static void Postfix(Map map, ref bool __result) + { + if (!__result || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + if (QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer) && + map.ParentFaction is { IsPlayer: true } && map.ParentFaction != Faction.OfPlayer) + __result = false; + } +} + +// Multifaction: QuestNode_Root_ReliquaryPilgrims has its own private map +// picker accepting any player map with a reliquary - neither GetMap owner +// filter applies. Re-pick among the generating faction's own maps. +[HarmonyPatch(typeof(QuestNode_Root_ReliquaryPilgrims), "GetMap")] +static class ReliquaryPilgrimsMapOwnerFilter +{ + static void Postfix(ref Map __result) + { + if (__result == null || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + if (!QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer) || + __result.ParentFaction is not { IsPlayer: true } || __result.ParentFaction == Faction.OfPlayer) + return; + + Find.Maps + .Where(m => m.IsPlayerHome && m.ParentFaction == Faction.OfPlayer && + QuestNode_Root_ReliquaryPilgrims.TryFindReliquaryWithRelic(m, out _, out _, out _)) + .TryRandomElement(out var rePicked); + __result = rePicked; + } +} + +// Same gap: QuestNode_Root_WorkSite picks its map with an inline predicate +// over IsPlayerHome maps. The predicate is a compiler-generated lambda - +// resolved by ordinal (like VNPE's Drain gizmo) and shape-checked so a game +// update degrades to a warning, not a crash. +[StaticConstructorOnStartup] +static class WorkSiteMapPickerFilter +{ + static WorkSiteMapPickerFilter() + { + try + { + var lambda = MpMethodUtil.GetLambda(typeof(QuestNode_Root_WorkSite), "RunInt", MethodType.Normal, null, 0); + if (lambda.ReturnType != typeof(bool) || + lambda.GetParameters() is not { Length: 1 } ps || ps[0].ParameterType != typeof(Map)) + { + Log.Warning("MP: WorkSite map-picker lambda shape unexpected - owner filter skipped"); + return; + } + + Multiplayer.harmony.Patch(lambda, + postfix: new HarmonyMethod(typeof(WorkSiteMapPickerFilter), nameof(PredicatePostfix))); + } + catch (Exception e) + { + Log.Warning($"MP: WorkSite map-picker lambda not found - owner filter skipped ({e.Message})"); + } + } + + static void PredicatePostfix(Map m, ref bool __result) + { + if (!__result || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + if (QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer) && + m.ParentFaction is { IsPlayer: true } && m.ParentFaction != Faction.OfPlayer) + __result = false; + } +} + +// The lambda filter narrows RunInt's candidates, so TestRunInt must agree or +// generation can proceed with no acceptable map left +[HarmonyPatch(typeof(QuestNode_Root_WorkSite), "TestRunInt")] +static class WorkSiteTestRunMatchesFilter +{ + static void Postfix(ref bool __result) + { + if (!__result || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || + !QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer)) + return; + + __result = Find.Maps.Any(m => + m.IsPlayerHome && m.ParentFaction == Faction.OfPlayer && QuestNode_Root_WorkSite.GetCandidates(m.Tile).Any()); + } +} + +// Delivery-side: when a quest's bound map is lost mid-quest, vanilla +// retargets to the FIRST player home map, faction-blind - a hostile mech +// cluster or monument copy lands on an uninvolved colony. Quest parts call +// this under the pushed owner context, so prefer the owner's maps. +[HarmonyPatch(typeof(Quest), nameof(Quest.TryFindNewSuitableMapParentForRetarget))] +static class RetargetPrefersOwnerMaps +{ + static void Postfix(ref MapParent __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || + !QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer)) + return; + + if (__result?.Map is { } m && m.ParentFaction == Faction.OfPlayer) + return; + + var owned = Find.Maps.FirstOrDefault(map => + map.IsPlayerHome && map.ParentFaction == Faction.OfPlayer)?.Parent; + if (owned != null) + __result = owned; + } +} + +// Caravan royals' bestowing-ceremony checks tick in the world tick +// (spectator), so the quest was stamped to the fallback owner and its +// quest-available letter - received under spectator - was dropped for every +// client. Push the royal's own faction around generation. +[HarmonyPatch(typeof(RoyalTitleUtility), nameof(RoyalTitleUtility.GenerateBestowingCeremonyQuest))] +static class BestowingCeremonyOwnerContext +{ + static void Prefix(Pawn pawn, ref bool __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || + !QuestFactionOwnership.IsOwnablePlayerFaction(pawn?.Faction) || + pawn.Faction == Faction.OfPlayer) + return; + + ((Map)null).PushFaction(pawn.Faction); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +// The same gap's second entry point: many C# quest roots (hospitality +// refugees, beggars, wanderer joins, DLC arrivals) pick their map via +// QuestGen_Get.GetMap, which never consults QuestNode_GetMap. Same owner +// rule: re-pick among the generating faction's own maps with vanilla's +// preference order; null makes the quest's map test fail, vanilla's own +// no-map behavior. +[HarmonyPatch(typeof(QuestGen_Get), nameof(QuestGen_Get.GetMap))] +static class QuestGenGetMapMatchesGeneratingFaction +{ + static void Postfix(ref Map __result, bool mustBeInfestable, int? preferMapWithMinFreeColonists, bool canBeSpace) + { + if (__result == null || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + if (!QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer) || + __result.ParentFaction is not { IsPlayer: true } || __result.ParentFaction == Faction.OfPlayer) + return; + + int minCount = preferMapWithMinFreeColonists ?? 1; + var ownMaps = Find.Maps.Where(m => + m.IsPlayerHome && m.ParentFaction == Faction.OfPlayer && + (canBeSpace || !m.Tile.LayerDef.isSpace) && + (!mustBeInfestable || InfestationCellFinder.TryFindCell(out _, m))).ToList(); + + if (!ownMaps.Where(m => m.mapPawns.FreeColonists.Count >= minCount).TryRandomElement(out var rePicked)) + ownMaps.Where(m => m.mapPawns.FreeColonists.Any()).TryRandomElement(out rePicked); + + __result = rePicked; + } +} diff --git a/Source/Client/Factions/WastepackAttribution.cs b/Source/Client/Factions/WastepackAttribution.cs new file mode 100644 index 000000000..693471fe0 --- /dev/null +++ b/Source/Client/Factions/WastepackAttribution.cs @@ -0,0 +1,327 @@ +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using Multiplayer.Client.Util; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Multifaction: wastepack goodwill/retaliation events carry no dumper. +// Vanilla queues { tile, amount } and applies via Faction.OfPlayer in the +// world tick - under the spectator context the goodwill hit lands on nobody +// and the retaliation quest's map pick has no owner in context (falls back to +// the lowest-loadID player). Mirror the pending queue with a dumper faction +// per event, resolved at enqueue time (ambient push, holder chain, per-thing +// stamp, or the synced-command faction), and re-run the apply grouped by +// (tile, dumper) under that faction's context. Unknown dumper: goodwill +// behaves as before (spectator no-op) and retaliation is dropped - a raid +// must never fall back to an arbitrary player. +public static class WastepackAttribution +{ + // Mirrors CompDissolutionEffect_Goodwill.pendingGoodwillEvents 1:1. + // Entries are faction loadIDs, -1 = unknown. + public static readonly List pendingDumpers = new(); + + // Set around enqueue paths whose call site knows the dumper but whose + // thing (if any) can't tell us (abandoned-map events have no thing). + public static int ambientDumper = -1; + + public static bool MpActive => Multiplayer.Client != null && Multiplayer.GameComp.multifaction; + + static bool Ownable(Faction f) => QuestFactionOwnership.IsOwnablePlayerFaction(f); + + public static int ResolveDumper(Thing pack) + { + if (ambientDumper != -1) + return ambientDumper; + + if (pack != null) + { + // Held packs: the holder chain names the dumper (transporter world + // object, caravan, carrying pawn - all still parented at the + // Notify_AbandonedAtTile/CompTick call sites). + for (var holder = pack.ParentHolder; holder != null; holder = holder.ParentHolder) + { + var f = holder switch + { + WorldObject wo => wo.Faction, + Pawn p => p.Faction, + Thing t => t.Faction, + _ => null + }; + if (Ownable(f)) + return f.loadID; + } + + if (Multiplayer.WorldComp.wastepackDumpers.TryGetValue(pack.thingIDNumber, out var stamped) && + Ownable(Find.FactionManager.GetById(stamped))) + return stamped; + } + + // Synced-command context (player-ordered dumps, jobs under a pushed + // faction) - the spectator is not ownable, so world-tick noise stays -1 + if (Ownable(Faction.OfPlayer)) + return Faction.OfPlayer.loadID; + + return -1; + } + + // Keeps the mirror aligned with the vanilla list even when an enqueue was + // gated off (pollution-ignored quests, unspawned packs) or a third party + // enqueued while our patches were bypassed: pad, then append per growth. + public static void MirrorAppend(int prevCount, Thing source) + { + var events = CompDissolutionEffect_Goodwill.pendingGoodwillEvents; + + while (pendingDumpers.Count < prevCount) + pendingDumpers.Add(-1); + + for (int i = prevCount; i < events.Count; i++) + pendingDumpers.Add(ResolveDumper(source)); + } + + // Vanilla WorldUpdate re-run grouped by (tile, dumper); apply wrapped in + // the dumper's faction context so every Faction.OfPlayer read - goodwill, + // IsPlayerTile, AnyPlayerHomeMap gates, GetMap's owner filter - resolves + // to the polluter. Body mirrors CompDissolutionEffect_Goodwill.WorldUpdate + // (1.6.4871); keep in lockstep with the decompile on game updates. + public static void RunAttributed() + { + var events = CompDissolutionEffect_Goodwill.pendingGoodwillEvents; + if (events.Count == 0) + { + pendingDumpers.Clear(); + return; + } + + while (pendingDumpers.Count < events.Count) + pendingDumpers.Add(-1); + + foreach (var group in events + .Select((e, i) => (e, dumperId: pendingDumpers[i])) + .GroupBy(x => (x.e.tile, x.dumperId))) + { + PlanetTile key = group.Key.tile; + var dumper = Find.FactionManager.GetById(group.Key.dumperId); + if (!Ownable(dumper)) + dumper = null; + + if (CompDissolutionEffect_Goodwill.TryGetAffectedSettlement(key, out var result, out var distance)) + { + if (dumper != null) + ((Map)null).PushFaction(dumper); + + try + { + int num = group.Sum(p => p.e.amount); + int num2 = UnityEngine.Mathf.Min( + -UnityEngine.Mathf.RoundToInt( + CompDissolutionEffect_Goodwill.GoodwillFactorOverDistanceCurvePerWastepack.Evaluate(distance) * num), -1); + HistoryEventDef historyEventDef = + ModsConfig.OdysseyActive && key.LayerDef.isSpace ? HistoryEventDefOf.OrbitalPollution + : result.Tile == key ? HistoryEventDefOf.PollutedBase + : distance > 8 ? HistoryEventDefOf.ToxicWasteDumping + : HistoryEventDefOf.PollutedNearbySite; + + if (result.Faction.IsPlayerGoodwillMinimum()) + Messages.Message("MessageAngeredPollutedCell".Translate(result.Faction.Name, historyEventDef.label), + result, MessageTypeDefOf.NegativeEvent); + + Faction.OfPlayer.TryAffectGoodwillWith(result.Faction, num2, canSendMessage: true, + canSendHostilityLetter: true, historyEventDef, result); + + if (!Current.Game.IsPlayerTile(key) && result.Faction.HostileTo(Faction.OfPlayer) && + Find.AnyPlayerHomeMap != null && + Rand.Chance(UnityEngine.Mathf.Clamp01((float)-num2 / 100f))) + { + if (dumper != null) + CompDissolutionEffect_Goodwill.TriggerRetaliationEvent(result.Faction); + else + Log.WarningOnce( + $"MP: dropped a wastepack retaliation with no attributable dumper (tile {key})", + Gen.HashCombineInt(key.GetHashCode(), 0x5EA7)); + } + } + finally + { + if (dumper != null) + FactionExtensions.PopFaction(); + } + } + + CompDissolutionEffect_Goodwill.tmpAvailableSettlements.Clear(); + } + + events.Clear(); + pendingDumpers.Clear(); + } +} + +[HarmonyPatch(typeof(CompDissolutionEffect_Goodwill), nameof(CompDissolutionEffect_Goodwill.WorldUpdate))] +static class WastepackWorldUpdateAttributed +{ + static bool Prefix() + { + if (!WastepackAttribution.MpActive) + { + // Vanilla will clear the event list; keep the mirror in step + WastepackAttribution.pendingDumpers.Clear(); + return true; + } + + WastepackAttribution.RunAttributed(); + return false; + } +} + +[HarmonyPatch(typeof(CompDissolutionEffect_Goodwill), nameof(CompDissolutionEffect_Goodwill.DoDissolutionEffectMap))] +static class WastepackMirrorMapEvent +{ + static void Prefix(ref int __state) => + __state = CompDissolutionEffect_Goodwill.pendingGoodwillEvents.Count; + + static void Postfix(CompDissolutionEffect_Goodwill __instance, int __state) + { + if (WastepackAttribution.MpActive) + WastepackAttribution.MirrorAppend(__state, __instance.parent); + } +} + +[HarmonyPatch(typeof(CompDissolutionEffect_Goodwill), nameof(CompDissolutionEffect_Goodwill.AddWorldDissolutionEvent))] +static class WastepackMirrorWorldEvent +{ + static void Prefix(ref int __state) => + __state = CompDissolutionEffect_Goodwill.pendingGoodwillEvents.Count; + + static void Postfix(int __state) + { + if (WastepackAttribution.MpActive) + WastepackAttribution.MirrorAppend(__state, null); + } +} + +// World dissolution of a held pack (caravan inventory tick, transporter +// "arrived and lost", abandon utilities): resolve from the holder chain while +// the pack is still parented +[HarmonyPatch(typeof(CompDissolution), nameof(CompDissolution.DissolveWorld))] +static class WastepackAmbientFromHolder +{ + static void Prefix(CompDissolution __instance, ref bool __state) + { + if (!WastepackAttribution.MpActive || WastepackAttribution.ambientDumper != -1) + return; + + WastepackAttribution.ambientDumper = WastepackAttribution.ResolveDumper(__instance.parent); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + WastepackAttribution.ambientDumper = -1; + } +} + +// Abandoned map: the synthetic world event has no thing - the dumper is the +// map's owner +[HarmonyPatch(typeof(PollutionInfo), nameof(PollutionInfo.MapRemoved))] +static class WastepackAmbientFromAbandonedMap +{ + static void Prefix(PollutionInfo __instance, ref bool __state) + { + if (!WastepackAttribution.MpActive) + return; + + if (__instance.map.ParentFaction is { IsPlayer: true } owner && + owner != Multiplayer.WorldComp.spectatorFaction) + { + WastepackAttribution.ambientDumper = owner.loadID; + __state = true; + } + } + + static void Finalizer(bool __state) + { + if (__state) + WastepackAttribution.ambientDumper = -1; + } +} + +// Family-4 stamps: a spawned pack rotting on a non-home map has no holder and +// no context, so the dumper is recorded ahead of time (scribed - rot takes +// days) + +// Pods/shuttles landing on an existing map: stamp cargo with the launcher +// before the arrival action scatters it +[HarmonyPatch(typeof(TravellingTransporters), nameof(TravellingTransporters.Arrived))] +static class WastepackStampOnArrival +{ + static void Prefix(TravellingTransporters __instance) + { + if (!WastepackAttribution.MpActive) + return; + if (__instance.Faction is not { IsPlayer: true } faction || + faction == Multiplayer.WorldComp.spectatorFaction) + return; + + foreach (var info in __instance.transporters) + foreach (var thing in info.innerContainer) + if (thing.def == ThingDefOf.Wastepack) + Multiplayer.WorldComp.wastepackDumpers[thing.thingIDNumber] = faction.loadID; + } +} + +// Mech charger/gestator waste: stamp at production with the building's owner +[HarmonyPatch(typeof(CompWasteProducer), nameof(CompWasteProducer.ProduceWaste))] +static class WastepackStampOnProduction +{ + static void Postfix(CompWasteProducer __instance) + { + if (!WastepackAttribution.MpActive) + return; + if (__instance.parent.Faction is not { IsPlayer: true } faction || + faction == Multiplayer.WorldComp.spectatorFaction) + return; + + var owner = __instance.parent.TryGetInnerInteractableThingOwner(); + if (owner == null) + return; + + foreach (var thing in owner) + if (thing.def == ThingDefOf.Wastepack && + !Multiplayer.WorldComp.wastepackDumpers.ContainsKey(thing.thingIDNumber)) + Multiplayer.WorldComp.wastepackDumpers[thing.thingIDNumber] = faction.loadID; + } +} + +// Catch-all: a pack spawning onto a non-home map under a synced command +// (pawn drops, trades) stamps from the acting faction. Existing stamps win. +[HarmonyPatch(typeof(Thing), nameof(Thing.SpawnSetup))] +static class WastepackStampOnSpawn +{ + static void Postfix(Thing __instance, Map map, bool respawningAfterLoad) + { + if (respawningAfterLoad || !WastepackAttribution.MpActive) + return; + if (__instance.def != ThingDefOf.Wastepack || map == null || map.IsPlayerHome) + return; + if (Multiplayer.WorldComp.wastepackDumpers.ContainsKey(__instance.thingIDNumber)) + return; + + var resolved = WastepackAttribution.ResolveDumper(__instance); + if (resolved != -1) + Multiplayer.WorldComp.wastepackDumpers[__instance.thingIDNumber] = resolved; + } +} + +[HarmonyPatch(typeof(Thing), nameof(Thing.Destroy))] +static class WastepackStampPrune +{ + static void Prefix(Thing __instance) + { + if (WastepackAttribution.MpActive && __instance.def == ThingDefOf.Wastepack) + Multiplayer.WorldComp.wastepackDumpers.Remove(__instance.thingIDNumber); + } +} diff --git a/Source/Client/Factions/WorldTickOwnerContext.cs b/Source/Client/Factions/WorldTickOwnerContext.cs new file mode 100644 index 000000000..a5d631fef --- /dev/null +++ b/Source/Client/Factions/WorldTickOwnerContext.cs @@ -0,0 +1,166 @@ +using System.Linq; +using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Multifaction: WorldObjectComp ticking runs under the spectator world tick, +// so core-side Faction.OfPlayer gates in those paths match nobody. Sites +// with the same root cause: +// - TimedForcedExit.ForceReform gathers `x.Faction == Faction.OfPlayer` pawns +// and reforms via ExitMapAndCreateCaravan(.., Faction.OfPlayer): under the +// spectator both match nothing and an expiring site map can close over the +// owner's pawns. +// - DefeatAllEnemiesQuestComp.CompTickInterval checks +// AnyHostileActiveThreatToPlayer and delivers rewards to AnyPlayerHomeMap: +// completion misdetects and the letter/rewards misfire for everyone. +// - CaravansBattlefield.CheckWonBattle uses AnyHostileActiveThreatToPlayer and +// FreeColonists.RandomElement for the victory tale/letter: under Spectator +// FreeColonists is empty on every client (ParentFaction is enemy/null, so the +// existing WorldObjectMethodPatches push cannot install a player owner). +// Each gets the deterministic owner of the site map pushed around the body. +static class WorldTickOwnerContext +{ + // Deterministic "whose map is this" for maps without a player parent + // faction: the lowest-loadID ownable player faction with a humanlike pawn + // spawned there (same site rule as StorytellerTargetsPatch, same selector + // shape as IdeoContextUtil.PrimaryPlayerFollower - identical on all + // clients). + public static Faction DeterministicMapOwner(Map map) + { + if (map == null) + return null; + + if (map.ParentFaction is { IsPlayer: true } parent && + QuestFactionOwnership.IsOwnablePlayerFaction(parent)) + return parent; + + return Find.FactionManager.AllFactionsListForReading + .Where(f => QuestFactionOwnership.IsOwnablePlayerFaction(f) && + map.mapPawns.SpawnedPawnsInFaction(f).Any(p => p.RaceProps.Humanlike)) + .OrderBy(f => f.loadID) + .FirstOrDefault(); + } + + public static bool PushOwnerOf(Map map) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return false; + + var owner = DeterministicMapOwner(map); + if (owner == null || owner == Faction.OfPlayer) + return false; + + ((Map)null).PushFaction(owner); + return true; + } +} + +[HarmonyPatch(typeof(TimedForcedExit), nameof(TimedForcedExit.ForceReform))] +static class TimedForcedExitOwnerContext +{ + static void Prefix(MapParent mapParent, ref bool __state) => + __state = WorldTickOwnerContext.PushOwnerOf(mapParent?.Map); + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +[HarmonyPatch(typeof(DefeatAllEnemiesQuestComp), nameof(DefeatAllEnemiesQuestComp.CompTickInterval))] +static class DefeatAllEnemiesQuestOwnerContext +{ + static void Prefix(DefeatAllEnemiesQuestComp __instance, ref bool __state) + { + if (__instance.Active && __instance.parent is MapParent { HasMap: true } mapParent) + __state = WorldTickOwnerContext.PushOwnerOf(mapParent.Map); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +// CaravansBattlefield.CheckWonBattle is private; ParentFaction is typically +// the ambushers (or null), so WorldObjectMethodPatches cannot push a player +// owner. Push the deterministic map owner so FreeColonists / victory letter +// see the caravan faction instead of Spectator. +[HarmonyPatch(typeof(CaravansBattlefield), "CheckWonBattle")] +static class CaravansBattlefieldWonBattleOwnerContext +{ + static void Prefix(CaravansBattlefield __instance, ref bool __state) + { + if (__instance.HasMap) + __state = WorldTickOwnerContext.PushOwnerOf(__instance.Map); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +// When a mission's stamped destination map +// is lost mid-flight, SendAway's private picker scans settlements for +// Faction.OfPlayer - under the spectator world tick that matches nobody and +// the faction-blind AnyPlayerHomeMap fallback unloads the mission's pawns and +// loot at an arbitrary player colony. Push the mission owner so vanilla's own +// scan starts matching; no body replication, no new scribed state. +[HarmonyPatch(typeof(ShipJob_WaitSendable), "SendAway")] +static class ShipJobWaitSendableOwnerContext +{ + static void Prefix(ShipJob_WaitSendable __instance, ref bool __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + var owner = ResolveOwner(__instance); + if (owner == null || owner == Faction.OfPlayer) + return; + + ((Map)null).PushFaction(owner); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } + + // Deterministic on every client: the quest table, the transporter's + // contents and DeterministicMapOwner are all synced state. The quest scan + // skips only Historical - never quest.hidden/dismissed like vanilla's + // gizmo scan, because dismissal is a per-client UI action. + static Faction ResolveOwner(ShipJob_WaitSendable job) + { + var shipThing = job.transportShip?.shipThing; + if (shipThing == null) + return null; + + var quests = Find.QuestManager.QuestsListForReading; + for (int i = 0; i < quests.Count; i++) + { + if (quests[i].Historical) + continue; + if (quests[i].QuestLookTargets.Contains(shipThing) && + QuestFactionOwnership.GetOwner(quests[i]) is { } questOwner) + return questOwner; + } + + if (job.transportShip.TransporterComp?.innerContainer is { } held) + foreach (var thing in held) + if (thing is Pawn { RaceProps.Humanlike: true } pawn && + QuestFactionOwnership.IsOwnablePlayerFaction(pawn.Faction)) + return pawn.Faction; + + return WorldTickOwnerContext.DeterministicMapOwner(shipThing.Map); + } +} diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index 5ea4b1760..ce26b791c 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -81,8 +81,18 @@ public static class Multiplayer public static bool arbiterInstance; public static bool loadingErrors; + public static readonly List loadingErrorsList = new(); public static Stopwatch harmonyWatch = new(); + // Central sink for init-time failures; keeps the message so the UI can + // show what broke instead of just a warning marker + public static void LoadingError(string message) + { + Log.Error(message); + loadingErrors = true; + loadingErrorsList.Add(message); + } + public static ModContentPack modContentPack; public static void InitMultiplayer(ModContentPack content) diff --git a/Source/Client/Multiplayer.csproj b/Source/Client/Multiplayer.csproj index 26cdeb7b1..9935e5553 100644 --- a/Source/Client/Multiplayer.csproj +++ b/Source/Client/Multiplayer.csproj @@ -25,7 +25,7 @@ - + diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 49519f9b9..df8730880 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -256,8 +256,7 @@ void LogError(string str) if (categoryNeedsAnnouncement) { Log.Message($"Multiplayer :: {category}"); } - Log.Error(str); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError(str); } var harmony = Multiplayer.harmony; diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index c123d9c2a..b8de17190 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -76,9 +76,23 @@ public static bool CompareToLocal(RemoteData remote) { return remote.remoteRwVersion == VersionControl.CurrentVersionString && + // Letter/message text is Translate()d at fire time and scribed - + // mismatched game languages scribe divergent text + remote.remoteLanguage == LanguageDatabase.activeLanguage.folderName && remote.CompareMods(activeModsSnapshot) == ModListDiff.None && remote.remoteFiles.DictsEqual(modFilesSnapshot) && - (!remote.hasConfigs || remote.remoteModConfigs.EqualAsSets(SyncConfigs.GetSyncableConfigContents(remote.RemoteModIds.ToList()))); + (!remote.hasConfigs || ConfigsEquivalent(remote.remoteModConfigs, + SyncConfigs.GetSyncableConfigContents(remote.RemoteModIds.ToList()))); + } + + // Don't fail the check over CRLF vs LF: mods writing settings with + // platform-default newlines produce different bytes for identical configs + private static bool ConfigsEquivalent(IEnumerable a, IEnumerable b) + { + static ModConfig Normalize(ModConfig c) => + c with { Contents = c.Contents?.Replace("\r\n", "\n").Replace("\r", "\n") }; + + return a.Select(Normalize).EqualAsSets(b.Select(Normalize)); } internal static void TakeModDataSnapshot() @@ -148,6 +162,7 @@ public class RemoteData { public string remoteRwVersion; public string remoteMpVersion; + public string remoteLanguage; public List remoteMods = new(); public ModFileDict remoteFiles = new(); @@ -176,6 +191,7 @@ public static RemoteData FromNet(ServerJoinDataPacket packet) { remoteRwVersion = packet.rwVersion, remoteMpVersion = packet.mpVersion, + remoteLanguage = packet.language, hasConfigs = packet.configsIncluded, }; diff --git a/Source/Client/Networking/State/ClientBaseState.cs b/Source/Client/Networking/State/ClientBaseState.cs index 0eb6bc1ba..176827ab3 100644 --- a/Source/Client/Networking/State/ClientBaseState.cs +++ b/Source/Client/Networking/State/ClientBaseState.cs @@ -1,6 +1,10 @@ +using System; +using System.Linq; using Multiplayer.Client.Networking; using Multiplayer.Common; using Multiplayer.Common.Networking.Packet; +using RimWorld; +using Verse; namespace Multiplayer.Client; @@ -8,6 +12,95 @@ public abstract class ClientBaseState(ConnectionBase connection) : MpConnectionS { protected MultiplayerSession Session => Multiplayer.session; + // PlayerList, chat and notifications live here rather than in + // ClientPlayingState: the server flips a joiner to ServerPlaying as soon + // as the world data is queued (ServerLoadingState.RunState), so these + // broadcasts can legally reach a client still in ClientLoading - and + // Steam P2P delivers reliable packets out of order besides (observed + // Server_PlayerList ahead of Server_WorldDataStart, 2026-07-28). An + // unhandled reliable packet kills the session, so the loading state must + // handle them. All three touch session/UI state only; the full List sent + // after the world data reconciles anything applied during the window. + [TypedPacketHandler] + public void HandlePlayerList(ServerPlayerListPacket packet) + { + if (packet.action == PlayerListAction.Add) + { + foreach (var info in packet.players) + { + if (!Multiplayer.session.players.Any(p => p.id == info.id || p.username == info.username)) + { + ServerLog.Log($"PlayerList: Adding player {info.id}:{info.username}"); + Multiplayer.session.players.Add(PlayerInfo.FromNet(info)); + } + else + { + ServerLog.Error($"PlayerList: Adding player {info.id}:{info.username} - player already exists"); + } + } + } + else if (packet.action == PlayerListAction.Remove) + { + ServerLog.Log($"PlayerList: Removing player with id {packet.playerId}"); + var matches = Multiplayer.session.players.RemoveAll(p => p.id == packet.playerId); + if (matches > 1) + { + ServerLog.Error($"PlayerList: Removing player with id {packet.playerId} -- occurred {matches} times. This should not happen"); + } + } + else if (packet.action == PlayerListAction.List) + { + ServerLog.Log($"PlayerList: Received player list with {packet.players.Length} entries"); + + Multiplayer.session.players.Clear(); + foreach (var info in packet.players) + { + ServerLog.Log($"PlayerList: Adding player from list {info.id}:{info.username}"); + Multiplayer.session.players.Add(PlayerInfo.FromNet(info)); + } + } + else if (packet.action == PlayerListAction.Latencies) + { + foreach (var latency in packet.latencies) + { + var player = Multiplayer.session.GetPlayerInfo(latency.playerId); + if (player == null) + { + ServerLog.Log($"PlayerList: Received latency info for unknown player with id {latency.playerId}"); + continue; + } + player.latency = latency.latency; + player.ticksBehind = latency.ticksBehind; + player.simulating = latency.simulating; + player.frameTime = latency.frameTime; + } + } + else if (packet.action == PlayerListAction.Status) + { + var player = Multiplayer.session.GetPlayerInfo(packet.playerId); + if (player == null) + { + ServerLog.Log($"PlayerList: Received player status ({packet.status}) for unknown player with id {packet.playerId}"); + } + else + { + player.status = packet.status; + } + } + } + + [TypedPacketHandler] + public void HandleChat(ServerChatPacket packet) => Multiplayer.session.AddMsg(packet.msg, rawMessage: packet.rawMessage); + + [TypedPacketHandler] + public void HandleNotification(ServerNotificationPacket packet) + { + var namedArgs = Array.ConvertAll(packet.args, s => (NamedArgument)s); + var msg = packet.key.Translate(namedArgs); + Messages.Message(msg, MessageTypeDefOf.SilentInput, false); + ServerLog.Log($"Notification: {msg} ({packet.key}, {packet.args.Join(", ")})"); + } + [TypedPacketHandler] public void HandleKeepAlive(ServerKeepAlivePacket packet) { diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index 0b74bab3b..e8b3f70cb 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -55,6 +55,7 @@ public void HandleInitDataRequest(ServerInitDataRequestPacket packet) => public static ClientInitDataPacket CreateInitDataPacket(bool includeConfigs) => new() { rwVersion = VersionControl.CurrentVersionString, + language = LanguageDatabase.activeLanguage.folderName, 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, diff --git a/Source/Client/Networking/State/ClientPlayingState.cs b/Source/Client/Networking/State/ClientPlayingState.cs index 6a96635a5..fdfd4dd1d 100644 --- a/Source/Client/Networking/State/ClientPlayingState.cs +++ b/Source/Client/Networking/State/ClientPlayingState.cs @@ -23,77 +23,6 @@ public void HandleCommand(ServerCommandPacket packet) Multiplayer.session.ProcessTimeControl(); } - [TypedPacketHandler] - public void HandlePlayerList(ServerPlayerListPacket packet) - { - if (packet.action == PlayerListAction.Add) - { - foreach (var info in packet.players) - { - if (!Multiplayer.session.players.Any(p => p.id == info.id || p.username == info.username)) - { - ServerLog.Log($"PlayerList: Adding player {info.id}:{info.username}"); - Multiplayer.session.players.Add(PlayerInfo.FromNet(info)); - } - else - { - ServerLog.Error($"PlayerList: Adding player {info.id}:{info.username} - player already exists"); - } - } - } - else if (packet.action == PlayerListAction.Remove) - { - ServerLog.Log($"PlayerList: Removing player with id {packet.playerId}"); - var matches = Multiplayer.session.players.RemoveAll(p => p.id == packet.playerId); - if (matches > 1) - { - ServerLog.Error($"PlayerList: Removing player with id {packet.playerId} -- occurred {matches} times. This should not happen"); - } - } - else if (packet.action == PlayerListAction.List) - { - ServerLog.Log($"PlayerList: Received player list with {packet.players.Length} entries"); - - Multiplayer.session.players.Clear(); - foreach (var info in packet.players) - { - ServerLog.Log($"PlayerList: Adding player from list {info.id}:{info.username}"); - Multiplayer.session.players.Add(PlayerInfo.FromNet(info)); - } - } - else if (packet.action == PlayerListAction.Latencies) - { - foreach (var latency in packet.latencies) - { - var player = Multiplayer.session.GetPlayerInfo(latency.playerId); - if (player == null) - { - ServerLog.Log($"PlayerList: Received latency info for unknown player with id {latency.playerId}"); - continue; - } - player.latency = latency.latency; - player.ticksBehind = latency.ticksBehind; - player.simulating = latency.simulating; - player.frameTime = latency.frameTime; - } - } - else if (packet.action == PlayerListAction.Status) - { - var player = Multiplayer.session.GetPlayerInfo(packet.playerId); - if (player == null) - { - ServerLog.Log($"PlayerList: Received player status ({packet.status}) for unknown player with id {packet.playerId}"); - } - else - { - player.status = packet.status; - } - } - } - - [TypedPacketHandler] - public void HandleChat(ServerChatPacket packet) => Multiplayer.session.AddMsg(packet.msg, rawMessage: packet.rawMessage); - [TypedPacketHandler] public void HandleCursor(ServerCursorPacket packet) { @@ -157,15 +86,6 @@ public void HandleMapResponse(ByteReader data) }); } - [TypedPacketHandler] - public void HandleNotification(ServerNotificationPacket packet) - { - var namedArgs = Array.ConvertAll(packet.args, s => (NamedArgument)s); - var msg = packet.key.Translate(namedArgs); - Messages.Message(msg, MessageTypeDefOf.SilentInput, false); - ServerLog.Log($"Notification: {msg} ({packet.key}, {packet.args.Join(", ")})"); - } - [TypedPacketHandler] public void HandleDesyncCheck(ServerSyncInfoPacket packet) => Multiplayer.game?.sync.AddClientOpinionAndCheckDesync(ClientSyncOpinion.FromNet(packet.SyncOpinion)); diff --git a/Source/Client/Patches/AlertThrashDiag.cs b/Source/Client/Patches/AlertThrashDiag.cs new file mode 100644 index 000000000..3615efce7 --- /dev/null +++ b/Source/Client/Patches/AlertThrashDiag.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; +using Multiplayer.Client.Util; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.Patches +{ + // Diagnostic only. Field reports describe alert sounds re-firing ("low + // food keeps pinging") in multiplayer sessions, which requires an alert + // to leave and re-enter the active list - Notify_Started rings the bell + // only on re-entry. No mechanism for the flip has been confirmed from + // the code, so instead of a speculative fix this logs a rate-limited + // line naming the alert and the state it flipped under, turning the next + // session's Player.log into the evidence. Log-only, UI-side, no sim + // state touched. + [HarmonyPatch(typeof(AlertsReadout), nameof(AlertsReadout.CheckAddOrRemoveAlert))] + static class AlertThrashDiag + { + // Real-time seconds of recent activations, per alert type + private static readonly Dictionary> activations = new(); + private static readonly Dictionary lastReport = new(); + + private const int ThrashActivationCount = 3; + private const float ThrashWindowSeconds = 60f; + private const float ReportIntervalSeconds = 300f; + + static void Prefix(AlertsReadout __instance, Alert alert, ref bool __state) + => __state = alert != null && __instance.activeAlerts.Contains(alert); + + static void Postfix(AlertsReadout __instance, Alert alert, bool __state) + { + if (Multiplayer.Client == null || alert == null) return; + + try + { + // Only newly-(re)activated alerts ring the bell + if (__state || !__instance.activeAlerts.Contains(alert)) return; + + var key = alert.GetType().Name; + var now = Time.realtimeSinceStartup; + + if (!activations.TryGetValue(key, out var times)) + activations[key] = times = new Queue(); + + times.Enqueue(now); + while (times.Count > 0 && times.Peek() < now - ThrashWindowSeconds) + times.Dequeue(); + + if (times.Count < ThrashActivationCount) return; + if (lastReport.TryGetValue(key, out var last) && now - last < ReportIntervalSeconds) return; + lastReport[key] = now; + + MpLog.Log( + $"Alert thrash: {key} activated {times.Count}x in {ThrashWindowSeconds}s. " + + $"ambientTicks={Find.TickManager.TicksGame}, worldTicks={Multiplayer.AsyncWorldTime?.worldTicks}, " + + $"viewedMap={Find.CurrentMap?.uniqueID}, " + + $"mapSpeeds=[{MapStates()}]{LowFoodInputs(alert)}"); + } + catch (Exception e) + { + Log.WarningOnce($"Alert thrash diagnostic failed: {e.Message}", 758231); + } + } + + private static string MapStates() => + Find.Maps.Select(m => m.AsyncTime()) + .Where(c => c != null) + .Join(c => $"{c.map.uniqueID}:{c.DesiredTimeSpeed}@{c.mapTicks}", ", "); + + private static string LowFoodInputs(Alert alert) + { + if (alert is not Alert_LowFood) return ""; + + var perMap = Find.Maps + .Where(m => m.IsPlayerHome && m.mapPawns.AnyColonistSpawned) + .Join(m => $"{m.uniqueID}:food={m.resourceCounter.TotalHumanEdibleNutrition:F1}/colonists={m.mapPawns.FreeColonistsSpawnedCount}", ", "); + + return $", lowFood=[{perMap}]"; + } + } +} diff --git a/Source/Client/Patches/Anomaly.cs b/Source/Client/Patches/Anomaly.cs index aa6a4d553..112323b54 100644 --- a/Source/Client/Patches/Anomaly.cs +++ b/Source/Client/Patches/Anomaly.cs @@ -1,9 +1,13 @@ using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Reflection.Emit; using HarmonyLib; using RimWorld; +using UnityEngine; using Verse; +using Verse.AI; +using Verse.AI.Group; namespace Multiplayer.Client.Patches { @@ -36,4 +40,53 @@ static CellRect EmptyCellRect() static void Prefix() => Rand.PushState(Find.TickManager.TicksAbs); static void Postfix() => Rand.PopState(); } + + // #959: UnnaturalCorpse gates simulation decisions on the local camera, + // forking corpse state between clients. Treat it as always unobserved in MP. + [HarmonyPatch(typeof(AnomalyUtility), nameof(AnomalyUtility.IsValidUnseenCell))] + static class AnomalyUtility_IsValidUnseenCell + { + // An empty rect makes the internal camera/CurrentMap check short-circuit + // deterministically; covers the container-breakout call in UnnaturalCorpseTracker + static void Prefix(ref CellRect view) + { + if (Multiplayer.Client != null) + view = CellRect.Empty; + } + } + + // #855: vanilla mutates the inventory container it's enumerating, aborting the + // toil so psychic ritual outcomes never fire. Replicated with a snapshot. + [HarmonyPatch(typeof(PsychicRitualToil_InvokeHorax), nameof(PsychicRitualToil_InvokeHorax.HoldRequiredOfferings))] + static class FixHoldRequiredOfferingsCollectionModified + { + static bool Prefix(PsychicRitualToil_InvokeHorax __instance, PsychicRitual psychicRitual) + { + if (__instance.requiredOffering == null) + return false; + + foreach (var pawn in psychicRitual.assignments.AssignedPawns(__instance.invokerRole)) + foreach (var thing in pawn.inventory.GetDirectlyHeldThings().ToList()) + if (__instance.requiredOffering.filter.Allows(thing)) + pawn.inventory.innerContainer.TryTransferToContainer(thing, pawn.carryTracker.innerContainer, + Mathf.CeilToInt(__instance.requiredOffering.GetBaseCount())); + + return false; + } + } + + [HarmonyPatch(typeof(UnnaturalCorpse), nameof(UnnaturalCorpse.IsOutsideView))] + static class UnnaturalCorpse_IsOutsideView + { + // Keep the (deterministic) reservation check, skip the camera checks + static bool Prefix(UnnaturalCorpse __instance, ref bool __result) + { + if (Multiplayer.Client == null) + return true; + + __result = !(__instance.SpawnedOrAnyParentSpawned && + __instance.MapHeld.reservationManager.IsReservedByAnyoneOf(__instance, Faction.OfPlayer)); + return false; + } + } } \ No newline at end of file diff --git a/Source/Client/Patches/FactionResidueGuard.cs b/Source/Client/Patches/FactionResidueGuard.cs new file mode 100644 index 000000000..4c96e246d --- /dev/null +++ b/Source/Client/Patches/FactionResidueGuard.cs @@ -0,0 +1,86 @@ +using Multiplayer.Client.Util; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.Patches +{ + // Heal-and-tell for faction data residue. Sim brackets (map tick, world + // tick, command execution) swap the live world/map managers between + // factions' copies; any strand (an exception that skipped a pop, a + // restore that no-op'd) leaves foreign faction data installed, and + // between frames NOTHING re-installs the local player's - alerts read a + // foreign resourceCounter (low-food/medicine flips and re-rings), zones/ + // areas/designations render from the wrong copies, and a paused map + // stays wrong until its next tick ("clears on unpause"). The bracket + // depth guards close the strand sources we know; this closes the class: + // after the tick/command loop, before render and UI read anything, + // re-point every map and the world at the local faction's data and name + // what was wrong. Render/UI-only by construction - every sim path + // installs its own context on entry, so healed state is never a + // simulation input. + public static class FactionResidueGuard + { + private static int healedFrames; + private static float lastReport; + private const float ReportIntervalSeconds = 60f; + + public static void HealAfterTicks() + { + if (Multiplayer.Client == null || Multiplayer.game == null || Multiplayer.reloading) return; + if (!Multiplayer.GameComp.multifaction) return; + if (Current.ProgramState != ProgramState.Playing) return; + + var real = Multiplayer.RealPlayerFaction; + if (real == null) return; + + string healed = null; + + // Outside any open bracket the ambient player faction must be the + // local player's; a non-empty stack after the tick loop means + // something stranded - the depth guards report that separately + if (FactionContext.stack.Count == 0 && Faction.OfPlayer != real) + { + healed = $"OfPlayer was {Faction.OfPlayer?.loadID.ToString() ?? "null"}"; + FactionContext.Set(real); + } + + var worldComp = Multiplayer.WorldComp; + if (worldComp.factionData.TryGetValue(real.loadID, out var worldData) && + !ReferenceEquals(Find.ResearchManager, worldData.researchManager)) + { + healed = "world data was foreign"; + worldComp.SetFaction(real); + } + + // Every map, not just the viewed one: alerts and the colonist bar + // read all maps' managers each frame + foreach (var map in Find.Maps) + { + var comp = map.MpComp(); + if (comp == null || !comp.factionData.TryGetValue(real.loadID, out var ownData)) continue; + if (ReferenceEquals(map.resourceCounter, ownData.resourceCounter)) continue; + + var installedFactionId = -1; + foreach (var kv in comp.factionData) + if (ReferenceEquals(map.resourceCounter, kv.Value.resourceCounter)) + installedFactionId = kv.Key; + + comp.SetFaction(real); + healed = $"map {map.uniqueID} had faction {installedFactionId}'s data"; + } + + if (healed == null) return; + + healedFrames++; + var now = Time.realtimeSinceStartup; + if (now - lastReport < ReportIntervalSeconds) return; + lastReport = now; + + MpLog.Warn( + $"Faction residue healed: {healed} -> local {real.loadID} (OfPlayer {Faction.OfPlayer?.loadID}); " + + $"{healedFrames} healed frames since last report"); + healedFrames = 0; + } + } +} diff --git a/Source/Client/Patches/Feedback.cs b/Source/Client/Patches/Feedback.cs index bf6183b18..c50d49d1d 100644 --- a/Source/Client/Patches/Feedback.cs +++ b/Source/Client/Patches/Feedback.cs @@ -242,12 +242,24 @@ static class DraftedMove_GotoFeedbackPatch static IEnumerable Transpiler(IEnumerable instructions) { + var endCurrentJobCalls = 0; + foreach (var inst in instructions) { if (inst.Calls(tryTakeOrderedJob)) inst.operand = ((Delegate)CustomTryTakeOrderedJob).Method; - else if (inst.Calls(endCurrentJob)) inst.operand = ((Delegate)CustomEndCurrentJob).Method; + if (inst.Calls(endCurrentJob)) + { + inst.operand = ((Delegate)SyncedEndGotoJob).Method; + endCurrentJobCalls++; + } yield return inst; } + + // #849: the EndCurrentJob call (goto onto the pawn's own cell) stopped + // the pawn on the clicking client only; fail loud if the shape changes + if (endCurrentJobCalls != 1) + Multiplayer.LoadingError( + $"DraftedMove_GotoFeedbackPatch: expected 1 EndCurrentJob call in PawnGotoAction, found {endCurrentJobCalls}"); } [SyncMethod(exposeParameters = [1], context = SyncContext.QueueOrder_Down)] @@ -259,14 +271,14 @@ static bool CustomTryTakeOrderedJob(Pawn_JobTracker self, Job job, JobTag? tag = 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) + static void SyncedEndGotoJob(Pawn_JobTracker self, JobCondition condition, bool startNewJob, bool canReturnToPool) { + // The command lands ticks after the click - only end the job if it's + // still the goto this order was aimed at + if (Multiplayer.Client != null && self.curJob?.def != JobDefOf.Goto) + return; + self.EndCurrentJob(condition, startNewJob, canReturnToPool); } } diff --git a/Source/Client/Patches/FrameCacheDeterminism.cs b/Source/Client/Patches/FrameCacheDeterminism.cs new file mode 100644 index 000000000..18e23a93f --- /dev/null +++ b/Source/Client/Patches/FrameCacheDeterminism.cs @@ -0,0 +1,63 @@ +using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using Verse; + +namespace Multiplayer.Client.Patches; + +// PlayerItemAccessibilityUtility caches its accessible-things scan keyed on +// (tile, RealTime.frameCount). Frame counts are per-client machine state: +// whether a call rescans or reuses depends on the local framerate, and the +// scan consumes synced RNG (GenMath.RoundRandom on pawn leather amounts), so +// per-client cache collapse diverges the rand stream. The sim callers are +// quest generation: QuestNode_TradeRequest_GetRequestedThing's validator +// calls PossiblyAccessible + PlayerCanMake for EVERY def in +// ThingSetMakerUtility.allGeneratableItems (hundreds, 1-3 scans each), and +// QuestNode_Root_Beggars (5 defs) - and TradeRequest's TestRun runs during +// every random-quest selection (NaturalRandomQuestChooser evaluates all +// candidate roots), not just when it wins. +// +// The old fix forced a full rescan on every call. That made the draw count +// deterministic but turned each quest-fire tick into 300-700 full +// every-item-on-every-map scans: a multi-second freeze on every client at +// every random quest arrival. The requirement was never "always +// rescan", only "every client rescans the same number of times" - so in sim +// context, repeat calls collapse per (tile, TickPatch.Timer), identical on +// every client in every sim context: the first call each session-tick +// rescans (also flushing any content a UI call computed between ticks; sim +// ticks are atomic on the main thread, so UI calls cannot interleave +// mid-tick), repeats within the tick reuse it. Outside the sim, vanilla +// behavior is untouched. +[HarmonyPatch(typeof(PlayerItemAccessibilityUtility), nameof(PlayerItemAccessibilityUtility.CacheAccessibleThings))] +static class ItemAccessibilityCacheInvalidation +{ + private static (PlanetTile tile, int timer)? lastSimScan; + + static void Prefix(PlanetTile nearTile) + { + if (Multiplayer.Client == null) + return; + if (!Multiplayer.Ticking && !Multiplayer.ExecutingCmds) + return; + + if (lastSimScan is { } last && last.tile == nearTile && last.timer == TickPatch.Timer) + { + // Same tile, same session tick: force vanilla's guard to pass so + // the content computed earlier this tick is reused + PlayerItemAccessibilityUtility.cachedAccessibleThingsForTile = nearTile; + PlayerItemAccessibilityUtility.cachedAccessibleThingsForFrame = RealTime.frameCount; + } + else + { + // Frame key, not the tile: PlanetTile.Invalid is a legitimate + // nearTile for map-less callers and would re-match; frameCount is + // never -1 + PlayerItemAccessibilityUtility.cachedAccessibleThingsForFrame = -1; + lastSimScan = (nearTile, TickPatch.Timer); + } + } + + // A forced reuse must never serve content from before a reload - the + // cached Thing refs die at SaveAndReload. Called from ClearAllPatch. + public static void Reset() => lastSimScan = null; +} diff --git a/Source/Client/Patches/IdeoContext.cs b/Source/Client/Patches/IdeoContext.cs new file mode 100644 index 000000000..17ed6c380 --- /dev/null +++ b/Source/Client/Patches/IdeoContext.cs @@ -0,0 +1,106 @@ +using System.Linq; +using HarmonyLib; +using Multiplayer.Client.Factions; +using RimWorld; +using Verse; + +namespace Multiplayer.Client.Patches; + +// Multifaction: ideo and age ticking run in the world tick, whose context is +// the spectator faction - so every vanilla `Faction.OfPlayer`-gated feature +// in those paths silently never fires for anyone: date obligations +// (festivals/celebrations), the small-ideo ObligationsActive fallback, role +// activity letters, funeral obligations for caravan/cross-map deaths, and +// growth-moment letters for caravan children (auto-rolled instead). Push the +// right player faction's context around each entry point; letters then also +// route to that faction instead of being dropped for everyone. +static class IdeoContextUtil +{ + // Deterministic "whose ideo is this": the lowest-loadID ownable player + // faction following it (identical on all clients) + public static Faction PrimaryPlayerFollower(Ideo ideo) + { + if (ideo == null) + return null; + + return Find.FactionManager.AllFactionsListForReading + .Where(f => QuestFactionOwnership.IsOwnablePlayerFaction(f) && f.ideos != null && f.ideos.Has(ideo)) + .OrderBy(f => f.loadID) + .FirstOrDefault(); + } + + public static bool PushIfOwnable(Faction faction) + { + if (!QuestFactionOwnership.IsOwnablePlayerFaction(faction) || faction == Faction.OfPlayer) + return false; + + ((Map)null).PushFaction(faction); + return true; + } +} + +[HarmonyPatch(typeof(Ideo), nameof(Ideo.IdeoTick))] +static class IdeoTickContextPatch +{ + static void Prefix(Ideo __instance, ref bool __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + __state = IdeoContextUtil.PushIfOwnable(IdeoContextUtil.PrimaryPlayerFollower(__instance)); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +// Death/corpse-destroyed obligation triggers gate on the dead pawn's home +// faction being Faction.OfPlayer - push it so caravan and cross-map deaths +// still produce funerals +[HarmonyPatch] +static class IdeoMemberNotifyContextPatch +{ + static System.Collections.Generic.IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(Ideo), nameof(Ideo.Notify_MemberDied)); + yield return AccessTools.Method(typeof(Ideo), nameof(Ideo.Notify_MemberCorpseDestroyed)); + } + + static void Prefix(Pawn member, ref bool __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || member == null) + return; + + __state = IdeoContextUtil.PushIfOwnable(member.HomeFaction); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +// Growth moments: a caravan child's birthday ticks under the spectator, so +// the letter branch's `pawn.Faction != Faction.OfPlayer` check fails and the +// trait/passions are rolled silently with no player choice +[HarmonyPatch(typeof(Pawn_AgeTracker), "BirthdayBiological")] +static class BirthdayBiologicalContextPatch +{ + static void Prefix(Pawn_AgeTracker __instance, ref bool __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + __state = IdeoContextUtil.PushIfOwnable(__instance.pawn.Faction); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} diff --git a/Source/Client/Patches/MapSetup.cs b/Source/Client/Patches/MapSetup.cs index f83e0f249..78bc0e2bd 100644 --- a/Source/Client/Patches/MapSetup.cs +++ b/Source/Client/Patches/MapSetup.cs @@ -29,6 +29,17 @@ public static void SetupMap(Map map, bool usingMapTimeFromSingleplayer = false) var async = CreateAsyncTimeCompForMap(map, usingMapTimeFromSingleplayer); Multiplayer.game.asyncTimeComps.Add(async); + // Ask the world, not the global TickManager: the global is viewer-dependent + // and this value is scribed onto the new map. Not on the singleplayer- + // conversion path (no client exists there; the world would read Paused and + // stomp the singleplayer speed). Runs after the comp is registered: the world + // getter walks every map's comp, and reading it while this map is comp-less + // is what let an exception here abort SetupMap and leave the map comp-less + // forever. The fresh comp sits at Paused, which the running-map filter + // ignores, so the value is unchanged. + if (!usingMapTimeFromSingleplayer && !Multiplayer.GameComp.asyncTime) + async.DesiredTimeSpeed = Multiplayer.AsyncWorldTime?.DesiredTimeSpeed ?? Find.TickManager.CurTimeSpeed; + // Store all current managers for Faction.OfPlayer InitFactionDataFromMap(map, Faction.OfPlayer); @@ -68,9 +79,8 @@ private static AsyncTimeComp CreateAsyncTimeCompForMap(Map map, bool usingMapTim startingTimeSpeed = TimeSpeed.Paused; } - if (!Multiplayer.GameComp.asyncTime) - startingTimeSpeed = Find.TickManager.CurTimeSpeed; - + // The non-async world-speed override happens in SetupMap AFTER this comp is + // registered - see the comment there. var asyncTimeCompForMap = new AsyncTimeComp(map, gameStartAbsTick) { mapTicks = startingMapTicks, diff --git a/Source/Client/Patches/Patches.cs b/Source/Client/Patches/Patches.cs index 344833379..49c35b067 100644 --- a/Source/Client/Patches/Patches.cs +++ b/Source/Client/Patches/Patches.cs @@ -9,6 +9,7 @@ using System.Reflection.Emit; using System.Text.RegularExpressions; using System.Xml.Linq; +using Multiplayer.Client.Factions; using Multiplayer.Client.Patches; using UnityEngine; using Verse; @@ -410,6 +411,16 @@ static bool Prefix(QuestPart_Choice.Choice ___localChoice) int index = part.choices.IndexOf(___localChoice); if (index >= 0) { + // Multifaction: only the owning faction claims its rewards + if (Multiplayer.GameComp.multifaction && + QuestFactionOwnership.GetOwner(part.quest) is { } owner && + owner != Multiplayer.RealPlayerFaction) + { + Messages.Message($"Only {owner.Name} can choose this quest's reward.", + MessageTypeDefOf.RejectInput, historical: false); + return false; + } + Choose(part, index); return false; } @@ -423,7 +434,24 @@ static bool Prefix(QuestPart_Choice.Choice ___localChoice) // Registered in SyncMethods.cs internal static void Choose(QuestPart_Choice part, int index) { - part.Choose(part.choices[index]); + // The synced command executes in the clicking player's faction context; + // reward actions must resolve against the quest owner's instead + var owner = Multiplayer.Client != null && Multiplayer.GameComp.multifaction + ? QuestFactionOwnership.GetOwner(part.quest) + : null; + + if (owner != null) + ((Map)null).PushFaction(owner); + + try + { + part.Choose(part.choices[index]); + } + finally + { + if (owner != null) + FactionExtensions.PopFaction(); + } } } diff --git a/Source/Client/Patches/SustainerClockPatches.cs b/Source/Client/Patches/SustainerClockPatches.cs new file mode 100644 index 000000000..b24a4c1c2 --- /dev/null +++ b/Source/Client/Patches/SustainerClockPatches.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using HarmonyLib; +using Multiplayer.Client.Util; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client.Patches +{ + // Sustainers with PerTick/PerTickRare maintenance compare an ambient tick + // against lastMaintainTick, but under per-map clocks the stamp and the + // check can come from different clocks: Maintain() runs under whatever + // context the maintainer had (often a map's tick loop), while + // SustainerUpdate runs under the sound bracket's clock (owner map, or the + // world clock for map-less sustainers). A stamp clock behind the check + // clock reads as "unmaintained" and ends the sustainer even though its + // owner maintained it microseconds ago - and owners respawn ended + // sustainers, so the sound restarts every frame. + // + // Fix: before the vanilla staleness check runs, if the sustainer LOOKS + // tick-stale but was really maintained within the grace window (measured + // in realtime, which no clock swap can touch), re-stamp it under the + // clock the check will use. Truly abandoned sustainers still die, at most + // GraceSeconds late - audio-only, imperceptible. PerFrame maintenance is + // untouched (frame counts are global). Vanilla paused behavior is + // untouched (a frozen clock never reads as stale). + public static class SustainerRealtimeMaintenance + { + public static readonly Dictionary lastMaintainRealTime = new(); + + public const float GraceSeconds = 1f; + + private static float lastRescueReport; + private static int rescueCount; + + public static void NoteRescue(Sustainer sustainer, int ambientTicks, int staleStamp) + { + rescueCount++; + var now = Time.realtimeSinceStartup; + if (now - lastRescueReport < 60f) return; + lastRescueReport = now; + // MpLog.Log, not Debug: Debug is [Conditional("DEBUG")] and + // compiled out of the Release zips the field actually runs - + // this line is field evidence, it must exist there + MpLog.Log($"Sustainer cross-clock rescue: {sustainer.def} ambient={ambientTicks} stamp={staleStamp} ({rescueCount} rescues since last report)"); + rescueCount = 0; + } + } + + [HarmonyPatch(typeof(Sustainer), MethodType.Constructor, typeof(SoundDef), typeof(SoundInfo))] + static class SustainerCtorRealtimeStamp + { + static void Postfix(Sustainer __instance) + { + if (Multiplayer.Client == null) return; + SustainerRealtimeMaintenance.lastMaintainRealTime[__instance] = Time.realtimeSinceStartup; + } + } + + [HarmonyPatch(typeof(Sustainer), nameof(Sustainer.Maintain))] + static class SustainerMaintainRealtimeStamp + { + static void Postfix(Sustainer __instance) + { + if (Multiplayer.Client == null) return; + SustainerRealtimeMaintenance.lastMaintainRealTime[__instance] = Time.realtimeSinceStartup; + } + } + + [HarmonyPatch(typeof(Sustainer), nameof(Sustainer.End))] + static class SustainerEndCleanup + { + static void Postfix(Sustainer __instance) + => SustainerRealtimeMaintenance.lastMaintainRealTime.Remove(__instance); + } + + [HarmonyPatch(typeof(Sustainer), nameof(Sustainer.SustainerUpdate))] + static class SustainerTolerantEndCheck + { + // Runs after SustainerUpdateMapTime's prefix so the re-stamp uses the + // same clock the vanilla staleness check is about to read + [HarmonyPriority(Priority.Low)] + static void Prefix(Sustainer __instance) + { + if (Multiplayer.Client == null || __instance.Ended) return; + + var maintenance = __instance.info.Maintenance; + int staleAfter; + if (maintenance == MaintenanceType.PerTick) + staleAfter = 1; + else if (maintenance == MaintenanceType.PerTickRare) + staleAfter = 250; + else + return; + + var ambientTicks = Find.TickManager.TicksGame; + if (ambientTicks <= __instance.lastMaintainTick + staleAfter) + return; // not stale, vanilla keeps it + + if (!SustainerRealtimeMaintenance.lastMaintainRealTime.TryGetValue(__instance, out var maintainedAt) || + Time.realtimeSinceStartup - maintainedAt > SustainerRealtimeMaintenance.GraceSeconds) + return; // truly abandoned, let vanilla end it + + SustainerRealtimeMaintenance.NoteRescue(__instance, ambientTicks, __instance.lastMaintainTick); + __instance.lastMaintainTick = ambientTicks; + } + } +} diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index a8068091b..6832a4c0c 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -5,6 +5,7 @@ using HarmonyLib; using LudeonTK; using Multiplayer.Client.AsyncTime; +using Multiplayer.Client.Desyncs; using Multiplayer.Common; using Multiplayer.Common.Networking.Packet; using RimWorld.Planet; @@ -51,7 +52,15 @@ public static IEnumerable AllTickables var maps = Find.Maps; for (int i = maps.Count - 1; i >= 0; i--) - yield return maps[i].AsyncTime(); + { + // Skip maps whose AsyncTimeComp isn't registered yet + // (mid-session map generation) - every consumer derefs the + // tickable, and a command aimed at such a map already + // fails loud through RunCmds' TickableById null path. + var comp = maps[i].AsyncTime(); + if (comp != null) + yield return comp; + } } } @@ -161,8 +170,47 @@ static ITickable CurrentTickable() static void Postfix() { - if (Multiplayer.Client == null || Find.CurrentMap == null) return; - Shader.SetGlobalFloat(ShaderPropertyIDs.GameSeconds, Find.CurrentMap.AsyncTime().mapTicks.TicksToSeconds()); + if (Multiplayer.Client == null) return; + + InstallViewerTimeContext(); + Patches.FactionResidueGuard.HealAfterTicks(); + + // AsyncTime() can be null while a join or load is mid-flight + // (Multiplayer.game lags Client in that window) + if (Find.CurrentMap?.AsyncTime() is not { } viewerTime) return; + Shader.SetGlobalFloat(ShaderPropertyIDs.GameSeconds, viewerTime.mapTicks.TicksToSeconds()); + } + +// Nothing owns the global clock between ticks, so rendering and the + // UI read whatever the last tickable left installed. Give the rest of + // the frame one defined context instead: the full snapshot (tick + // count, speed, slower, gameStartAbsTick) of what the player is + // actually looking at, so every unwrapped per-frame reader sees a + // single consistent clock instead of alternating between the viewer's + // clock inside SetMapTime brackets and the world clock outside them. + // + // Between-tick readers that genuinely want world time have explicit + // world wraps: LetterStackUpdate, AlertsReadout, World.WorldUpdate + // and - load-bearing for determinism - SaveLoad.SaveGameData, which + // would otherwise scribe each client's viewer clock into join-point + // saves. + // + // Safe for the sim: this runs after the entire tick and command loop, + // and every sim path installs its own context on entry (the world + // tick installs its own count), so this value is never a simulation + // input. + internal static void InstallViewerTimeContext() + { + // Null checks cover the join/load window where Multiplayer.game + // (and with it the async comps) lags Multiplayer.Client + if (Multiplayer.game == null || Find.TickManager == null) return; + + // The previous snapshots are deliberately discarded: this installs + // the frame's owner, it doesn't bracket a scope + if (WorldRendererUtility.WorldSelected) + TimeSnapshot.GetAndSetFromWorld(); + else if (Find.CurrentMap is { } map && map.AsyncTime() != null) + TimeSnapshot.GetAndSetFromMap(map); } private static bool RunCmds() @@ -258,7 +306,7 @@ private static void TickTickable(ITickable tickable) } catch (Exception e) { - Log.Error($"Exception during ticking {tickable}: {e}"); + SimulationFailures.Handle($"Exception during ticking {tickable}", e); } } } @@ -291,7 +339,14 @@ public static float ActualRateMultiplier(this ITickable tickable, TimeSpeed spee var rate = Multiplayer.AsyncWorldTime.TickRateMultiplier(speed); foreach (var map in Find.Maps) - rate = Math.Min(rate, map.AsyncTime().TickRateMultiplier(speed)); + { + // A map can sit in Find.Maps before its AsyncTimeComp is + // registered (mid-session map generation). A comp-less map + // isn't a running map, so it can't bound the rate. + var comp = map.AsyncTime(); + if (comp != null) + rate = Math.Min(rate, comp.TickRateMultiplier(speed)); + } return rate; } @@ -317,6 +372,23 @@ public static void Reset() public static ITickable TickableById(int tickableId) => AllTickables.FirstOrDefault(t => t.TickableId == tickableId); } + // Root_Play.Update runs RealTime.Update, PortraitsCache and UIRootUpdate + // BEFORE TickManagerUpdate, where the frame's viewer context is normally + // installed - so those consumers read the PREVIOUS frame's residual + // ambient (e.g. unpausedTime advances by a stale TickRateMultiplier, + // making pausable-animated materials move in bursts). Install the viewer + // context at the top of the frame too; the sim still installs its own + // contexts on entry, so this is render/UI-only like the post-tick install. + [HarmonyPatch(typeof(Root_Play), nameof(Root_Play.Update))] + static class FrameStartViewerContext + { + static void Prefix() + { + if (Multiplayer.Client == null) return; + TickPatch.InstallViewerTimeContext(); + } + } + public class SimulatingData { public int? target; diff --git a/Source/Client/Patches/TimestampFixer.cs b/Source/Client/Patches/TimestampFixer.cs index 5526021d1..9ab9a34b2 100644 --- a/Source/Client/Patches/TimestampFixer.cs +++ b/Source/Client/Patches/TimestampFixer.cs @@ -33,6 +33,11 @@ static TimestampFixer() public static void FixPawn(Pawn p, Map oldMap, Map newMap) { + // Destroyed pawns don't need fixing, and deep-scribing them warns + // "Deep-saving destroyed thing" on every predator kill + if (p.Destroyed) + return; + var oldTime = oldMap?.AsyncTime().mapTicks ?? Multiplayer.AsyncWorldTime.worldTicks; var newTime = newMap?.AsyncTime().mapTicks ?? Multiplayer.AsyncWorldTime.worldTicks; currentOffset = newTime - oldTime; diff --git a/Source/Client/Patches/UnsyncedSpawnTripwire.cs b/Source/Client/Patches/UnsyncedSpawnTripwire.cs new file mode 100644 index 000000000..dd591778e --- /dev/null +++ b/Source/Client/Patches/UnsyncedSpawnTripwire.cs @@ -0,0 +1,35 @@ +using System; +using HarmonyLib; +using Verse; + +namespace Multiplayer.Client.Patches; + +// A Thing spawned outside the synced simulation - neither during ticking nor +// command execution - exists on this client only and desyncs the session much +// later, when pawn decisions around the divergent state first differ (the +// classic unsynced-mod-gizmo bug). Warn the moment it happens, once per def, +// with the stack that names the offender. A negative id means the thing was +// created in interface context (UniqueIdsPatch local id block) - caught +// regardless of where the spawn itself runs. +[HarmonyPatch(typeof(Thing))] +[HarmonyPatch(nameof(Thing.SpawnSetup))] +static class UnsyncedSpawnTripwire +{ + static void Postfix(Thing __instance, bool respawningAfterLoad) + { + // Motes/flecks and other no-id things are not simulation state; loading + // respawns everything outside ticking legitimately + if (Multiplayer.Client == null || respawningAfterLoad || !__instance.def.HasThingIDNumber) + return; + + // InInterface (not raw flag checks) so map gen, reloading and long + // events don't false-positive + if (__instance.thingIDNumber >= 0 && !Multiplayer.InInterface) + return; + + Log.WarningOnce( + $"MP: {__instance.def.defName} (id {__instance.thingIDNumber}) spawned outside the synced simulation - " + + $"an unsynced mod action is diverging this client. Stack: {Environment.StackTrace}", + __instance.def.shortHash ^ 0x51DE); + } +} diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index eb5812a86..d3c350067 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -73,7 +73,12 @@ public static void SendViewedMapUpdate(int previous, int current) else if (previous == current) return; int currentTick = Find.TickManager?.TicksGame ?? 0; MpLog.Debug($"VTR MapSwitchPatch: {lastMovedToMapId}->{current} @ tick {currentTick}{warn}"); - Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(previous, current)); + // Payload is an absolute announce (playerId, viewedMapId) into the + // synced view table, not a delta - re-announcing the same view is + // idempotent, so ResendCurrentView after a reload can never + // double-count and command ordering around join points can't + // drift the counts + Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(Multiplayer.session.playerId, current)); lastMovedToMapId = current; } @@ -82,6 +87,32 @@ public static void Reset() lastMovedToMapId = InvalidMapId; lastSentAtTick = -1; } + + // Re-announce the local view after every SaveAndReload. The view + // table is scribed and the counts derive from it, so unlike the old + // incremental counts nothing is lost across a reload - but sends are + // suppressed while reloading and a view change during that window + // (or a fresh host with an empty table) would otherwise go + // unannounced. The announce is an absolute (playerId, mapId) write, + // so repeating an unchanged view is a no-op. Called by + // SaveAndReloadCore after the reloading flag clears. + public static void ResendCurrentView() + { + if (Multiplayer.Client == null) return; + + lastMovedToMapId = InvalidMapId; + lastSentAtTick = -1; + + // wantedMode, not the CurrentWorldRenderMode getter: the getter + // is patched (WorldRenderModePatch) and must not run mid-install + int viewedId = Find.World?.renderer?.wantedMode == WorldRenderMode.Planet + ? WorldMapId + : Find.CurrentMap?.uniqueID ?? InvalidMapId; + + if (viewedId == InvalidMapId) return; + + SendViewedMapUpdate(InvalidMapId, viewedId); + } } [HarmonyPatch(typeof(Game), nameof(Game.CurrentMap), MethodType.Setter)] diff --git a/Source/Client/Persistent/GrowthMomentSession.cs b/Source/Client/Persistent/GrowthMomentSession.cs index 3c348ccdc..dea968868 100644 --- a/Source/Client/Persistent/GrowthMomentSession.cs +++ b/Source/Client/Persistent/GrowthMomentSession.cs @@ -10,7 +10,10 @@ namespace Multiplayer.Client.Persistent; public class GrowthMomentSession(Map _) : ExposableSession(null), ITickingSession { public static GrowthMomentSession GetSessionFor(Pawn pawn) => - pawn.Map.MpComp().sessionManager.GetFirstOfType(sess => sess.Pawn == pawn); + // Null map: unspawned pawn (caravan child) - sessions are map-bound, + // so there is none; callers fall back to the vanilla dialog whose + // choices are still synced + pawn.Map?.MpComp().sessionManager.GetFirstOfType(sess => sess.Pawn == pawn); // There isn't a trait selected. public const int NullTraitIdx = -1; // intentionally the same as the "not found" return value for IndexOf @@ -24,9 +27,26 @@ public static GrowthMomentSession GetSessionFor(Pawn pawn) => public List passionIndexes = []; public bool uiDirty; // if true, received or sent an update + // The child's faction (stamped from the creation context) - only that + // faction's players get the dialog and may make the choice. -1 = legacy + // sessions from older saves: everyone, the old behavior. + public int ownerFactionId = -1; + + public bool IsForLocalPlayer => + ownerFactionId < 0 || Multiplayer.RealPlayerFaction?.loadID == ownerFactionId; + + // Valid during synced execution, where Faction.OfPlayer is the issuer's + private bool ContextFactionMayChoose => + ownerFactionId < 0 || Faction.OfPlayer?.loadID == ownerFactionId; + public static GrowthMomentSession Create(ChoiceLetter_GrowthMoment letter) => new(null) { - letter = letter + letter = letter, + ownerFactionId = Multiplayer.GameComp.multifaction && + Faction.OfPlayer is { IsPlayer: true } f && + f != Multiplayer.WorldComp.spectatorFaction + ? f.loadID + : -1 }; // LetterWithTimeout.LastTickBeforeTimeout adjusted to use the map's time instead of Find.TickManager.TicksGame @@ -38,7 +58,7 @@ public override bool IsCurrentlyPausing(Map map) => Map == map && letter.Timeout public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) { - if (Map != entry.map) return null; + if (Map != entry.map || !IsForLocalPlayer) return null; return new FloatMenuOption("MpGrowthMomentSession".Translate(Pawn.Name.ToStringShort), OpenWindow); } @@ -47,6 +67,9 @@ public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry [SyncMethod] public void UpdateChoices(int traitIdx, List passionIndexes) { + // Another faction's player can't steer this child's choices + if (!ContextFactionMayChoose) return; + this.traitIdx = traitIdx; this.passionIndexes = passionIndexes; this.uiDirty = true; @@ -62,8 +85,13 @@ public void OpenWindow() public void Tick() { + // Pawn off-map mid-session (formed a caravan): session lingers in its + // original map's manager but Map follows the pawn - don't NRE + if (Map == null) return; if (!IsSessionValid) Map.MpComp().sessionManager.RemoveSession(this); - else if (letter.TimeoutActive && letter.disappearAtTick <= Map.AsyncTime().mapTicks + 1) OpenWindow(); + // The forced-open at timeout is UI-only - owner's players only + else if (letter.TimeoutActive && letter.disappearAtTick <= Map.AsyncTime().mapTicks + 1 && IsForLocalPlayer) + OpenWindow(); } public override void ExposeData() @@ -73,6 +101,7 @@ public override void ExposeData() Scribe_References.Look(ref letter, "pawn"); Scribe_Values.Look(ref traitIdx, "traitIdx"); Scribe_Collections.Look(ref passionIndexes, "passionIndexes", LookMode.Value); + Scribe_Values.Look(ref ownerFactionId, "ownerFactionId", -1); } [SyncMethod] @@ -80,6 +109,9 @@ public static GrowthMomentSession TryAddSession(ChoiceLetter_GrowthMoment letter { if (letter.ArchiveView) return null; var pawn = letter.pawn; + // Unspawned pawn (caravan child): sessions are map-bound, so no + // session - the vanilla dialog handles it and MakeChoices is synced + if (pawn.Map == null) return null; var sessionManager = pawn.Map.MpComp().sessionManager; var sess = sessionManager.GetFirstOfType(sess => sess.Pawn == pawn); if (sess == null) @@ -168,6 +200,11 @@ static bool WindowStackAddPatch(ref Window window) dialog.letter.ArchiveView || window is GrowthMomentWindow) return true; + // Unspawned pawn (caravan child): no map-bound session possible - let + // the vanilla dialog open locally; MakeChoices itself is synced + if (dialog.letter.pawn.Map == null) + return true; + if (GrowthMomentSession.GetSessionFor(dialog.letter.pawn) is { } session) session.OpenWindow(); else @@ -176,6 +213,20 @@ static bool WindowStackAddPatch(ref Window window) return false; } + // The choice itself is faction authority: only the child's faction may + // apply it (UpdateChoices already guards the live-sync path) + [MpPrefix(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.MakeChoices))] + static bool MakeChoicesGuard(ChoiceLetter_GrowthMoment __instance) + { + if (Multiplayer.Client == null || !Multiplayer.ExecutingCmds) return true; + var session = GrowthMomentSession.GetSessionFor(__instance.pawn); + if (session == null || session.ownerFactionId < 0 || Faction.OfPlayer?.loadID == session.ownerFactionId) + return true; + + Log.Message($"MP: ignored growth-moment choice for {__instance.pawn} from non-owner faction {Faction.OfPlayer?.Name}"); + return false; + } + [MpPostfix(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.MakeChoices))] static void MakeChoicesPatch(ChoiceLetter_GrowthMoment __instance) { diff --git a/Source/Client/Persistent/RitualSession.cs b/Source/Client/Persistent/RitualSession.cs index b278b83c0..99b5abf03 100644 --- a/Source/Client/Persistent/RitualSession.cs +++ b/Source/Client/Persistent/RitualSession.cs @@ -1,4 +1,5 @@ using Multiplayer.API; +using Multiplayer.Client.Factions; using RimWorld; using Verse; @@ -30,6 +31,30 @@ public void Remove() [SyncMethod] public void Start() + { + // Multifaction: ritual side-effects (ideo development, letters) must + // resolve against the ritual's faction, not whoever invoked Start. + // Ownable excludes the spectator faction (IsPlayer matches it) + var ownerFaction = Multiplayer.GameComp.multifaction + ? data.organizer?.Faction is { } f && QuestFactionOwnership.IsOwnablePlayerFaction(f) ? f + : map.ParentFaction is { } pf && QuestFactionOwnership.IsOwnablePlayerFaction(pf) ? pf : null + : null; + + if (ownerFaction != null) + map.PushFaction(ownerFaction); + + try + { + StartInt(); + } + finally + { + if (ownerFaction != null) + map.PopFaction(); + } + } + + private void StartInt() { // Handle the same stuff as Dialog_BeginGravshipLaunch if (data.isGravshipRitual) diff --git a/Source/Client/Saving/ConvertToSp.cs b/Source/Client/Saving/ConvertToSp.cs index 1dc65d74a..299d6cec2 100644 --- a/Source/Client/Saving/ConvertToSp.cs +++ b/Source/Client/Saving/ConvertToSp.cs @@ -1,4 +1,5 @@ -using Verse; +using RimWorld; +using Verse; using Verse.Profile; namespace Multiplayer.Client.Saving; @@ -30,6 +31,15 @@ private static void PrepareSingleplayer() var spectator = Multiplayer.WorldComp.spectatorFaction; if (spectator != null) { + // Purge faction-keyed state first or the save writes dangling Faction_N refs + foreach (var map in Find.Maps) + map.pawnDestinationReservationManager.reservedDestinations.Remove(spectator); + + // Pawns stranded in the spectator faction would keep a dangling reference + foreach (var pawn in PawnsFinder.All_AliveOrDead) + if (pawn.Faction == spectator) + pawn.SetFaction(Faction.OfPlayer); + spectator.RemoveAllRelations(); Find.FactionManager.allFactions.Remove(spectator); } diff --git a/Source/Client/Saving/LordCrossRefs.cs b/Source/Client/Saving/LordCrossRefs.cs new file mode 100644 index 000000000..2505a0d9c --- /dev/null +++ b/Source/Client/Saving/LordCrossRefs.cs @@ -0,0 +1,28 @@ +using HarmonyLib; +using Verse.AI.Group; + +namespace Multiplayer.Client; + +// Lords loaded with the game register into sharedCrossRefs automatically (the +// loader directory is swapped to it), but lords created at runtime never did - +// so synced blobs referencing them (e.g. ceremony delivery jobs) failed to +// resolve and ran lordless (live find: GiveToPawn job losing Lord_7). +[HarmonyPatch(typeof(LordManager), nameof(LordManager.AddLord))] +static class RegisterLordCrossRef +{ + static void Postfix(Lord newLord) + { + if (Multiplayer.game != null) + ScribeUtil.sharedCrossRefs.RegisterLoaded(newLord); + } +} + +[HarmonyPatch(typeof(LordManager), nameof(LordManager.RemoveLord))] +static class UnregisterLordCrossRef +{ + static void Postfix(Lord oldLord) + { + if (Multiplayer.game != null) + ScribeUtil.sharedCrossRefs.Unregister(oldLord); + } +} diff --git a/Source/Client/Saving/SaveCompression.cs b/Source/Client/Saving/SaveCompression.cs index 9ea45d393..b6a151d44 100644 --- a/Source/Client/Saving/SaveCompression.cs +++ b/Source/Client/Saving/SaveCompression.cs @@ -383,4 +383,32 @@ static bool Referenced(Thing t) return t.Map?.compressor?.compressibilityDecider.IsReferenced(t) ?? false; } } + + // Vanilla 1.6 bug: Ideology TreeSighting refs aren't declared to the + // compressor, so sighted wild trees compress away and every save warns. + // Declare them like job/designation targets; also fixes SP saves. + [HarmonyPatch(typeof(CompressibilityDecider), nameof(CompressibilityDecider.DetermineReferences))] + static class DeclareTreeSightingRefs + { + static void Postfix(CompressibilityDecider __instance) + { + foreach (var pawn in __instance.map.mapPawns.AllPawnsSpawned) + { + var surroundings = pawn.surroundings; + if (surroundings == null) continue; + + Declare(__instance, surroundings.miniTreeSightings); + Declare(__instance, surroundings.fullTreeSightings); + } + } + + static void Declare(CompressibilityDecider decider, List sightings) + { + if (sightings == null) return; + + foreach (var sighting in sightings) + if (sighting.tree != null) + decider.referencedThings.Add(sighting.tree); + } + } } diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index 857ff0b18..e1e68a134 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -8,6 +8,7 @@ using System.Security.Cryptography; using System.Threading; using System.Xml; +using Multiplayer.Client.Patches; using Multiplayer.Client.Saving; using Multiplayer.Client.Util; using UnityEngine; @@ -112,6 +113,8 @@ private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizatio Multiplayer.reloading = false; + VTRSync.ResendCurrentView(); + return gameData; } @@ -159,9 +162,23 @@ private static void ClearState() public static TempGameData SaveGameData() { - var gameDoc = SaveGameToDoc(); - var sessionData = SessionData.WriteSessionData(); - return new TempGameData(gameDoc, sessionData); + // ExposeSmallComponents scribes the ambient TickManager state + // (ticksGameInt, gameStartAbsTick, curTimeSpeed), and join-point + // saves run on every client locally - so the save must be pinned + // to the world clock, or each client would scribe its own + // viewer's clock and their post-reload states would diverge + //. Covers SaveAndReload and both autosave paths. + var prev = TimeSnapshot.GetAndSetFromWorld(); + try + { + var gameDoc = SaveGameToDoc(); + var sessionData = SessionData.WriteSessionData(); + return new TempGameData(gameDoc, sessionData); + } + finally + { + prev?.Set(); + } } public static XmlDocument SaveGameToDoc() diff --git a/Source/Client/Saving/SavingPatches.cs b/Source/Client/Saving/SavingPatches.cs index 95832ff18..46717856e 100644 --- a/Source/Client/Saving/SavingPatches.cs +++ b/Source/Client/Saving/SavingPatches.cs @@ -66,6 +66,7 @@ static class ClearAllPatch static void Postfix() { CacheAverageTileTemperature.Clear(); + Patches.ItemAccessibilityCacheInvalidation.Reset(); Multiplayer.game?.OnDestroy(); Multiplayer.game = null; } @@ -170,8 +171,15 @@ static bool Prefix(LoadedObjectDirectory __instance, ref ILoadReferenceable reff { text = reffable.GetUniqueLoadID(); } - catch + catch (Exception e) { + // Tolerated so one broken object doesn't abort the load, but a + // throwing object silently never registers (vanilla's per-object + // try/catch skips it; "[excepted]" is only a message placeholder) + // - that shouldn't stay silent + Log.WarningOnce( + $"MP: GetUniqueLoadID threw for {reffable?.GetType().FullName ?? "null"}; object may not resolve cross-references: {e.Message}", + reffable?.GetType().GetHashCode() ?? 0); } return !__instance.allObjectsByLoadID.ContainsKey(text); diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 9bf2464c1..9ac028a2b 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -40,6 +40,16 @@ public class MultiplayerSession : IConnectionStatusListener public bool desynced; + // Synced commands/ticks that threw and were skipped this session - + // each one is a potential divergence. See SimulationFailures. + public int simulationFailures; + public string firstSimulationFailure; + + // Set when the join-data mismatch window let the player connect anyway; + // null means no mismatch window was shown. Included in desync reports - + // whether the builds matched is the first forensic question. + public string joinDataDiff; + public List pendingSteam = new(); public List knownUsers = new(); diff --git a/Source/Client/Syncing/Game/SyncActions.cs b/Source/Client/Syncing/Game/SyncActions.cs index b5b20a220..3ed6d9fc8 100644 --- a/Source/Client/Syncing/Game/SyncActions.cs +++ b/Source/Client/Syncing/Game/SyncActions.cs @@ -16,8 +16,7 @@ public static void Init() { void Error(string error) { - Multiplayer.loadingErrors = true; - Log.Error(error); + Multiplayer.LoadingError(error); } // TODO: Use MpMethodUtil instead if we decide to make it work with generic types/methods (already in MP Compat, so use it). Or remove this TODO if we decide not to. diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index cff9df04b..648823e27 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -24,13 +24,13 @@ public static void Init() SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_CaptureEntity), "GetOptionsFor", 2).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Capture entity // Other possible float menu options that could be added later: - // TODO maybe? TransporterUtility.InitiateLoading is not synced, not sure if significant + // Carry to shuttle: TransporterUtility.InitiateLoading is synced via a prefix in SyncMethods // SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_CarryToShuttle), "GetSingleOptionFor", 0).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Carry to shuttle // TODO: missing some flecks // SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_DraftedAttack), "GetSingleOptionFor", 0).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Drafted attack // TODO: missing fleck // SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_Equip), "GetSingleOptionFor", 0).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Equip - // TODO: uncertain if everything is synced, some things are possibly synced elsewhere, possibly not fully synced + // Start ritual: covered - both branches call the synced Precept_Ritual.ShowRitualBeginWindow // SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_StartRitual), "GetOptionsFor", 0).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Start ritual // TODO: missing fleck/mote // SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_WorkGivers), "GetOptionsFor", 0).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Generic work givers @@ -296,7 +296,9 @@ private static void InitRituals() SyncDelegate.Lambda(typeof(LordJob_BestowingCeremony), nameof(LordJob_BestowingCeremony.GetPawnGizmos), 2); // Cancel ceremony SyncDelegate.Lambda(typeof(LordJob_BestowingCeremony), nameof(LordJob_BestowingCeremony.GetPawnGizmos), 0); // Make pawn leave ceremony - // TODO: May need to change this back to SynchMetho, but changes to 1.6 broke it. + // Verified against 1.6 assembly: ordinal 0 resolves to the dialog-opening + // lambda in <>c__DisplayClass7_0 and its closure fields (LordJob, LordToil) + // have implicit sync workers. The dialog becomes a RitualSession on open. SyncDelegate.Lambda(typeof(LordToil_BestowingCeremony_Wait), nameof(LordToil_BestowingCeremony_Wait.ExtraFloatMenuOptions), 0); // Begin bestowing float menu SyncMethod.Register(typeof(Command_BestowerCeremony), nameof(Command_BestowerCeremony.ProcessInput)); // Begin bestowing gizmo diff --git a/Source/Client/Syncing/Game/SyncGame.cs b/Source/Client/Syncing/Game/SyncGame.cs index f9c1bc366..136b4b38b 100644 --- a/Source/Client/Syncing/Game/SyncGame.cs +++ b/Source/Client/Syncing/Game/SyncGame.cs @@ -15,8 +15,7 @@ static void TryInit(string name, Action action) } catch (Exception e) { - Log.Error($"Exception during {name} initialization: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"Exception during {name} initialization: {e}"); } } diff --git a/Source/Client/Syncing/Game/SyncMarkers.cs b/Source/Client/Syncing/Game/SyncMarkers.cs index 4ce5e5140..65bb29154 100644 --- a/Source/Client/Syncing/Game/SyncMarkers.cs +++ b/Source/Client/Syncing/Game/SyncMarkers.cs @@ -9,6 +9,6 @@ public static class SyncMarkers [MpPrefix(typeof(MainTabWindow_Work), nameof(MainTabWindow_Work.DoManualPrioritiesCheckbox))] static void ManualPriorities_Prefix() => manualPriorities = true; - [MpPostfix(typeof(MainTabWindow_Work), nameof(MainTabWindow_Work.DoManualPrioritiesCheckbox))] - static void ManualPriorities_Postfix() => manualPriorities = false; + [MpFinalizer(typeof(MainTabWindow_Work), nameof(MainTabWindow_Work.DoManualPrioritiesCheckbox))] + static void ManualPriorities_Finalizer() => manualPriorities = false; } diff --git a/Source/Client/Syncing/Game/SyncMethods.cs b/Source/Client/Syncing/Game/SyncMethods.cs index 1b04e7881..a0da09d18 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -515,6 +515,11 @@ public static void Init() crate.GetMultiSelectFloatMenuOptions(SyncUtil.prevSelected.OfType()).ExecuteEnumerable(); }); + // Faction/colony naming dialogs (opened from the multifaction sidebar); + // Named targets Faction.OfPlayer = the issuing player's faction in command context + SyncMethod.Register(typeof(NamePlayerFactionDialogUtility), nameof(NamePlayerFactionDialogUtility.Named)); + SyncMethod.Register(typeof(NamePlayerSettlementDialogUtility), nameof(NamePlayerSettlementDialogUtility.Named)); + // Double ExecuteWhenFinished ensures it'll load after MP Compat late patches, // so it will have registered all its sync workers already. LongEventHandler.ExecuteWhenFinished(() => LongEventHandler.ExecuteWhenFinished(() => @@ -545,10 +550,40 @@ static bool PastePawnTimetable(Pawn p) } [MpPrefix(typeof(StorageSettingsClipboard), nameof(StorageSettingsClipboard.Copy))] - static void StorageSettingsClipboardCopy_Prefix() => Multiplayer.dontSync = true; + static void StorageSettingsClipboardCopy_Prefix(ref bool __state) + { + if (!Multiplayer.dontSync) + { + Multiplayer.dontSync = true; + __state = true; + } + } - [MpPostfix(typeof(StorageSettingsClipboard), nameof(StorageSettingsClipboard.Copy))] - static void StorageSettingsClipboardCopy_Postfix() => Multiplayer.dontSync = false; + // Finalizer, not postfix: a throw in Copy would leave dontSync stuck on, + // silently disabling all syncing for the session + [MpFinalizer(typeof(StorageSettingsClipboard), nameof(StorageSettingsClipboard.Copy))] + static void StorageSettingsClipboardCopy_Finalizer(bool __state) + { + if (__state) + Multiplayer.dontSync = false; + } + + // "Carry to shuttle" calls InitiateLoading from interface code, mutating + // loading state on the clicking client only (the job order itself already + // syncs via TryTakeOrderedJob). The skipped original's int return (new + // group id) is default on the sync path - the real id is allocated later + // inside the synced command; all vanilla call sites discard it + [MpPrefix(typeof(TransporterUtility), nameof(TransporterUtility.InitiateLoading))] + static bool InitiateLoading_Prefix(IEnumerable transporters) + { + if (!Multiplayer.ShouldSync) return true; + SyncedInitiateLoading(transporters.ToList()); + return false; + } + + [SyncMethod] + static void SyncedInitiateLoading(List transporters) => + TransporterUtility.InitiateLoading(transporters); [MpTranspiler(typeof(CompPlantable), nameof(CompPlantable.BeginTargeting), lambdaOrdinal: 0)] static IEnumerable CompPlantableTranspiler(IEnumerable insts) @@ -705,14 +740,17 @@ static void AddBill_Prefix(Bill bill) [MpPostfix(typeof(Ideo), nameof(Ideo.CopyTo))] static void FixIdeoAfterCopy(Ideo __instance, Ideo ideo) { - if (Multiplayer.ExecutingCmds) + // Also during ticking: ideo reform can complete from ritual outcome toils + if (Multiplayer.ExecutingCmds || Multiplayer.Ticking) { // Fix ids for precepts generated by the fluid ideo reforming UI foreach (var precept in ideo.PreceptsListForReading) if (precept.ID < 0) precept.ID = Find.UniqueIDsManager.GetNextPreceptID(); - ideo.development.ideo = ideo; + // CopyTo only creates development for fluid ideos - guard non-fluid copies + if (ideo.development != null) + ideo.development.ideo = ideo; ideo.style.ideo = ideo; } } diff --git a/Source/Client/Syncing/Game/ThingFilterMarkers.cs b/Source/Client/Syncing/Game/ThingFilterMarkers.cs index 98651e066..8f9599a0c 100644 --- a/Source/Client/Syncing/Game/ThingFilterMarkers.cs +++ b/Source/Client/Syncing/Game/ThingFilterMarkers.cs @@ -37,56 +37,56 @@ static void TabStorageFillTab_Prefix(ITab_Storage __instance) DrawnThingFilter = new TabStorageWrapper(selParent); } - [MpPostfix(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] - static void TabStorageFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] + static void TabStorageFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] static void BillConfig_Prefix(Dialog_BillConfig __instance) => DrawnThingFilter = new BillConfigWrapper(__instance.bill); - [MpPostfix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] - static void BillConfig_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] + static void BillConfig_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] static void ManageOutfit_Prefix(Dialog_ManageApparelPolicies __instance) => DrawnThingFilter = new OutfitWrapper(__instance.SelectedPolicy); - [MpPostfix(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] - static void ManageOutfit_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] + static void ManageOutfit_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] static void ManageFoodRestriction_Prefix(Dialog_ManageFoodPolicies __instance) => DrawnThingFilter = new FoodRestrictionWrapper(__instance.SelectedPolicy); - [MpPostfix(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] - static void ManageFoodRestriction_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] + static void ManageFoodRestriction_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] static void TabPenAutocutFillTab_Prefix(ITab_PenAutoCut __instance) => DrawnThingFilter = new PenAutocutWrapper(__instance.SelectedCompAnimalPenMarker); - [MpPostfix(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] - static void TabPenAutocutFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] + static void TabPenAutocutFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] static void TabPenAnimalsFillTab_Prefix(ITab_PenAnimals __instance) => DrawnThingFilter = new PenAnimalsWrapper(__instance.SelectedCompAnimalPenMarker); - [MpPostfix(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] - static void TabPenAnimalsFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] + static void TabPenAnimalsFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] static void TabWindTurbineAutocutFillTab_Prefix(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = new DefaultAutocutWrapper(__instance.AutoCut); - [MpPostfix(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] - static void TabWindTurbineAutocutFillTab_Postfix(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] + static void TabWindTurbineAutocutFillTab_Finalizer(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = null; [MpPrefix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] static void ThingFilterUI_Prefix() => drawingThingFilter = true; - [MpPostfix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] - static void ThingFilterUI_Postfix() => drawingThingFilter = false; + [MpFinalizer(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] + static void ThingFilterUI_Finalizer() => drawingThingFilter = false; // Reading policies need special handling as they draw two ThingFilters private static ReadingPolicy drawnReadingPolicy; @@ -94,8 +94,8 @@ static void TabWindTurbineAutocutFillTab_Prefix(ITab_WindTurbineAutoCut __instan [MpPrefix(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] static void Dialog_ManageReadingPolicies_Prefix(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = __instance.SelectedPolicy; - [MpPostfix(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] - static void Dialog_ManageReadingPolicies_Postfix(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = null; + [MpFinalizer(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] + static void Dialog_ManageReadingPolicies_Finalizer(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = null; [MpPrefix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] static void ThingFilterUI_ReadingPolicy_Prefix(ThingFilter filter) @@ -107,8 +107,8 @@ static void ThingFilterUI_ReadingPolicy_Prefix(ThingFilter filter) DrawnThingFilter = new ReadingPolicyEffectFilterWrapper(drawnReadingPolicy); } - [MpPostfix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] - static void ThingFilterUI_ReadingPolicy_Postfix(ThingFilter filter) + [MpFinalizer(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] + static void ThingFilterUI_ReadingPolicy_Finalizer(ThingFilter filter) { if (drawnReadingPolicy != null && drawnReadingPolicy.defFilter == filter) DrawnThingFilter = null; diff --git a/Source/Client/Syncing/Sync.cs b/Source/Client/Syncing/Sync.cs index 50b7cc7be..a4912bc2b 100644 --- a/Source/Client/Syncing/Sync.cs +++ b/Source/Client/Syncing/Sync.cs @@ -147,8 +147,7 @@ internal static void RegisterAllAttributes(Assembly asm) } catch (Exception e) { - Log.Error($"Exception registering SyncMethod {type}::{method} by attribute: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"Exception registering SyncMethod {type}::{method} by attribute: {e}"); } } @@ -161,8 +160,7 @@ internal static void RegisterAllAttributes(Assembly asm) } catch (Exception e) { - Log.Error($"Exception registering SyncField by attribute: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"Exception registering SyncField by attribute: {e}"); } } } @@ -397,8 +395,7 @@ public static void ValidateAll() handler.Validate(); } catch (Exception e) { - Log.Error($"{handler} validation failed: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"{handler} validation failed: {e}"); } } } diff --git a/Source/Client/Syncing/SyncFieldUtil.cs b/Source/Client/Syncing/SyncFieldUtil.cs index 5a42405dd..4dc4bbc86 100644 --- a/Source/Client/Syncing/SyncFieldUtil.cs +++ b/Source/Client/Syncing/SyncFieldUtil.cs @@ -163,8 +163,7 @@ internal static void ApplyWatchFieldPatches(Type type) { Multiplayer.harmony.PatchMeasure(attr.Method, prefix, postfix); } catch (Exception e) { - Log.Error($"FAIL: {attr.Method.DeclaringType.FullName}:{attr.Method.Name} with {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"FAIL: {attr.Method.DeclaringType.FullName}:{attr.Method.Name} with {e}"); } } } diff --git a/Source/Client/UI/IngameDebug.cs b/Source/Client/UI/IngameDebug.cs index 9f76da958..d74159def 100644 --- a/Source/Client/UI/IngameDebug.cs +++ b/Source/Client/UI/IngameDebug.cs @@ -34,9 +34,15 @@ internal static void DoDebugPrintout() text.AppendLine($"Avg Delta: {avgDelta = (avgDelta * 59.0 + Time.deltaTime * 60.0) / 60.0:0.0000}"); text.AppendLine($"Game Ticks: {Find.TickManager.TicksGame}"); text.AppendLine($"Time Speed: {Find.TickManager.CurTimeSpeed}"); + // Paused comes from the current map, TickRateMultiplier from the global + // time speed. In vanilla they cannot disagree; if they do here, something + // left a foreign speed installed in the global TickManager. + text.AppendLine($"Tick Rate Mult: {Find.TickManager.TickRateMultiplier:0.##}{separator}Paused: {Find.TickManager.Paused}"); text.AppendLine($"Tick Until: {TickPatch.tickUntil}{separator}Remote: {Multiplayer.session?.remoteTickUntil ?? 0}"); text.AppendLine($"Received Commands: {Multiplayer.session?.receivedCmds ?? 0}"); text.AppendLine($"Sent Commands: {Multiplayer.session?.remoteSentCmds ?? 0}"); + text.AppendLine($"Sim Failures: {Multiplayer.session?.simulationFailures ?? 0}" + + (Multiplayer.session?.firstSimulationFailure is { } first ? $"{separator}First: {first}" : "")); text.AppendLine($"\nFaction ID: {Faction.OfPlayer.loadID} ({FactionContext.stack.Count}){separator}Real Faction ID: {Multiplayer.RealPlayerFaction?.loadID ?? -1}"); text.AppendLine($"Next Thing ID: {Find.UniqueIDsManager.nextThingID}{separator}Next Job ID: {Find.UniqueIDsManager.nextJobID}"); @@ -52,6 +58,7 @@ internal static void DoDebugPrintout() text.AppendLine($"Server TPT: {TickPatch.serverTimePerTick:0.0}ms"); text.AppendLine($"Calculated TPT: {(TickPatch.tickUntil - TickPatch.Timer <= 3 ? TickPatch.serverTimePerTick * 1.2f : TickPatch.tickUntil - TickPatch.Timer >= 7 ? TickPatch.serverTimePerTick * 0.8f : TickPatch.serverTimePerTick):0.0}ms"); text.AppendLine($"Map Ticks: {async.mapTicks}{separator}Frozen: {TickPatch.serverFrozen} @ {TickPatch.frozenAt}"); + text.AppendLine($"Map Speed: {async.DesiredTimeSpeed}{separator}World Speed: {Multiplayer.AsyncWorldTime?.DesiredTimeSpeed}"); text.AppendLine($"Client Opinions: {Multiplayer.game.sync.knownClientOpinions.Count}{separator}Opinion Start Tick: {Multiplayer.game.sync.knownClientOpinions.FirstOrDefault()?.startTick ?? 0}"); text.AppendLine($"Opinion Start Tick: {Multiplayer.game.sync.knownClientOpinions.FirstOrDefault()?.startTick ?? 0}"); text.AppendLine($"Force Normal Speed Until: {async.slower.forceNormalSpeedUntil}"); diff --git a/Source/Client/UI/MainMenuPatches.cs b/Source/Client/UI/MainMenuPatches.cs index 246b66e80..ce577b7be 100644 --- a/Source/Client/UI/MainMenuPatches.cs +++ b/Source/Client/UI/MainMenuPatches.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using HarmonyLib; using Multiplayer.Client.Saving; @@ -29,6 +30,28 @@ public static class MainMenu_AddHeight [HarmonyPatch(typeof(OptionListingUtility), nameof(OptionListingUtility.DrawOptionListing))] public static class MainMenuPatch { + // Playing with failed patches/registrations near-guarantees desyncs or + // broken features - confirm before hosting/browsing + internal static void OpenWithInitErrorCheck(Action open) + { + if (!Multiplayer.loadingErrors) + { + open(); + return; + } + + const int maxShown = 8; + var errors = Multiplayer.loadingErrorsList; + var shown = errors.Take(maxShown).Select(e => " - " + e.Split('\n')[0].Trim()); + var text = $"Multiplayer failed to initialize {errors.Count} of its patches or sync registrations. " + + "Playing multiplayer in this state is likely to desync or break features.\n\n" + + string.Join("\n", shown) + + (errors.Count > maxShown ? $"\n ...and {errors.Count - maxShown} more" : "") + + "\n\nFull stack traces are in the log. Continue anyway?"; + + Find.WindowStack.Add(Dialog_MessageBox.CreateConfirmation(text, open, true, layer: WindowLayer.Super)); + } + static void Prefix(Rect rect, List optList) { if (!MainMenuMarker.drawing) return; @@ -55,8 +78,11 @@ static void Prefix(Rect rect, List optList) GUIUtility.systemCopyBuffer = version; else { - Find.WindowStack.Add(new ServerBrowser()); - VersionChecker.OpenNewVersionDialogIfApplicable(); + OpenWithInitErrorCheck(() => + { + Find.WindowStack.Add(new ServerBrowser()); + VersionChecker.OpenNewVersionDialogIfApplicable(); + }); } })); } @@ -67,7 +93,8 @@ static void Prefix(Rect rect, List optList) if (Multiplayer.session == null) optList.Insert(0, new ListableOption( "MpHostServer".Translate(), - () => Find.WindowStack.Add(new HostWindow() { layer = WindowLayer.Super }) + () => OpenWithInitErrorCheck(() => + Find.WindowStack.Add(new HostWindow() { layer = WindowLayer.Super })) )); if (MpVersion.IsDebug && Multiplayer.IsReplay) diff --git a/Source/Client/Util/Extensions.cs b/Source/Client/Util/Extensions.cs index 1be34d424..faf6e3129 100644 --- a/Source/Client/Util/Extensions.cs +++ b/Source/Client/Util/Extensions.cs @@ -182,7 +182,7 @@ public static void TryKill(this Process process) { process.Kill(); } - catch { } + catch { } // best-effort shutdown; the process may already have exited } /// diff --git a/Source/Client/Util/MpPatch.cs b/Source/Client/Util/MpPatch.cs index cce8bdea6..aa2fd0eee 100644 --- a/Source/Client/Util/MpPatch.cs +++ b/Source/Client/Util/MpPatch.cs @@ -120,7 +120,8 @@ public static List DoMpPatches(this Harmony harmony, Type type) toPatch, (attr is MpPrefix) ? patch : null, (attr is MpPostfix) ? patch : null, - (attr is MpTranspiler) ? patch : null + (attr is MpTranspiler) ? patch : null, + (attr is MpFinalizer) ? patch : null ); } @@ -129,8 +130,7 @@ public static List DoMpPatches(this Harmony harmony, Type type) } catch (Exception e) { - Log.Error($"MpPatch {m.DeclaringType}.{m.Name} failed with exception: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"MpPatch {m.DeclaringType}.{m.Name} failed with exception: {e}"); } } } @@ -189,6 +189,30 @@ public MpPostfix(Type parentType, string parentMethod, int lambdaOrdinal) : base } } + /// + /// Finalizer method attribute. Runs even when the patched method throws, + /// unlike a postfix - use for cleanup of global state set in a prefix. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class MpFinalizer : MpPatch + { + public MpFinalizer(string typeName, string method) : base(typeName, method) + { + } + + public MpFinalizer(Type type, string method, Type[] argTypes = null) : base(type, method, argTypes) + { + } + + public MpFinalizer(Type type, string innerType, string method) : base(type, innerType, method) + { + } + + public MpFinalizer(Type parentType, string parentMethod, int lambdaOrdinal) : base(parentType, parentMethod, lambdaOrdinal) + { + } + } + /// /// Transpiler method attribute /// diff --git a/Source/Client/Util/VersionChecker.cs b/Source/Client/Util/VersionChecker.cs index 22cf2625f..2881fcaad 100644 --- a/Source/Client/Util/VersionChecker.cs +++ b/Source/Client/Util/VersionChecker.cs @@ -34,9 +34,12 @@ public static void Init() }); } + // Exact match: git describe on any branch CONTAINING the continuous tag + // yields "continuous-N-g" - those are downstream builds, not the + // continuous release, and must not be told to "update" to upstream public static bool IsContinuousRelease => Multiplayer.modContentPack.ModMetaData.Source == ContentSource.ModsFolder && - MpVersion.GitDescription?.StartsWith("continuous") == true && + MpVersion.GitDescription == "continuous" && MpVersion.GitHash?.EndsWith("dirty") == false; public static bool IsLocalBuild => diff --git a/Source/Client/Windows/ConnectingWindow.cs b/Source/Client/Windows/ConnectingWindow.cs index 1e05e0e31..d8203a402 100644 --- a/Source/Client/Windows/ConnectingWindow.cs +++ b/Source/Client/Windows/ConnectingWindow.cs @@ -54,9 +54,14 @@ public override void DoWindowContents(Rect inRect) case ClientLoadingState { subState: LoadingState.Downloading } state: label = "MpDownloading".Translate() + $" ({state.DownloadProgressPercent}%)"; var leftToDownloadKBps = (state.WorldExpectedSize - state.WorldReceivedSize) / 1000; - if (state.DownloadSpeedKBps != 0) + // The speed getter recomputes from live checkpoints on every + // access - read it once, or the value checked isn't the value + // divided by (observed as a DivideByZeroException here; it is + // also -1 before the first checkpoint) + var downloadSpeedKBps = state.DownloadSpeedKBps; + if (downloadSpeedKBps > 0) { - var timeLeftSecs = leftToDownloadKBps / state.DownloadSpeedKBps; + var timeLeftSecs = leftToDownloadKBps / downloadSpeedKBps; label += $"\n{timeLeftSecs}s – "; } @@ -64,7 +69,7 @@ public override void DoWindowContents(Rect inRect) label += "\n"; label += - $"{state.WorldReceivedSize / 1000}/{state.WorldExpectedSize / 1000} KB ({state.DownloadSpeedKBps} KB/s)"; + $"{state.WorldReceivedSize / 1000}/{state.WorldExpectedSize / 1000} KB ({downloadSpeedKBps} KB/s)"; break; default: label = result ?? (ConnectingString + MpUI.FixedEllipsis()); diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index fb891cc22..25828ee1a 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -250,6 +250,20 @@ void RefreshFiles() if (connectAnyway) { Log.Message($"Multiplayer: Connecting anyway ({DiffString()})"); + + // Remember what was mismatched for the desync report, and make + // the risk visible in chat - a mid-rejoin click-through is easy + // to wave past and mismatched files mean divergent simulations + if (Multiplayer.session != null) + { + Multiplayer.session.joinDataDiff = DiffString(); + + if (filesRoot.children.Any() || modListDiff != ModListDiff.None) + Multiplayer.session.AddMsg( + $"Connected with mismatched mod files ({DiffString()}) - desyncs are likely until everyone runs the same build.", + rawMessage: true); + } + connectAnywayCallback(); Close(false); } @@ -269,7 +283,9 @@ void RefreshFiles() private string DiffString() { var str = ""; - str += $"RW version match: {remote.remoteMpVersion == MpVersion.Version}, "; + // remoteMpVersion is the MP mod version, not the RimWorld version + str += $"MP version match: {remote.remoteMpVersion == MpVersion.Version}, "; + str += $"Language match: {remote.remoteLanguage == LanguageDatabase.activeLanguage.folderName}, "; str += $"Mod list diff: {modListDiff}, "; str += $"Files match: {!filesRoot.children.Any()}, "; str += $"Config sync enabled: {remote.hasConfigs}, "; @@ -352,7 +368,18 @@ private void DrawGeneralTab(Rect inRect) bool mpVersionCheck = remote.remoteMpVersion == MpVersion.Version; Widgets.Checkbox(new Rect(0, 0, 24, 24).CenterOn(checkboxColumn.Down(2 * rowHeight)).min, ref mpVersionCheck); - inRect.yMin += rowHeight * 3 + 30f; + var languageRect = mpVersionRect.Down(rowHeight).Width(rowLabelWidth); + Widgets.DrawHighlightIfMouseover(languageRect); + Widgets.DrawAltRect(headerRect.Down(3 * rowHeight)); + Widgets.Label(languageRect, + "MpMismatchLanguage".TryTranslate(out var languageLabel) ? languageLabel.ToString() : "Game language"); + Widgets.Label(serverColumn.Down(3 * rowHeight), remote.remoteLanguage); + Widgets.Label(clientColumn.Down(3 * rowHeight), LanguageDatabase.activeLanguage.folderName); + + bool languageCheck = remote.remoteLanguage == LanguageDatabase.activeLanguage.folderName; + Widgets.Checkbox(new Rect(0, 0, 24, 24).CenterOn(checkboxColumn.Down(3 * rowHeight)).min, ref languageCheck); + + inRect.yMin += rowHeight * 4 + 30f; } } diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index 9e94cb170..1b4a31459 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -12,7 +12,7 @@ - + diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 7d3940f8b..0f7c1e3f8 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -32,6 +32,10 @@ static MultiplayerServer() public const char EndpointSeparator = '&'; public const int NetTicksPerSecond = 30; // Not an exact amount. The net loop isn't particularly precise. + // How long a client may stay behind the pause gate before the whole + // simulation pauses for it (net ticks, ~45s real time) + public const int PauseGraceNetTicks = 45 * NetTicksPerSecond; + public static readonly Regex UsernamePattern = new(@"^[a-zA-Z0-9_]+$"); public WorldData worldData; @@ -119,8 +123,8 @@ public void Run() int ticked = 0; while (realTime > 0 && ticked < 2) { - playersBehind.Clear(); - playersBehind.AddRange(PlayingIngamePlayers.Where(p => p.ExtrapolatedTicksBehind > 90)); + EvaluatePlayersBehind(playersBehind); + if (!freezeManager.Frozen && PlayingPlayers.Any(p => p.ExtrapolatedTicksBehind < 40) && !playersBehind.Any()) @@ -172,7 +176,38 @@ public void Run() } } - private void TickNet() + internal void EvaluatePlayersBehind(List playersBehind) + { + playersBehind.Clear(); + foreach (var p in JoinedPlayers) + { + // Only actively playing players hold the pause gate; anyone + // loading, rejoining or desynced gets the timer reset so the + // grace restarts fresh if they return still behind + if (p.status != PlayerStatus.Playing || !p.IsPlaying) + { + p.behindSinceNetTimer = -1; + continue; + } + + if (p.ExtrapolatedTicksBehind > 90) + { + if (p.behindSinceNetTimer < 0) + p.behindSinceNetTimer = NetTimer; + + // Tolerate a lagging/tabbed-out client for a grace period + // before pausing everyone; they catch up on return + if (NetTimer - p.behindSinceNetTimer > PauseGraceNetTicks) + playersBehind.Add(p); + } + else + { + p.behindSinceNetTimer = -1; + } + } + } + + internal void TickNet() { NetTimer++; diff --git a/Source/Common/Networking/Packet/InitDataPacket.cs b/Source/Common/Networking/Packet/InitDataPacket.cs index ecec5699c..4409f9172 100644 --- a/Source/Common/Networking/Packet/InitDataPacket.cs +++ b/Source/Common/Networking/Packet/InitDataPacket.cs @@ -20,6 +20,7 @@ public record struct ClientInitDataPacket : IPacket private const int MaxRawDataLength = 1 << 20; public string rwVersion; + public string language; public int[] debugOnlySyncCmds; public int[] hostOnlySyncCmds; public RoundModeEnum modCtorRoundMode; @@ -37,6 +38,7 @@ public List Mods public void Bind(PacketBuffer buf) { buf.Bind(ref rwVersion); + buf.Bind(ref language); buf.Bind(ref debugOnlySyncCmds, BinderOf.Int()); buf.Bind(ref hostOnlySyncCmds, BinderOf.Int()); buf.BindEnum(ref modCtorRoundMode); diff --git a/Source/Common/Networking/Packet/JoinDataPacket.cs b/Source/Common/Networking/Packet/JoinDataPacket.cs index 507b59f37..2a83126fd 100644 --- a/Source/Common/Networking/Packet/JoinDataPacket.cs +++ b/Source/Common/Networking/Packet/JoinDataPacket.cs @@ -9,6 +9,7 @@ public record struct ServerJoinDataPacket : IPacket public int playerId; public string rwVersion; public string mpVersion; + public string language; public DefCheckStatus[] defStatus; public bool configsIncluded; public byte[] rawServerInitData; @@ -25,6 +26,7 @@ public void Bind(PacketBuffer buf) buf.Bind(ref playerId); buf.Bind(ref rwVersion); buf.Bind(ref mpVersion); + buf.Bind(ref language); 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. diff --git a/Source/Common/Networking/Packet/PlayerListPacket.cs b/Source/Common/Networking/Packet/PlayerListPacket.cs index b42fdc18a..2b4e41c88 100644 --- a/Source/Common/Networking/Packet/PlayerListPacket.cs +++ b/Source/Common/Networking/Packet/PlayerListPacket.cs @@ -98,7 +98,8 @@ public void Bind(PacketBuffer buf) buf.BindEnum(ref type); buf.BindEnum(ref status); buf.Bind(ref steamId); - buf.Bind(ref steamPersonaName, maxLength: Constants.k_cwchPersonaNameMax); + // k_cwchPersonaNameMax counts chars but ReadString limits UTF-8 bytes (up to 4 per char) + buf.Bind(ref steamPersonaName, maxLength: Constants.k_cwchPersonaNameMax * 4); buf.Bind(ref ticksBehind); buf.Bind(ref simulating); buf.Bind(ref r); diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index b2d2a392f..8a1ac2c6c 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -155,6 +155,7 @@ private bool HandleClientJoinData(ClientJoinDataPacket packet) playerId = Player.id, rwVersion = serverInitData.RwVersion, mpVersion = MpVersion.Version, + language = serverInitData.Language, defStatus = defStatus, configsIncluded = serverInitData.IncludeConfigs, rawServerInitData = serverInitData.RawData, diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index f7b21e7a7..5f9beabb4 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -37,12 +37,20 @@ public void HandleClientCommand(ClientCommandPacket packet) if (packet.type == CommandType.PlayerCount) { + // Payload: (playerId, viewedMapId) - an absolute announce into + // the clients' synced view table. The player id must be the + // sender's own; the server also sends removals on its behalf + // at disconnect (PlayerManager.SetDisconnected). ByteReader reader = new ByteReader(packet.data); - var prevMapId = reader.ReadInt32(); + var playerId = reader.ReadInt32(); var newMapId = reader.ReadInt32(); - if (Player.currentMapId != prevMapId) - ServerLog.Error($"Inconsistent player {Player.Username} map. Last known map: {Player.currentMapId}, " + - $"however received command with transition: {prevMapId} -> {newMapId}"); + + if (playerId != Player.id) + { + ServerLog.Error($"Player {Player.Username} announced a view for player id {playerId} - dropped"); + return; + } + Player.currentMapId = newMapId; Player.hasReportedCurrentMap = true; diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index efa871704..1a1e4943f 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -79,10 +79,12 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) if (player.hasJoined) { - // Send PlayerCount command to remove the player from their last known map + // Remove the leaver from the clients' synced view table + // (viewedMapId -1 removes the entry) so their map doesn't keep + // a phantom viewer until the next reload if (player.currentMapId != -1) { - byte[] playerCountData = ByteWriter.GetBytes(player.currentMapId, -1); // previousMap: player's map, newMap: -1 (disconnected) + byte[] playerCountData = ByteWriter.GetBytes(player.id, -1); server.commands.Send(CommandType.PlayerCount, ScheduledCommand.NoFaction, ScheduledCommand.Global, playerCountData); } // todo check player.IsPlaying? diff --git a/Source/Common/ServerInitData.cs b/Source/Common/ServerInitData.cs index 730a1afbb..968f8fdf2 100644 --- a/Source/Common/ServerInitData.cs +++ b/Source/Common/ServerInitData.cs @@ -8,6 +8,7 @@ public record ServerInitData( byte[] RawData, bool IncludeConfigs, string RwVersion, + string Language, HashSet DebugOnlySyncCmds, HashSet HostOnlySyncCmds, (RoundModeEnum, RoundModeEnum) RoundModes, @@ -15,7 +16,7 @@ Dictionary DefInfos ) { public static ServerInitData FromNet(ClientInitDataPacket packet) => new( - packet.rawMods, packet.includeConfigs, packet.rwVersion, + packet.rawMods, packet.includeConfigs, packet.rwVersion, packet.language, packet.debugOnlySyncCmds.ToHashSet(), packet.hostOnlySyncCmds.ToHashSet(), (packet.modCtorRoundMode, packet.staticCtorRoundMode), diff --git a/Source/Common/ServerPlayer.cs b/Source/Common/ServerPlayer.cs index f1a231c97..d28c14e0e 100644 --- a/Source/Common/ServerPlayer.cs +++ b/Source/Common/ServerPlayer.cs @@ -19,6 +19,9 @@ public class ServerPlayer : IChatSource public int ticksBehindReceivedAt; public int ExtrapolatedTicksBehind => ticksBehind + (Server.gameTimer - ticksBehindReceivedAt); + // NetTimer value when this player first exceeded the pause gate, -1 while keeping up + public int behindSinceNetTimer = -1; + public ulong steamId; public string steamPersonaName = ""; diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index 9e8b83277..81ab54b52 100644 --- a/Source/Common/Version.cs +++ b/Source/Common/Version.cs @@ -6,7 +6,7 @@ namespace Multiplayer.Common public static class MpVersion { public const string SimpleVersion = "0.11.5"; - public const int Protocol = 56; + public const int Protocol = 58; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() diff --git a/Source/MultiplayerLoader/MultiplayerLoader.csproj b/Source/MultiplayerLoader/MultiplayerLoader.csproj index 018083c71..1ffe0a20b 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.csproj +++ b/Source/MultiplayerLoader/MultiplayerLoader.csproj @@ -10,7 +10,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/SourceGen/SourceGen.csproj b/Source/SourceGen/SourceGen.csproj index e2387a071..f2ea383ff 100644 --- a/Source/SourceGen/SourceGen.csproj +++ b/Source/SourceGen/SourceGen.csproj @@ -10,8 +10,12 @@ - - + + + diff --git a/Source/Tests/ChatCommandManagerTest.cs b/Source/Tests/ChatCommandManagerTest.cs index 6dc33c554..befe4fbd3 100644 --- a/Source/Tests/ChatCommandManagerTest.cs +++ b/Source/Tests/ChatCommandManagerTest.cs @@ -360,6 +360,7 @@ public void ModsCommand_ShowsServerModList() ]), false, "1.6.4633", + "English", [], [], default, @@ -389,6 +390,7 @@ public void ModsCommand_CanPageThroughServerModList() ]), false, "1.6.4633", + "English", [], [], default, diff --git a/Source/Tests/Helper/TestJoiningState.cs b/Source/Tests/Helper/TestJoiningState.cs index 5121b0cc5..3198bef9f 100644 --- a/Source/Tests/Helper/TestJoiningState.cs +++ b/Source/Tests/Helper/TestJoiningState.cs @@ -25,6 +25,7 @@ protected override async Task RunState() connection.Send(new ClientInitDataPacket { rwVersion = RwVersion, + language = "English", debugOnlySyncCmds = [], hostOnlySyncCmds = [], modCtorRoundMode = RoundModeEnum.ToNearest, diff --git a/Source/Tests/Helper/TestLoadingKeepAliveState.cs b/Source/Tests/Helper/TestLoadingKeepAliveState.cs index c0bb37ef4..e0cb4935a 100644 --- a/Source/Tests/Helper/TestLoadingKeepAliveState.cs +++ b/Source/Tests/Helper/TestLoadingKeepAliveState.cs @@ -25,6 +25,7 @@ protected override async Task RunState() connection.Send(new ClientInitDataPacket { rwVersion = RwVersion, + language = "English", debugOnlySyncCmds = [], hostOnlySyncCmds = [], modCtorRoundMode = RoundModeEnum.ToNearest, diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index fbe1eac84..8adca7099 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -108,6 +108,23 @@ private static IEnumerable RoundtripPackets() factionId = 30 }); + // Steam allows 32 *characters*; this 30-char Cyrillic name is 56 UTF-8 bytes + // and must survive the byte-limited reader + yield return ServerPlayerListPacket.Add(new ServerPlayerListPacket.PlayerInfo + { + id = 4, + username = "Dana", + latency = 55, + type = PlayerType.Steam, + status = PlayerStatus.Playing, + steamId = 444555666, + steamPersonaName = "Тестовое имя игрока в Римворлд", + ticksBehind = 0, + simulating = false, + r = 128, g = 64, b = 32, + factionId = 40 + }); + yield return ServerPlayerListPacket.Remove(99); yield return ServerPlayerListPacket.Latencies([ @@ -202,6 +219,7 @@ private static IEnumerable RoundtripPackets() gameName = "GameName", playerId = 1, rwVersion = "1.6.4566", + language = "English", mpVersion = "0.11.0+123456", defStatus = [ @@ -222,6 +240,7 @@ private static IEnumerable RoundtripPackets() yield return new ClientInitDataPacket { rwVersion = "1.0.0", + language = "English", debugOnlySyncCmds = [1, 2, 3, 4], hostOnlySyncCmds = [1], modCtorRoundMode = RoundModeEnum.ToNearest, diff --git a/Source/Tests/PauseGraceTest.cs b/Source/Tests/PauseGraceTest.cs new file mode 100644 index 000000000..cef773b65 --- /dev/null +++ b/Source/Tests/PauseGraceTest.cs @@ -0,0 +1,185 @@ +using Multiplayer.Common; + +namespace Tests; + +[TestFixture] +public class PauseGraceTest +{ + private MultiplayerServer server = null!; + private int nextPlayerId; + private readonly List playersBehind = new(); + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + nextPlayerId = 1; + playersBehind.Clear(); + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private ServerPlayer AddPlayingPlayer(string username) + { + var conn = new DummyConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn); + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + player.UpdateStatus(PlayerStatus.Playing); + return player; + } + + private void AdvanceNetTicks(int ticks) + { + for (int i = 0; i < ticks; i++) + server.TickNet(); + } + + private void Evaluate() => server.EvaluatePlayersBehind(playersBehind); + + // ExtrapolatedTicksBehind = ticksBehind + (gameTimer - ticksBehindReceivedAt); + // gameTimer never advances in these tests, so ticksBehind is the whole value + private static void SetBehind(ServerPlayer player, int ticks) + { + player.ticksBehind = ticks; + player.ticksBehindReceivedAt = 0; + } + + [Test] + public void Behind_WithinGrace_NoPause() + { + var player = AddPlayingPlayer("player"); + SetBehind(player, 100); + + Evaluate(); + Assert.That(player.behindSinceNetTimer, Is.GreaterThanOrEqualTo(0), "timer should arm"); + Assert.That(playersBehind, Is.Empty); + + // Grace uses a strict > comparison: exactly PauseGraceNetTicks elapsed is still within grace + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks); + Evaluate(); + Assert.That(playersBehind, Is.Empty); + } + + [Test] + public void Behind_PastGrace_PauseFires() + { + var player = AddPlayingPlayer("player"); + SetBehind(player, 100); + + Evaluate(); + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 1); + Evaluate(); + + Assert.That(playersBehind, Is.EqualTo(new[] { player })); + } + + [Test] + public void Recovery_ResetsTimer_GraceRestartsOnRelapse() + { + var player = AddPlayingPlayer("player"); + SetBehind(player, 100); + Evaluate(); + AdvanceNetTicks(600); + + SetBehind(player, 0); + Evaluate(); + Assert.That(player.behindSinceNetTimer, Is.EqualTo(-1), "recovery should reset the timer"); + + // Falling behind again must arm a fresh grace, not resume the old one + SetBehind(player, 100); + Evaluate(); + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks); + Evaluate(); + Assert.That(playersBehind, Is.Empty); + + AdvanceNetTicks(1); + Evaluate(); + Assert.That(playersBehind, Is.EqualTo(new[] { player })); + } + + [Test] + public void RejoinWhileArmed_GraceRestarts() + { + var player = AddPlayingPlayer("player"); + SetBehind(player, 100); + Evaluate(); + Assert.That(player.behindSinceNetTimer, Is.GreaterThanOrEqualTo(0)); + + // Rejoin changes only the connection state - status stays Playing. + // Start a join point first (as PlayerManager does on rejoin) so the + // loading state genuinely pends instead of completing synchronously. + server.worldData.TryStartJoinPointCreation(force: true); + player.conn.ChangeState(ConnectionStateEnum.ServerLoading); + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 100); + Evaluate(); + Assert.That(player.behindSinceNetTimer, Is.EqualTo(-1), "timer should reset while rejoining"); + Assert.That(playersBehind, Is.Empty); + + // Back in play, still behind: fresh grace, no instant pause + player.conn.ChangeState(ConnectionStateEnum.ServerPlaying); + Evaluate(); + Assert.That(playersBehind, Is.Empty, "returning player must get a fresh grace"); + + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 1); + Evaluate(); + Assert.That(playersBehind, Is.EqualTo(new[] { player })); + } + + [Test] + public void DesyncWhileArmed_GraceRestarts() + { + var player = AddPlayingPlayer("player"); + SetBehind(player, 100); + Evaluate(); + Assert.That(player.behindSinceNetTimer, Is.GreaterThanOrEqualTo(0)); + + // Desync changes the status but not the connection state + player.UpdateStatus(PlayerStatus.Desynced); + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 100); + Evaluate(); + Assert.That(player.behindSinceNetTimer, Is.EqualTo(-1), "timer should reset while desynced"); + + player.UpdateStatus(PlayerStatus.Playing); + Evaluate(); + Assert.That(playersBehind, Is.Empty, "recovered player must get a fresh grace"); + + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 1); + Evaluate(); + Assert.That(playersBehind, Is.EqualTo(new[] { player })); + } + + [Test] + public void TimersArePerPlayer() + { + var laggard = AddPlayingPlayer("laggard"); + var healthy = AddPlayingPlayer("healthy"); + SetBehind(laggard, 100); + SetBehind(healthy, 0); + + Evaluate(); + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 1); + Evaluate(); + Assert.That(playersBehind, Is.EqualTo(new[] { laggard })); + Assert.That(healthy.behindSinceNetTimer, Is.EqualTo(-1)); + + // The second player arms its own timer at the current NetTimer, not the first player's + SetBehind(healthy, 100); + Evaluate(); + Assert.That(playersBehind, Is.EqualTo(new[] { laggard }), "healthy player is within its own grace"); + + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 1); + Evaluate(); + Assert.That(playersBehind, Is.EquivalentTo(new[] { laggard, healthy })); + } +} diff --git a/Source/Tests/StandaloneMapStreamingTest.cs b/Source/Tests/StandaloneMapStreamingTest.cs index 6676875b1..cc7f146c1 100644 --- a/Source/Tests/StandaloneMapStreamingTest.cs +++ b/Source/Tests/StandaloneMapStreamingTest.cs @@ -97,7 +97,7 @@ public void InitialPlayerCountMapReport_DoesNotSendMapResponse() state.HandleClientCommand(new ClientCommandPacket( CommandType.PlayerCount, ScheduledCommand.Global, - ByteWriter.GetBytes(-1, 5) + ByteWriter.GetBytes(player.id, 5) )); Assert.That(player.currentMapId, Is.EqualTo(5)); @@ -116,7 +116,7 @@ public void WorldToMapTransition_DoesNotSendMapResponse() state.HandleClientCommand(new ClientCommandPacket( CommandType.PlayerCount, ScheduledCommand.Global, - ByteWriter.GetBytes(-2, 5) + ByteWriter.GetBytes(player.id, 5) )); Assert.That(player.currentMapId, Is.EqualTo(5)); @@ -134,13 +134,43 @@ public void MapToMapTransition_DoesNotSendMapResponseWhenStreamingDisabled() state.HandleClientCommand(new ClientCommandPacket( CommandType.PlayerCount, ScheduledCommand.Global, - ByteWriter.GetBytes(3, 5) + ByteWriter.GetBytes(player.id, 5) )); Assert.That(player.currentMapId, Is.EqualTo(5)); Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_MapResponse)); } + [Test] + public void PlayerCountForOtherPlayer_IsDropped() + { + var (player, conn) = AddPlayer("player", 3); + + // The drop logs a server error by design - capture it instead of + // letting the global Assert.Fail hook (ServerTest.SetUp) eat it + var prevErrorHook = ServerLog.error; + string? loggedError = null; + ServerLog.error = s => loggedError = s; + + try + { + var state = player.conn.GetState()!; + state.HandleClientCommand(new ClientCommandPacket( + CommandType.PlayerCount, + ScheduledCommand.Global, + ByteWriter.GetBytes(player.id + 1, 5) + )); + } + finally + { + ServerLog.error = prevErrorHook; + } + + Assert.That(loggedError, Does.Contain("announced a view")); + Assert.That(player.currentMapId, Is.EqualTo(3)); + Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_Command)); + } + [Test] public void HandleDebug_IgnoredWhenDevModeDisabled() { diff --git a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt index b87199a17..e40528e62 100644 --- a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt @@ -1,3 +1,3 @@ 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) +05-00-00-00-31-2E-30-2E-30-07-00-00-00-45-6E-67-6C-69-73-68-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-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (130 bytes) diff --git a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt index 98e950920..d3cbfc1ac 100644 --- a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt @@ -1,3 +1,3 @@ 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) +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-07-00-00-00-45-6E-67-6C-69-73-68-05-00-00-00-00-00-02-03-01-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (108 bytes) diff --git a/Source/Tests/packet-serializations/ServerPlayerListPacket.verified.txt b/Source/Tests/packet-serializations/ServerPlayerListPacket.verified.txt index 8a8a61fa8..d3bf89510 100644 --- a/Source/Tests/packet-serializations/ServerPlayerListPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerPlayerListPacket.verified.txt @@ -1,5 +1,6 @@ 00-02-00-00-00-01-00-00-00-05-00-00-00-41-6C-69-63-65-2A-00-00-00-00-01-15-CD-5B-07-00-00-00-00-0A-00-00-00-41-6C-69-63-65-53-74-65-61-6D-00-00-00-00-00-FF-00-00-0A-00-00-00-02-00-00-00-03-00-00-00-42-6F-62-63-00-00-00-02-02-B1-68-DE-3A-00-00-00-00-06-00-00-00-42-6F-62-42-6F-74-05-00-00-00-01-00-FF-00-14-00-00-00 (105 bytes) 01-01-00-00-00-03-00-00-00-07-00-00-00-43-68-61-72-6C-69-65-0A-00-00-00-01-00-3D-1E-A1-06-00-00-00-00-08-00-00-00-43-68-61-72-6C-69-65-43-01-00-00-00-00-00-00-FF-1E-00-00-00 (58 bytes) +01-01-00-00-00-04-00-00-00-04-00-00-00-44-61-6E-61-37-00-00-00-01-01-92-61-7F-1A-00-00-00-00-38-00-00-00-D0-A2-D0-B5-D1-81-D1-82-D0-BE-D0-B2-D0-BE-D0-B5-20-D0-B8-D0-BC-D1-8F-20-D0-B8-D0-B3-D1-80-D0-BE-D0-BA-D0-B0-20-D0-B2-20-D0-A0-D0-B8-D0-BC-D0-B2-D0-BE-D1-80-D0-BB-D0-B4-00-00-00-00-00-80-40-20-28-00-00-00 (103 bytes) 02-63-00-00-00 03-02-00-00-00-01-00-00-00-2A-00-00-00-00-00-00-00-00-29-5C-85-41-02-00-00-00-4D-00-00-00-03-00-00-00-01-EC-51-05-42 (39 bytes) 04-01-00-00-00-01