448 lines
15 KiB
C#
448 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using NitroxModel.DataStructures.GameLogic;
|
|
using NitroxModel.Serialization;
|
|
using NitroxModel.Server;
|
|
using NitroxServer.GameLogic.Entities;
|
|
using NitroxServer.Serialization;
|
|
using NitroxServer.Serialization.World;
|
|
using Timer = System.Timers.Timer;
|
|
|
|
namespace NitroxServer;
|
|
|
|
public class Server
|
|
{
|
|
private readonly Communication.NitroxServer server;
|
|
private readonly WorldPersistence worldPersistence;
|
|
private readonly SubnauticaServerConfig serverConfig;
|
|
private readonly Timer saveTimer;
|
|
private readonly World world;
|
|
private readonly WorldEntityManager worldEntityManager;
|
|
private readonly EntityRegistry entityRegistry;
|
|
|
|
private CancellationTokenSource serverCancelSource;
|
|
|
|
public static Server Instance { get; private set; }
|
|
|
|
public bool IsRunning { get; private set; }
|
|
|
|
public bool IsSaving { get; private set; }
|
|
|
|
public string Name { get; private set; } = "My World";
|
|
public int Port => serverConfig?.ServerPort ?? -1;
|
|
|
|
public Server(WorldPersistence worldPersistence, World world, SubnauticaServerConfig serverConfig, Communication.NitroxServer server, WorldEntityManager worldEntityManager, EntityRegistry entityRegistry)
|
|
{
|
|
this.worldPersistence = worldPersistence;
|
|
this.serverConfig = serverConfig;
|
|
this.server = server;
|
|
this.world = world;
|
|
this.worldEntityManager = worldEntityManager;
|
|
this.entityRegistry = entityRegistry;
|
|
|
|
Instance = this;
|
|
|
|
saveTimer = new Timer();
|
|
saveTimer.Interval = serverConfig.SaveInterval;
|
|
saveTimer.AutoReset = true;
|
|
saveTimer.Elapsed += delegate
|
|
{
|
|
if (!serverConfig.DisableAutoBackup && serverConfig.MaxBackups != 0)
|
|
{
|
|
BackUp();
|
|
}
|
|
else
|
|
{
|
|
Save();
|
|
}
|
|
};
|
|
}
|
|
|
|
public string GetSaveSummary(Perms viewerPerms = Perms.CONSOLE)
|
|
{
|
|
// TODO: Extend summary with more useful save file data
|
|
// Note for later additions: order these lines by their length
|
|
StringBuilder builder = new("\n");
|
|
if (viewerPerms is Perms.CONSOLE)
|
|
{
|
|
builder.AppendLine($" - Save location: {Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), Name)}");
|
|
}
|
|
builder.AppendLine($"""
|
|
- Aurora's state: {world.StoryManager.GetAuroraStateSummary()}
|
|
- Current time: day {world.TimeKeeper.Day} ({Math.Floor(world.TimeKeeper.ElapsedSeconds)}s)
|
|
- Scheduled goals stored: {world.GameData.StoryGoals.ScheduledGoals.Count}
|
|
- Story goals completed: {world.GameData.StoryGoals.CompletedGoals.Count}
|
|
- Radio messages stored: {world.GameData.StoryGoals.RadioQueue.Count}
|
|
- World gamemode: {serverConfig.GameMode}
|
|
- Encyclopedia entries: {world.GameData.PDAState.EncyclopediaEntries.Count}
|
|
- Known tech: {world.GameData.PDAState.KnownTechTypes.Count}
|
|
""");
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
// TODO : Remove this method once server hosting/loading happens as a service (see '.NET Generic Host' on msdn)
|
|
public static SubnauticaServerConfig CreateOrLoadConfig()
|
|
{
|
|
string? saveDir = null;
|
|
if (GetSaveName(Environment.GetCommandLineArgs()) is { } saveName)
|
|
{
|
|
saveDir = Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), saveName);
|
|
}
|
|
|
|
if (Directory.Exists(saveDir))
|
|
{
|
|
return SubnauticaServerConfig.Load(saveDir);
|
|
}
|
|
|
|
// Check if there are any save files
|
|
List<ServerListing> saves = GetSaves();
|
|
|
|
if (saves.Count > 0)
|
|
{
|
|
// Get last save file used
|
|
string lastSaveAccessed = saves[0].SaveDir;
|
|
|
|
if (saves.Count > 1)
|
|
{
|
|
for (int i = 1; i < saves.Count; i++)
|
|
{
|
|
if (File.GetLastWriteTime(Path.Combine(saves[i].SaveDir, $"WorldData{ServerProtoBufSerializer.FILE_ENDING}")) > File.GetLastWriteTime(lastSaveAccessed))
|
|
{
|
|
lastSaveAccessed = saves[i].SaveDir;
|
|
}
|
|
else if (File.GetLastWriteTime(Path.Combine(saves[i].SaveDir, $"WorldData{ServerJsonSerializer.FILE_ENDING}")) > File.GetLastWriteTime(lastSaveAccessed))
|
|
{
|
|
lastSaveAccessed = saves[i].SaveDir;
|
|
}
|
|
}
|
|
}
|
|
|
|
saveDir = lastSaveAccessed;
|
|
}
|
|
else
|
|
{
|
|
// Create new save file
|
|
Log.Debug("No save file was found, creating a new one...");
|
|
saveDir = Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), "My World");
|
|
Directory.CreateDirectory(saveDir);
|
|
}
|
|
|
|
return SubnauticaServerConfig.Load(saveDir);
|
|
}
|
|
|
|
public void Save()
|
|
{
|
|
if (IsSaving)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsSaving = true;
|
|
|
|
bool savedSuccessfully = worldPersistence.Save(world, Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), Name));
|
|
if (savedSuccessfully && !string.IsNullOrWhiteSpace(serverConfig.PostSaveCommandPath))
|
|
{
|
|
try
|
|
{
|
|
// Call external tool for backups, etc
|
|
if (File.Exists(serverConfig.PostSaveCommandPath))
|
|
{
|
|
using Process process = Process.Start(serverConfig.PostSaveCommandPath);
|
|
Log.Info($"Post-save command completed successfully: {serverConfig.PostSaveCommandPath}");
|
|
}
|
|
else
|
|
{
|
|
Log.Error($"Post-save file does not exist: {serverConfig.PostSaveCommandPath}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Post-save command failed");
|
|
}
|
|
}
|
|
IsSaving = false;
|
|
}
|
|
|
|
public bool Start(string saveName, CancellationTokenSource ct)
|
|
{
|
|
Debug.Assert(serverCancelSource == null);
|
|
|
|
Validate.NotNull(ct);
|
|
if (ct.IsCancellationRequested)
|
|
{
|
|
return false;
|
|
}
|
|
if (!server.Start(ct.Token))
|
|
{
|
|
return false;
|
|
}
|
|
Name = saveName;
|
|
serverCancelSource = ct;
|
|
IsRunning = true;
|
|
|
|
if (!serverConfig.DisableAutoBackup)
|
|
{
|
|
worldPersistence.BackUp(Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), saveName));
|
|
}
|
|
|
|
try
|
|
{
|
|
if (serverConfig.CreateFullEntityCache)
|
|
{
|
|
Log.Info("Starting to load all batches up front.");
|
|
Log.Info("This can take up to several minutes and you can't join until it's completed.");
|
|
Log.Info($"{entityRegistry.GetAllEntities().Count} entities already cached");
|
|
if (entityRegistry.GetAllEntities().Count < 504732)
|
|
{
|
|
worldEntityManager.LoadAllUnspawnedEntities(serverCancelSource.Token);
|
|
|
|
Log.Info("Saving newly cached entities.");
|
|
Save();
|
|
}
|
|
Log.Info("All batches have now been loaded.");
|
|
}
|
|
}
|
|
catch (OperationCanceledException ex)
|
|
{
|
|
Log.Warn($"Server start was cancelled by user:{Environment.NewLine}{ex.Message}");
|
|
return false;
|
|
}
|
|
|
|
LogHowToConnectAsync().ContinueWithHandleError(ex => Log.Warn($"Failed to show how to connect: {ex.GetFirstNonAggregateMessage()}"));
|
|
Log.Info($"Server is listening on port {Port} UDP");
|
|
Log.Info($"Using {serverConfig.SerializerMode} as save file serializer");
|
|
Log.InfoSensitive("Server Password: {password}", string.IsNullOrEmpty(serverConfig.ServerPassword) ? "None. Public Server." : serverConfig.ServerPassword);
|
|
Log.InfoSensitive("Admin Password: {password}", serverConfig.AdminPassword);
|
|
Log.Info($"Autosave: {(serverConfig.DisableAutoSave ? "DISABLED" : $"ENABLED ({serverConfig.SaveInterval / 60000} min)")}");
|
|
Log.Info($"Autobackup: {(serverConfig.DisableAutoBackup || serverConfig.MaxBackups == 0 ? "DISABLED" : "ENABLED")} (Max Backups: {serverConfig.MaxBackups})");
|
|
Log.Info($"Loaded save\n{GetSaveSummary()}");
|
|
|
|
PauseServer();
|
|
|
|
return true;
|
|
}
|
|
|
|
public void Stop(bool shouldSave = true)
|
|
{
|
|
if (!IsRunning)
|
|
{
|
|
return;
|
|
}
|
|
IsRunning = false;
|
|
|
|
try
|
|
{
|
|
serverCancelSource.Cancel();
|
|
}
|
|
catch
|
|
{
|
|
// ignored
|
|
}
|
|
|
|
Log.Info("Nitrox Server Stopping...");
|
|
DisablePeriodicSaving();
|
|
|
|
if (shouldSave)
|
|
{
|
|
Save();
|
|
}
|
|
|
|
server.Stop();
|
|
Log.Info("Nitrox Server Stopped");
|
|
}
|
|
|
|
public void BackUp()
|
|
{
|
|
if (!IsRunning)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Save();
|
|
|
|
worldPersistence.BackUp(Path.Combine(KeyValueStore.Instance.GetSavesFolderDir(), Name));
|
|
}
|
|
|
|
private async Task LogHowToConnectAsync()
|
|
{
|
|
Task<IPAddress> localIp = Task.Run(NetHelper.GetLanIp);
|
|
Task<IPAddress> wanIp = NetHelper.GetWanIpAsync();
|
|
Task<IPAddress> hamachiIp = Task.Run(NetHelper.GetHamachiIp);
|
|
|
|
List<string> options = ["127.0.0.1 - You (Local)"];
|
|
if (await wanIp != null)
|
|
{
|
|
options.Add("{ip:l} - Friends on another internet network (Port Forwarding)");
|
|
}
|
|
if (await hamachiIp != null)
|
|
{
|
|
options.Add($"{hamachiIp.Result} - Friends using Hamachi (VPN)");
|
|
}
|
|
// LAN IP could be null if all Ethernet/Wi-Fi interfaces are disabled.
|
|
if (await localIp != null)
|
|
{
|
|
options.Add($"{localIp.Result} - Friends on same internet network (LAN)");
|
|
}
|
|
|
|
Log.InfoSensitive($"Use IP to connect:{Environment.NewLine}\t{string.Join($"{Environment.NewLine}\t", options)}", wanIp.Result);
|
|
}
|
|
|
|
public void StopAndWait(bool shouldSave = true)
|
|
{
|
|
Stop(shouldSave);
|
|
Log.Info("Press enter to continue");
|
|
Console.Read();
|
|
}
|
|
|
|
public void EnablePeriodicSaving()
|
|
{
|
|
saveTimer.Start();
|
|
}
|
|
|
|
public void DisablePeriodicSaving()
|
|
{
|
|
saveTimer.Stop();
|
|
}
|
|
|
|
public void PauseServer()
|
|
{
|
|
DisablePeriodicSaving();
|
|
world.TimeKeeper.StopCounting();
|
|
Log.Info("Server has paused, waiting for players to connect");
|
|
}
|
|
|
|
public void ResumeServer()
|
|
{
|
|
if (!serverConfig.DisableAutoSave)
|
|
{
|
|
EnablePeriodicSaving();
|
|
}
|
|
world.TimeKeeper.StartCounting();
|
|
Log.Info("Server has resumed");
|
|
}
|
|
|
|
private static List<ServerListing> GetSaves()
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(KeyValueStore.Instance.GetSavesFolderDir());
|
|
|
|
List<ServerListing> saves = [];
|
|
foreach (string saveDir in Directory.EnumerateDirectories(KeyValueStore.Instance.GetSavesFolderDir()))
|
|
{
|
|
try
|
|
{
|
|
ServerListing entryFromDir = ServerListing.Validate(saveDir);
|
|
if (entryFromDir != null)
|
|
{
|
|
saves.Add(entryFromDir);
|
|
}
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// ignored
|
|
}
|
|
}
|
|
|
|
return [.. saves.OrderByDescending(entry => entry.LastAccessedTime)];
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Error while getting saves");
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses the save name from the given command line arguments or defaults to the standard save name.
|
|
/// </summary>
|
|
// TODO : Remove this method once server hosting/loading happens as a service (see '.NET Generic Host' on msdn)
|
|
public static string GetSaveName(string[] args, string defaultValue = null)
|
|
{
|
|
string result = args.GetCommandArgs("--save").FirstOrDefault() ?? args.GetCommandArgs("--name").FirstOrDefault();
|
|
return IsValidSaveName(result) ? result : defaultValue;
|
|
}
|
|
|
|
private static bool IsValidSaveName(string name)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
return false;
|
|
}
|
|
if (name.StartsWith("--"))
|
|
{
|
|
return false;
|
|
}
|
|
if (name.EndsWith("."))
|
|
{
|
|
return false;
|
|
}
|
|
if (name.IndexOfAny(Path.GetInvalidFileNameChars().ToArray()) > -1)
|
|
{
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
internal class ServerListing
|
|
{
|
|
public string SaveDir { get; set; }
|
|
public Version SaveVersion { get; set; }
|
|
public DateTime LastAccessedTime { get; set; }
|
|
|
|
internal static ServerListing? Validate(string saveDir)
|
|
{
|
|
ServerListing serverListing = new();
|
|
if (!File.Exists(Path.Combine(saveDir, "server.cfg")))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
SubnauticaServerConfig config = SubnauticaServerConfig.Load(saveDir);
|
|
string fileEnding = config.SerializerMode switch
|
|
{
|
|
ServerSerializerMode.JSON => ServerJsonSerializer.FILE_ENDING,
|
|
ServerSerializerMode.PROTOBUF => ServerProtoBufSerializer.FILE_ENDING,
|
|
_ => throw new NotImplementedException()
|
|
};
|
|
|
|
string saveFileVersion = Path.Combine(saveDir, $"Version{fileEnding}");
|
|
if (!File.Exists(saveFileVersion))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Version version;
|
|
using (FileStream stream = new(saveFileVersion, FileMode.Open, FileAccess.Read, FileShare.Read))
|
|
{
|
|
version = config.SerializerMode switch
|
|
{
|
|
ServerSerializerMode.JSON => new ServerJsonSerializer().Deserialize<SaveFileVersion>(stream)?.Version ?? NitroxEnvironment.Version,
|
|
ServerSerializerMode.PROTOBUF => new ServerProtoBufSerializer().Deserialize<SaveFileVersion>(stream)?.Version ?? NitroxEnvironment.Version,
|
|
_ => throw new NotImplementedException()
|
|
};
|
|
}
|
|
|
|
serverListing.SaveDir = saveDir;
|
|
serverListing.SaveVersion = version;
|
|
serverListing.LastAccessedTime = File.GetLastWriteTime(File.Exists(Path.Combine(saveDir, $"PlayerData{fileEnding}"))
|
|
?
|
|
// This file is affected by server saving
|
|
Path.Combine(saveDir, $"PlayerData{fileEnding}")
|
|
:
|
|
// If the above file doesn't exist (server was never ran), use the Version file instead
|
|
Path.Combine(saveDir, $"Version{fileEnding}"));
|
|
|
|
return serverListing;
|
|
}
|
|
}
|