Add C# dedicated server replacement

This commit is contained in:
Nomads_Reach
2026-08-16 02:23:04 -04:00
parent 0024436ce8
commit ddadf87423
17 changed files with 3516 additions and 0 deletions
+317
View File
@@ -0,0 +1,317 @@
using System.Collections.Concurrent;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace CommonwealthOnline.Server;
internal readonly record struct ScopeKey(string CellId, string WorldspaceId) : IComparable<ScopeKey>
{
public int CompareTo(ScopeKey other)
{
var cell = string.Compare(CellId, other.CellId, StringComparison.Ordinal);
return cell != 0 ? cell : string.Compare(WorldspaceId, other.WorldspaceId, StringComparison.Ordinal);
}
}
internal readonly record struct StateScope(string CellId, string WorldspaceId, double X, double Y);
internal readonly record struct AuthorityAssignment(ScopeKey Scope, uint PlayerId, uint Epoch);
internal readonly record struct AuthorityChange(ScopeKey Scope, uint PreviousPlayerId, uint PlayerId, uint Epoch);
internal sealed class NpcAuthorityManager
{
private readonly Dictionary<ScopeKey, AuthorityAssignment> _assignments = new();
private readonly Dictionary<ScopeKey, uint> _lastEpoch = new();
public void Clear()
{
_assignments.Clear();
_lastEpoch.Clear();
}
public AuthorityAssignment? Get(ScopeKey scope) => _assignments.TryGetValue(scope, out var value) ? value : null;
public IReadOnlyCollection<AuthorityAssignment> Assignments => _assignments.OrderBy(x => x.Key).Select(x => x.Value).ToArray();
public bool Authorize(uint playerId, ScopeKey scope, uint epoch) =>
_assignments.TryGetValue(scope, out var assignment) && assignment.PlayerId == playerId && assignment.Epoch == epoch;
public IReadOnlyList<AuthorityChange> Reconcile(IEnumerable<(uint PlayerId, ScopeKey Scope)> players)
{
var desired = players
.Where(x => x.PlayerId > 0)
.GroupBy(x => x.Scope)
.ToDictionary(group => group.Key, group => group.Min(x => x.PlayerId));
var scopes = _assignments.Keys.Concat(desired.Keys).Distinct().OrderBy(x => x).ToArray();
var changes = new List<AuthorityChange>();
foreach (var scope in scopes)
{
var previous = _assignments.TryGetValue(scope, out var oldAssignment) ? oldAssignment.PlayerId : 0;
var next = desired.TryGetValue(scope, out var nextPlayer) ? nextPlayer : 0;
if (previous == next) continue;
var epoch = _lastEpoch.TryGetValue(scope, out var last) ? checked(last + 1) : 1;
_lastEpoch[scope] = epoch;
if (next == 0) _assignments.Remove(scope);
else _assignments[scope] = new AuthorityAssignment(scope, next, epoch);
changes.Add(new AuthorityChange(scope, previous, next, epoch));
}
return changes;
}
}
internal sealed class ClientSession
{
private readonly object _gate = new();
public ClientSession(IGameConnection connection, uint playerId)
{
Connection = connection;
PlayerId = playerId;
ConnectedAt = JsonHelpers.UnixTime();
RateWindowStart = MonotonicClock.Now;
}
public IGameConnection Connection { get; }
public uint PlayerId { get; }
public IPEndPoint RemoteEndpoint => Connection.RemoteEndpoint;
public string Label => $"{RemoteEndpoint.Address}:{RemoteEndpoint.Port}";
public double ConnectedAt { get; }
public double? LastPacketAt { get; private set; }
public JsonObject? LastTransform { get; private set; }
public double? LastTransformMonotonic { get; private set; }
public long PacketsReceived { get; private set; }
public long PacketsSent { get; private set; }
public long PacketsBroadcast { get; private set; }
public bool GameplayActive { get; private set; }
public int ProtocolVersion { get; private set; }
public double RateWindowStart { get; set; }
public int RateWindowCount { get; set; }
public int RateViolations { get; set; }
public bool RateWindowBlocked { get; set; }
public uint LastCombatSequence { get; set; }
public void RecordReceived()
{
lock (_gate)
{
LastPacketAt = JsonHelpers.UnixTime();
PacketsReceived++;
}
}
public void RecordTransform(JsonObject packet, double acceptedMonotonic)
{
lock (_gate)
{
LastTransform = JsonHelpers.CloneObject(packet);
LastTransformMonotonic = acceptedMonotonic;
}
}
public (JsonObject? Transform, double? Monotonic) TransformAnchor()
{
lock (_gate)
return (LastTransform is null ? null : JsonHelpers.CloneObject(LastTransform), LastTransformMonotonic);
}
public void RecordSent(bool broadcast)
{
lock (_gate)
{
PacketsSent++;
if (broadcast) PacketsBroadcast++;
}
}
public bool Activate(int protocolVersion)
{
lock (_gate)
{
if (GameplayActive) return false;
GameplayActive = true;
ProtocolVersion = protocolVersion;
return true;
}
}
public JsonObject Snapshot()
{
lock (_gate)
{
return new JsonObject
{
["playerId"] = PlayerId,
["address"] = RemoteEndpoint.Address.ToString(),
["port"] = RemoteEndpoint.Port,
["connectedAt"] = ConnectedAt,
["lastPacketAt"] = LastPacketAt,
["lastTransform"] = LastTransform?.DeepClone(),
["packetsReceived"] = PacketsReceived,
["packetsSent"] = PacketsSent,
["packetsBroadcast"] = PacketsBroadcast,
["gameplayActive"] = GameplayActive,
["protocolVersion"] = ProtocolVersion
};
}
}
}
internal readonly record struct BanEntry(string Ip, string Reason, double BannedAt);
internal sealed class BanStore
{
private readonly object _gate = new();
private readonly string _path;
private Dictionary<string, BanEntry> _bans = new(StringComparer.Ordinal);
public BanStore(string path)
{
_path = Path.GetFullPath(path);
Load();
}
public BanEntry? GetBan(string ip)
{
if (!TryNormalizeIp(ip, out var normalized)) return null;
lock (_gate) return _bans.TryGetValue(normalized, out var entry) ? entry : null;
}
public IReadOnlyList<BanEntry> List()
{
lock (_gate) return _bans.Values.OrderBy(x => x.Ip, StringComparer.Ordinal).ToArray();
}
public BanEntry Ban(string ip, string reason)
{
if (!TryNormalizeIp(ip, out var normalized)) throw new ArgumentException($"Invalid IP address: '{ip}'", nameof(ip));
var entry = new BanEntry(normalized, (reason ?? string.Empty)[..Math.Min((reason ?? string.Empty).Length, 1024)], JsonHelpers.UnixTime());
lock (_gate)
{
_bans[normalized] = entry;
SaveLocked();
}
return entry;
}
public bool Unban(string ip)
{
if (!TryNormalizeIp(ip, out var normalized)) return false;
lock (_gate)
{
if (!_bans.Remove(normalized)) return false;
SaveLocked();
return true;
}
}
private void Load()
{
lock (_gate)
{
if (!File.Exists(_path)) { _bans = new(StringComparer.Ordinal); return; }
JsonObject root;
try
{
root = JsonNode.Parse(File.ReadAllBytes(_path)) as JsonObject ?? throw new InvalidDataException("root is not an object");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
{
throw new InvalidOperationException($"Could not load ban file {_path}: {ex.Message}", ex);
}
if (root["banned_ips"] is not JsonArray array) throw new InvalidOperationException($"Ban file {_path} has an invalid schema.");
var bans = new Dictionary<string, BanEntry>(StringComparer.Ordinal);
var invalid = 0;
foreach (var node in array)
{
if (node is not JsonObject item || !TryNormalizeIp(JsonHelpers.String(item["ip"]) ?? string.Empty, out var ip)) { invalid++; continue; }
var reason = JsonHelpers.String(item["reason"]) ?? string.Empty;
if (reason.Length > 1024) reason = reason[..1024];
var bannedAt = JsonHelpers.TryDouble(item["bannedAt"] ?? item["banned_at"], double.MinValue, double.MaxValue, out var value) ? value : 0.0;
bans[ip] = new BanEntry(ip, reason, bannedAt);
}
if (invalid > 0) throw new InvalidOperationException($"Ban file {_path} contains {invalid} invalid {(invalid == 1 ? "entry" : "entries")}.");
_bans = bans;
}
}
private void SaveLocked()
{
var directory = Path.GetDirectoryName(_path)!;
Directory.CreateDirectory(directory);
var root = new JsonObject
{
["banned_ips"] = new JsonArray(_bans.Values.OrderBy(x => x.Ip, StringComparer.Ordinal).Select(entry => (JsonNode)new JsonObject
{
["ip"] = entry.Ip,
["reason"] = entry.Reason,
["bannedAt"] = entry.BannedAt
}).ToArray())
};
var temp = Path.Combine(directory, $".{Path.GetFileName(_path)}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp");
using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
{
JsonSerializer.Serialize(stream, root, new JsonSerializerOptions { WriteIndented = true });
stream.WriteByte((byte)'\n');
stream.Flush(true);
}
TryRestrictPermissions(temp);
File.Move(temp, _path, true);
TryRestrictPermissions(_path);
}
private static bool TryNormalizeIp(string value, out string normalized)
{
normalized = string.Empty;
if (!IPAddress.TryParse(value.Trim(), out var address)) return false;
normalized = address.ToString();
return true;
}
internal static void TryRestrictPermissions(string path)
{
if (OperatingSystem.IsWindows()) return;
try { File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
}
}
internal static class MonotonicClock
{
private static readonly double Frequency = System.Diagnostics.Stopwatch.Frequency;
public static double Now => System.Diagnostics.Stopwatch.GetTimestamp() / Frequency;
}
internal static class WorldStatePresets
{
public static readonly IReadOnlyList<(string Label, string Value)> Weather = new (string, string)[]
{
("Clear", "0002b52a"), ("Cloudy", "001cc186"), ("Overcast", "001c8556"), ("Fog", "001c3473"),
("Rain", "001ca7e4"), ("Radstorm", "001c3d5e"), ("Glowing Sea", "000f1033")
};
public static readonly IReadOnlyList<(string Label, string Value)> Time = new (string, string)[]
{
("Midnight", "0000"), ("Dawn", "0600"), ("Morning", "0900"), ("Noon", "1200"),
("Afternoon", "1500"), ("Evening", "1800"), ("Dusk (7 PM)", "1900"), ("Night", "2200")
};
public static string NormalizeWeatherConsoleArg(string value)
{
var text = value.Trim();
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) text = text[2..];
if (!uint.TryParse(text, System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var parsed))
throw new ArgumentException("invalid weather form id", nameof(value));
return parsed.ToString("x8", System.Globalization.CultureInfo.InvariantCulture);
}
public static string RelayWeatherFormId(string value) => uint.Parse(value, System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture).ToString("X8", System.Globalization.CultureInfo.InvariantCulture);
public static double? HhmmToGameHour(string hhmm)
{
var text = hhmm.Trim();
if (text.Length is < 1 or > 4 || !text.All(char.IsDigit)) return null;
text = text.PadLeft(4, '0');
var hours = int.Parse(text[..2]);
var minutes = int.Parse(text[2..]);
return hours > 23 || minutes > 59 ? null : hours + minutes / 60.0;
}
}