Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ ipch/
*.opensdf
*.sdf
*.cachefile
*.lscache
*.VC.db
*.VC.VC.opendb

Expand Down
21 changes: 18 additions & 3 deletions Source/Client/AsyncTime/AsyncWorldTimeComp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ public void ExecuteCmd(ScheduledCommand cmd)

if (cmdType == CommandType.CreateJoinPoint)
{
if (Multiplayer.session?.ConnectedToStandaloneServer == true && !TickPatch.currentExecutingCmdIssuedBySelf)
return;

LongEventHandler.QueueLongEvent(CreateJoinPointAndSendIfHost, "MpCreatingJoinPoint", false, null);
}

Expand Down Expand Up @@ -275,9 +278,21 @@ private static void CreateJoinPointAndSendIfHost()
{
Multiplayer.session.dataSnapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveAndReload(), Multiplayer.GameComp.multifaction);

if (!TickPatch.Simulating && !Multiplayer.IsReplay &&
(Multiplayer.LocalServer != null || Multiplayer.arbiterInstance))
SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true);
if (!TickPatch.Simulating && !Multiplayer.IsReplay)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of having every player send the game data back to the server, could you have the server send info which player is expected to send the game data back? WorldData.cs Server.commands.Send(CommandType.CreateJoinPoint, ScheduledCommand.NoFaction, ScheduledCommand.Global, Array.Empty<byte>()); - the Array.Empty could be changed to include playerId or perhaps a value of true could be sent to the desired player and false to the rest?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about this approach: when a player wants to save, they request a joinpoint from the server, and the server asks a specific player to create it? I'm still leaning towards the idea that the player who triggered the save should be the one doing it for the maps they have loaded — so the server would still delegate it back to the requester. But if there are two players, maybe this server-mediated step would better coordinate who should do it?

Honestly, I'm not sure it adds much in practice — it feels like an extra round-trip for the same result. But I'd like your thoughts on it.

The philosophy I'm following is "each player uploads their own maps." The goal is to avoid a bottleneck where, say, 10 players need to save sequentially through a single designated uploader. On a standalone server there's no natural "authoritative" client like the host in a hosted game, so having each player responsible for their own maps seemed like the most resilient approach.

Currently, the IssuedBySelf gate (which was missing from PR #876+ due to a rebase oversight — now restored) ensures that when a CreateJoinPoint command is broadcast, only the client who originally requested the save actually executes CreateJoinPointAndSendIfHost. The other clients receive the command but skip execution. So the Array.Empty<byte>() payload doesn't need to carry a playerId — the client-side gate already handles it by checking currentExecutingCmdIssuedBySelf.

That said, if you'd prefer making this explicit via the command payload (e.g. embedding the requester's playerId so the server decides who saves), I'm open to it. It would make the intent clearer on the wire, even if the behavior is the same.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still leaning towards the idea that the player who triggered the save should be the one doing it for the maps they have loaded

I'm fine with that, and that's not my issue with this change. The issue is that right now everyone saves every map and everyone sends the world data back to the server.

The philosophy I'm following is "each player uploads their own maps." The goal is to avoid a bottleneck where, say, 10 players need to save sequentially through a single designated uploader. On a standalone server there's no natural "authoritative" client like the host in a hosted game, so having each player responsible for their own maps seemed like the most resilient approach.

That probably is a better approach. Not sure how much of a bottleneck it actually would be. Also, right now there isn't such a functionality to have the player only save their own map. If it exists in another PR of yours, please include that info in this PR's description so I'm aware of the overarching goal and have more context to review on

Currently, the IssuedBySelf gate (which was missing from PR #876 due to a rebase oversight — now restored) ensures that when a CreateJoinPoint command is broadcast, only the client who originally requested the save actually executes CreateJoinPointAndSendIfHost. The other clients receive the command but skip execution. So the Array.Empty() payload doesn't need to carry a playerId — the client-side gate already handles it by checking currentExecutingCmdIssuedBySelf.

I only reviewed this PR and didn't look at the others. If that PR fixes my concern, please move the relevant part to this PR. Also, IssuedBySelf will only work if you send the CreateJoinPoint command with a player provided. Right now, StartJoinPointCreation never sends an associated player

That said, if you'd prefer making this explicit via the command payload (e.g. embedding the requester's playerId so the server decides who saves), I'm open to it. It would make the intent clearer on the wire, even if the behavior is the same.

As long as the IssuedBySelf code you are referencing keeps working in non-standalone mode, I'm fine with just doing that. It's probably even better than my suggestion, so I think I'd even prefer your approach

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As soon as i get home ill send you more context, i was working on it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR does not implement map streaming itself, but it keeps the standalone join-point flow compatible with the upcoming streaming architecture. In hosted MP, join-point creation is host/arbiter-owned; in standalone, snapshot creation/upload must already happen client-side, and future streaming work extends that further by allowing the upload work to be split across assigned clients. Some of the standalone branching here is meant to preserve that separation cleanly without changing hosted behavior.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also i used ur idea about make the server handle who is the one who save for the streaming map one

{
if (Multiplayer.session?.ConnectedToStandaloneServer == true)
{
// Standalone: every client uploads world data + individual snapshots
SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true);
SaveLoad.SendStandaloneMapSnapshots(Multiplayer.session.dataSnapshot);
SaveLoad.SendStandaloneWorldSnapshot(Multiplayer.session.dataSnapshot);
}
else if (Multiplayer.LocalServer != null || Multiplayer.arbiterInstance)
{
// Hosted: only host/arbiter uploads world data
SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true);
}
}
}

public void SetTimeEverywhere(TimeSpeed speed)
Expand Down
34 changes: 34 additions & 0 deletions Source/Client/ConstantTicker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,40 @@ private static void TickNonSimulation()

private static void TickAutosave()
{
// When connected to a remote standalone server, the client drives
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code in this method is effiectively duplicated with the only change being where the settings are taken from - server.settings vs Multiplayer.session. Could you change the code to always rely on the data from Multiplayer.session? You'd probably need to initialize the session's autosave fields when hosting (not only connecting) a server but it'd simplify this code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the cleanup direction here. The reason I did not fold it into this PR is that the two branches are still reading from two different sources of truth today.

When connected to a remote standalone server, the client drives autosave from the values received through the connection protocol and stored in Multiplayer.session. When locally hosting, the code still reads directly from LocalServer.settings. So although the control flow is very similar, unifying this would first require deciding that session state becomes the canonical autosave source in the hosting path too.

That seems reasonable, but it also broadens this PR from standalone persistence/upload into a more general autosave-source refactor. I preferred to keep this change focused and treat that unification as a small follow-up cleanup instead.

// the autosave timer using the interval received at connection time
// (from the server's TOML settings via ServerProtocolOkPacket).
if (Multiplayer.session?.ConnectedToStandaloneServer == true)
{
var session = Multiplayer.session;
if (session.autosaveInterval <= 0)
return;

if (session.autosaveUnit == AutosaveUnit.Minutes)
{
session.autosaveCounter++;

if (session.autosaveCounter > session.autosaveInterval * TicksPerMinute)
{
session.autosaveCounter = 0;
Autosaving.DoAutosave();
}
}
else if (session.autosaveUnit == AutosaveUnit.Days)
{
var anyMapCounterUp =
Multiplayer.game.mapComps
.Any(m => m.autosaveCounter > session.autosaveInterval * TicksPerIngameDay);

if (anyMapCounterUp)
{
Multiplayer.game.mapComps.Do(m => m.autosaveCounter = 0);
Autosaving.DoAutosave();
}
}
return;
}

if (Multiplayer.LocalServer is not { } server) return;

if (server.settings.autosaveUnit == AutosaveUnit.Minutes)
Expand Down
2 changes: 0 additions & 2 deletions Source/Client/MultiplayerGame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,6 @@ public void ChangeRealPlayerFaction(int newFaction)

public void ChangeRealPlayerFaction(Faction newFaction, bool regenMapDrawers = true)
{
Log.Message($"Changing real player faction to {newFaction} from {myFaction}");

myFaction = newFaction;
FactionContext.Set(newFaction);
worldComp.SetFaction(newFaction);
Expand Down
4 changes: 4 additions & 0 deletions Source/Client/Networking/State/ClientJoiningState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public override void StartState()
[TypedPacketHandler]
public void HandleProtocolOk(ServerProtocolOkPacket packet)
{
Multiplayer.session.isStandaloneServer = packet.isStandaloneServer;
Multiplayer.session.autosaveInterval = packet.autosaveInterval;
Multiplayer.session.autosaveUnit = packet.autosaveUnit;

if (packet.hasPassword)
{
// Delay showing the window for better UX
Expand Down
1 change: 1 addition & 0 deletions Source/Client/Patches/TickPatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ private static bool RunCmds()
while (tickable.Cmds.Count > 0 && tickable.Cmds.Peek().ticks == curTimer)
{
ScheduledCommand cmd = tickable.Cmds.Dequeue();

// Minimal code impact fix for #733. Having all the commands be added to a single queue gets rid of
// the out-of-order execution problem. With a proper fix, this can be reverted to tickable.ExecuteCmd
var target = TickableById(cmd.mapId);
Expand Down
6 changes: 6 additions & 0 deletions Source/Client/Patches/VTRSyncPatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using HarmonyLib;
using Multiplayer.Client.Util;
using Multiplayer.Common;
using Multiplayer.Common.Networking.Packet;
using RimWorld.Planet;
using Verse;

Expand Down Expand Up @@ -142,6 +143,11 @@ static void Postfix(WorldRenderMode __result)
{
VTRSync.SendViewedMapUpdate(VTRSync.lastMovedToMapId, VTRSync.WorldMapId);
}

// On standalone with streaming, trigger a join point when leaving a map
// so each player can save independently without disturbing others
if (Multiplayer.session?.ConnectedToStandaloneServer == true && Multiplayer.GameComp.multifaction && Multiplayer.GameComp.asyncTime)
Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.WorldTravel));
}
// Detect transition back to tile map
else if (__result != WorldRenderMode.Planet && lastRenderMode == WorldRenderMode.Planet)
Expand Down
56 changes: 56 additions & 0 deletions Source/Client/Saving/SaveLoad.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using Ionic.Zlib;
using Multiplayer.Common;
using Multiplayer.Common.Networking.Packet;
using RimWorld;
using RimWorld.Planet;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Xml;
using Multiplayer.Client.Saving;
Expand Down Expand Up @@ -240,6 +242,60 @@ void Send()
else
Send();
}

/// <summary>
/// Send per-map standalone snapshots to the server for all maps in the given snapshot.
/// Called after autosave when connected to a standalone server.
/// </summary>
public static void SendStandaloneMapSnapshots(GameDataSnapshot snapshot)
{
var tick = snapshot.CachedAtTime;

foreach (var (mapId, mapBytes) in snapshot.MapData)
{
var compressed = GZipStream.CompressBuffer(mapBytes);

byte[] hash;
using (var sha = SHA256.Create())
hash = sha.ComputeHash(compressed);

var packet = new ClientStandaloneMapSnapshotPacket
{
mapId = mapId,
tick = tick,
mapData = compressed,
sha256Hash = hash,
};

OnMainThread.Enqueue(() => Multiplayer.Client?.SendFragmented(packet.Serialize()));
}
}

/// <summary>
/// Send the world + session standalone snapshot to the server.
/// Called after autosave when connected to a standalone server.
/// </summary>
public static void SendStandaloneWorldSnapshot(GameDataSnapshot snapshot)
{
var tick = snapshot.CachedAtTime;
var worldCompressed = GZipStream.CompressBuffer(snapshot.GameData);
var sessionCompressed = GZipStream.CompressBuffer(snapshot.SessionData);

using var hasher = SHA256.Create();
hasher.TransformBlock(worldCompressed, 0, worldCompressed.Length, null, 0);
hasher.TransformFinalBlock(sessionCompressed, 0, sessionCompressed.Length);
var hash = hasher.Hash ?? System.Array.Empty<byte>();

Comment on lines +284 to +288
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
using var hasher = SHA256.Create();
hasher.TransformBlock(worldCompressed, 0, worldCompressed.Length, null, 0);
hasher.TransformFinalBlock(sessionCompressed, 0, sessionCompressed.Length);
var hash = hasher.Hash ?? System.Array.Empty<byte>();
byte[] hash;
using (var sha = SHA256.Create())
{
sha.TransformBlock(worldCompressed, 0, worldCompressed.Length, null, 0);
hash = sha.TransformFinalBlock(sessionCompressed, 0, sessionCompressed.Length);
}

nit: symmetry with SendStandaloneMapSnapshots

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the underlying point: this hashing logic is effectively duplicated with the server-side validation path in WorldData, so a shared helper would be cleaner.

I did not take the suggestion literally because the best fix here is not really to reshuffle this one call site, but to extract a single helper for the standalone world/session hash computation and use it from both SaveLoad and WorldData. That keeps the producer and validator on the same implementation, which matters more than the exact local shape here.

So I agree with the cleanup direction, I just think it belongs as a small shared-helper refactor rather than as an inline rewrite of only this method.

var packet = new ClientStandaloneWorldSnapshotPacket
{
tick = tick,
worldData = worldCompressed,
sessionData = sessionCompressed,
sha256Hash = hash,
};

OnMainThread.Enqueue(() => Multiplayer.Client?.SendFragmented(packet.Serialize()));
}
}

}
43 changes: 32 additions & 11 deletions Source/Client/Session/Autosaving.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.IO;
using System.Linq;
using Multiplayer.Common;
using Multiplayer.Common.Networking.Packet;
using RimWorld;
using UnityEngine;
using Verse;
Expand All @@ -14,8 +15,19 @@ public static void DoAutosave()
{
LongEventHandler.QueueLongEvent(() =>
{
SaveGameToFile_Overwrite(GetNextAutosaveFileName(), false);
Multiplayer.Client.Send(Packets.Client_Autosaving);
var snapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false);

if (!SaveGameToFile_Overwrite(GetNextAutosaveFileName(), snapshot))
return;

Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save));

// When connected to a standalone server, also upload fresh snapshots
if (Multiplayer.session?.ConnectedToStandaloneServer == true)
{
SaveLoad.SendStandaloneMapSnapshots(snapshot);
SaveLoad.SendStandaloneWorldSnapshot(snapshot);
}
}, "MpSaving", false, null);
}

Expand All @@ -33,30 +45,39 @@ private static string GetNextAutosaveFileName()
.First();
}

public static void SaveGameToFile_Overwrite(string fileNameNoExtension, bool currentReplay)
public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, bool currentReplay)
=> SaveGameToFile_Overwrite(fileNameNoExtension,
currentReplay ? Multiplayer.session.dataSnapshot : null);

public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, GameDataSnapshot snapshot)
{
Log.Message($"Multiplayer: saving to file {fileNameNoExtension}");

try
{
var tmp = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.tmp.zip"));
Replay.ForSaving(tmp).WriteData(
currentReplay ?
Multiplayer.session.dataSnapshot :
SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false)
var tmpPath = Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.tmp.zip");
if (File.Exists(tmpPath))
File.Delete(tmpPath);

Replay.ForSaving(new FileInfo(tmpPath)).WriteData(
snapshot ?? SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false)
);

var dst = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip"));
if (!dst.Exists) dst.Open(FileMode.Create).Close();
tmp.Replace(dst.FullName, null);
var dstPath = Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip");
if (File.Exists(dstPath))
File.Replace(tmpPath, dstPath, destinationBackupFileName: null);
else
File.Move(tmpPath, dstPath);

Messages.Message("MpGameSaved".Translate(fileNameNoExtension), MessageTypeDefOf.SilentInput, false);
Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup;
return true;
}
catch (Exception e)
{
Log.Error($"Exception saving multiplayer game as {fileNameNoExtension}: {e}");
Messages.Message("MpGameSaveFailed".Translate(), MessageTypeDefOf.SilentInput, false);
return false;
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SaveGameToFile_Overwrite now returns bool and swallows exceptions internally. Callers that relied on exceptions to detect failure (e.g., code wrapping this call in try/catch) will no longer observe failures unless they check the return value, and may continue follow-up steps assuming a save exists. Consider either re-throwing (and letting callers handle) or ensure all call sites are updated to handle the false return explicitly.

Suggested change
return false;
throw;

Copilot uses AI. Check for mistakes.
}
}
}
6 changes: 6 additions & 0 deletions Source/Client/Session/MultiplayerSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ public class MultiplayerSession : IConnectionStatusListener

public IConnector connector;
public BootstrapServerState bootstrapState = BootstrapServerState.None;
public bool isStandaloneServer;
public float autosaveInterval;
public AutosaveUnit autosaveUnit;
public bool ConnectedToStandaloneServer => client != null && isStandaloneServer;

public void ApplyBootstrapState(ServerBootstrapPacket packet) =>
bootstrapState = BootstrapServerState.FromPacket(packet);
Expand All @@ -64,6 +68,8 @@ public void ApplyBootstrapState(ServerBootstrapPacket packet) =>

public void Stop()
{
isStandaloneServer = false;

if (client != null)
{
client.Close(MpDisconnectReason.Internal);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,16 @@ private void CreateBootstrapReplaySave()
{
try
{
Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false);
if (!Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false))
{
OnMainThread.Enqueue(() =>
{
saveUploadStatus = "Save failed, see log for details.";
bootstrapSaveQueued = false;
});
return;
}

var path = Path.Combine(Multiplayer.ReplaysDir, $"{BootstrapSaveName}.zip");
OnMainThread.Enqueue(() => FinalizeBootstrapSave(path));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ private void DrawSettings(Rect entry, Rect inRect)
else if (tab == Tab.Preview)
DrawPreviewTab(contentRect, inRect.height);

settings.EnforceStandaloneRequirements();

settingsUiBuffers.MaxPlayersBuffer = buffers.MaxPlayersBuffer;
settingsUiBuffers.AutosaveBuffer = buffers.AutosaveBuffer;

Expand Down Expand Up @@ -143,6 +145,7 @@ private void StartUploadSettingsToml()
{
try
{
settings.EnforceStandaloneRequirements();
connection.Send(new ClientBootstrapSettingsPacket(settings));

OnMainThread.Enqueue(() =>
Expand Down
1 change: 1 addition & 0 deletions Source/Client/Windows/BootstrapConfiguratorWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public BootstrapConfiguratorWindow(ConnectionBase connection)
settings.directAddress = $"0.0.0.0:{MultiplayerServer.DefaultPort}";
settings.steam = false;
settings.arbiter = false;
settings.EnforceStandaloneRequirements();

settingsUiBuffers.MaxPlayersBuffer = settings.maxPlayers.ToString();
settingsUiBuffers.AutosaveBuffer = settings.autosaveInterval.ToString();
Expand Down
10 changes: 9 additions & 1 deletion Source/Client/Windows/SaveGameWindow.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using Multiplayer.Client.Util;
using Multiplayer.Common;
using RimWorld;
using System.Collections.Generic;
using System.IO;
using Multiplayer.Common.Networking.Packet;
using UnityEngine;
using Verse;

Expand Down Expand Up @@ -199,7 +201,13 @@ private void Accept(bool currentReplay)
{
if (curText.Length != 0)
{
LongEventHandler.QueueLongEvent(() => Autosaving.SaveGameToFile_Overwrite(curText, currentReplay), "MpSaving", false, null);
LongEventHandler.QueueLongEvent(() =>
{
if (!Autosaving.SaveGameToFile_Overwrite(curText, currentReplay))
return;

Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save));
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClientAutosavingPacket is sent after saving even when currentReplay is true. In replay sessions the connection ignores non-command packets, so this send is a no-op and adds confusing coupling between replay saving and network join-point logic. Consider skipping the send when currentReplay is true (or when Multiplayer.Client is null) to keep replay saves purely local.

Suggested change
Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save));
if (!currentReplay && Multiplayer.Client != null)
Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save));

Copilot uses AI. Check for mistakes.
}, "MpSaving", false, null);
Close();
}
}
Expand Down
2 changes: 1 addition & 1 deletion Source/Common/ChatCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public ChatCmdJoinPoint()

public override void Handle(IChatSource source, string[] args)
{
if (!Server.worldData.TryStartJoinPointCreation(true))
if (!Server.worldData.TryStartJoinPointCreation(true, sourcePlayer: source as ServerPlayer))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commands can not only be run by the players, but also by the server (by writing the command name in the console). Doesn't this change break the command when running from the server?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh i didnt know that, ill test it

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. In standalone this was indeed wrong for the server-console path.

The intent of passing sourcePlayer here was to preserve the issuing player when the command comes from a real player, because that later becomes the origin for CreateJoinPoint. But when the command is issued from the server console, source as ServerPlayer is null, and in standalone that can leave us without any client actually executing the join point creation.

I fixed that locally in WorldData.TryStartJoinPointCreation: if standalone starts a join point without a source player, it now falls back to a real playing player (prefer host, otherwise the first playing player), and aborts with a log if there is none. So the player-issued case keeps the original attribution, while the console-issued case still works correctly.

source.SendMsg("Join point creation already in progress.");
}
}
Expand Down
8 changes: 8 additions & 0 deletions Source/Common/JoinPointRequestReason.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Multiplayer.Common;

public enum JoinPointRequestReason : byte
{
Unknown = 0,
Save = 1,
WorldTravel = 2,
}
1 change: 1 addition & 0 deletions Source/Common/MultiplayerServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ static MultiplayerServer()
public int NetTimer { get; private set; }

public bool IsStandaloneServer { get; set; }
public StandalonePersistence? persistence;

public MultiplayerServer(ServerSettings settings)
{
Expand Down
Loading
Loading