panel — open-source game server manager (public release)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:19:43 -07:00
commit 5232609719
2160 changed files with 300415 additions and 0 deletions
@@ -0,0 +1,3 @@
bin/
obj/
*.user
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<RootNamespace>EmpyrionBridge</RootNamespace>
<AssemblyName>panel-empyrion-bridge</AssemblyName>
<LangVersion>12</LangVersion>
<NoWarn>CS8981;CS0414;CS8632;CS8625;CS8600;CS8601;CS8602;CS8603;CS8604;CS8618;CS8619</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="protobuf-net" Version="3.2.30" />
</ItemGroup>
<ItemGroup>
<Reference Include="Mif">
<HintPath>..\lib\Mif.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
</Project>
@@ -0,0 +1,149 @@
using System.Collections.Concurrent;
using Eleon.Modding;
using EPMConnector;
namespace EmpyrionBridge;
// Wraps an EPMConnector.Client with a request/response correlation layer.
// EPM replies with the same seqNr we sent, so a TaskCompletionSource keyed
// by seqNr resolves on the matching Event_* package.
public class BridgeConnection : IDisposable
{
public string Host { get; }
public int Port { get; }
public bool IsConnected => _connected;
readonly Client _client;
readonly ConcurrentDictionary<ushort, Pending> _pending = new();
readonly ILogger _log;
int _seq;
volatile bool _connected;
// Subscribers receive every package not consumed by a pending request,
// i.e. EPM's broadcasts of player join/leave, chat, structure changes,
// PDA state, dedi stats heartbeats, etc. Used by the SSE fan-out.
readonly ConcurrentDictionary<int, Action<ModProtocol.Package>> _subscribers = new();
int _subscriberId;
record Pending(CmdId ExpectedResponse, TaskCompletionSource<ModProtocol.Package> Tcs);
public IDisposable Subscribe(Action<ModProtocol.Package> handler)
{
var id = Interlocked.Increment(ref _subscriberId);
_subscribers[id] = handler;
return new Subscription(this, id);
}
sealed class Subscription : IDisposable
{
readonly BridgeConnection _conn;
readonly int _id;
public Subscription(BridgeConnection c, int id) { _conn = c; _id = id; }
public void Dispose() => _conn._subscribers.TryRemove(_id, out _);
}
public BridgeConnection(string host, int port, ILogger log)
{
Host = host;
Port = port;
_log = log;
_client = new Client(clientId: 0);
_client.ClientMessages += msg => _log.LogDebug("EPM[{Host}:{Port}]: {Msg}", host, port, msg);
_client.OnConnected += () => { _connected = true; _log.LogInformation("EPM[{Host}:{Port}] connected", host, port); };
_client.GameEventReceived += OnPackage;
}
public Task ConnectAsync(TimeSpan timeout)
{
_client.Connect(Host, Port);
return WaitForConnect(timeout);
}
async Task WaitForConnect(TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!_connected && DateTime.UtcNow < deadline)
{
await Task.Delay(100);
}
if (!_connected) throw new TimeoutException($"EPM at {Host}:{Port} did not respond within {timeout.TotalSeconds:F0}s — is the mod loaded?");
}
public async Task<T> RequestAsync<T>(CmdId requestCmd, CmdId expectedResponse, object requestData, TimeSpan timeout) where T : class
{
if (!_connected) throw new InvalidOperationException("not connected");
var seq = (ushort)(Interlocked.Increment(ref _seq) & 0xFFFF);
var tcs = new TaskCompletionSource<ModProtocol.Package>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[seq] = new Pending(expectedResponse, tcs);
try
{
_client.Send(requestCmd, seq, requestData);
var winner = await Task.WhenAny(tcs.Task, Task.Delay(timeout));
if (winner != tcs.Task)
{
throw new TimeoutException($"no {expectedResponse} response to {requestCmd} within {timeout.TotalSeconds:F0}s");
}
var pkg = await tcs.Task;
return pkg.data as T;
}
finally
{
_pending.TryRemove(seq, out _);
}
}
public void SendFireAndForget(CmdId cmd, object data)
{
if (!_connected) throw new InvalidOperationException("not connected");
var seq = (ushort)(Interlocked.Increment(ref _seq) & 0xFFFF);
_client.Send(cmd, seq, data);
}
void OnPackage(ModProtocol.Package p)
{
_log.LogDebug("EPM[{Host}:{Port}] <== cmd={Cmd} seq={Seq} dataType={Type}",
Host, Port, p.cmd, p.seqNr, p.data?.GetType()?.Name ?? "null");
// Match a pending request by seqNr. Resolve on exact-cmd match
// (success) OR on Event_Error / Event_Ok (server-side failure /
// empty success); otherwise fall through to the SSE subscribers.
if (_pending.TryGetValue(p.seqNr, out var pending))
{
if (pending.ExpectedResponse == p.cmd)
{
_pending.TryRemove(p.seqNr, out _);
pending.Tcs.TrySetResult(p);
return;
}
if (p.cmd == CmdId.Event_Error || p.cmd == CmdId.Event_Ok)
{
// EPM sends Event_Error for invalid requests (e.g. asking
// for a non-existent player) AND for requests that have
// no data to return (e.g. Request_GlobalStructure_Update
// on a server with no structures). Either way, resolve
// the pending request with a null payload — caller's
// typed cast returns null and they render an empty list.
_pending.TryRemove(p.seqNr, out _);
pending.Tcs.TrySetResult(new ModProtocol.Package { cmd = pending.ExpectedResponse, seqNr = p.seqNr, data = null });
return;
}
}
// Broadcast to SSE subscribers. Catch failures per subscriber so a
// slow / failing one can't block the others.
foreach (var kv in _subscribers)
{
try { kv.Value(p); }
catch (Exception ex) { _log.LogWarning(ex, "subscriber {Id} threw", kv.Key); }
}
}
public void Dispose()
{
try { _client.Disconnect(); } catch { /* best effort */ }
foreach (var pending in _pending.Values) pending.Tcs.TrySetCanceled();
_pending.Clear();
}
}
@@ -0,0 +1,85 @@
using System.Collections.Concurrent;
namespace EmpyrionBridge;
// Lazy connection pool keyed by host:port. Reuses an EPM TCP connection
// across HTTP requests; drops it after IdleTtl with no traffic.
public class BridgePool : IAsyncDisposable
{
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(8);
public TimeSpan IdleTtl { get; init; } = TimeSpan.FromMinutes(5);
readonly ConcurrentDictionary<string, Entry> _entries = new();
readonly ILoggerFactory _logFactory;
readonly ILogger<BridgePool> _log;
readonly Timer _reaper;
public BridgePool(ILoggerFactory logFactory)
{
_logFactory = logFactory;
_log = logFactory.CreateLogger<BridgePool>();
_reaper = new Timer(Reap, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
public async Task<BridgeConnection> GetAsync(string host, int port)
{
var key = $"{host}:{port}";
Entry e;
lock (_entries)
{
if (!_entries.TryGetValue(key, out e))
{
e = new Entry { Conn = new BridgeConnection(host, port, _logFactory.CreateLogger("EPM")) };
_entries[key] = e;
}
e.LastUsed = DateTime.UtcNow;
}
if (!e.Conn.IsConnected)
{
await e.ConnectGate.WaitAsync();
try
{
if (!e.Conn.IsConnected)
{
await e.Conn.ConnectAsync(ConnectTimeout);
}
}
finally
{
e.ConnectGate.Release();
}
}
return e.Conn;
}
void Reap(object _)
{
var cutoff = DateTime.UtcNow - IdleTtl;
foreach (var kv in _entries.ToArray())
{
if (kv.Value.LastUsed < cutoff)
{
if (_entries.TryRemove(kv.Key, out var e))
{
_log.LogInformation("reaping idle connection {Key}", kv.Key);
e.Conn.Dispose();
}
}
}
}
public async ValueTask DisposeAsync()
{
await _reaper.DisposeAsync();
foreach (var e in _entries.Values) e.Conn.Dispose();
_entries.Clear();
}
class Entry
{
public BridgeConnection Conn = null!;
public DateTime LastUsed = DateTime.UtcNow;
public SemaphoreSlim ConnectGate = new(1, 1);
}
}
@@ -0,0 +1,103 @@
using System;
using System.Net.Sockets;
using System.Threading;
using Eleon.Modding;
namespace EPMConnector
{
public class Client
{
ModThreadHelper.Info connectToServerThread;
public event Action OnConnected;
public event Action<String> ClientMessages;
public event Action<ModProtocol.Package> GameEventReceived;
volatile ModProtocol client;
int clientId;
string gameServerIp;
int gameServerPort;
public Client(int clientId)
{
this.clientId = clientId;
}
public void Connect(string ipAddress, int port)
{
this.gameServerIp = ipAddress;
this.gameServerPort = port;
connectToServerThread = ModThreadHelper.StartThread(ThreadConnectToServer, System.Threading.ThreadPriority.Lowest);
}
private void ThreadConnectToServer(ModThreadHelper.Info ti)
{
ClientMessages(string.Format("ModInterface: Started connection thread. Connecting to {0}:{1}", this.gameServerIp, this.gameServerPort));
while (!ti.eventRunning.WaitOne(0))
{
if (client == null)
{
try
{
TcpClient tcpClient = new TcpClient(this.gameServerIp, this.gameServerPort);
tcpClient.ReceiveBufferSize = 10 * 1024 * 1024;
tcpClient.SendBufferSize = 10 * 1024 * 1024;
client = new ModProtocol(tcpClient, PackageReceivedDelegate, DisconnectedDelegate);
ClientMessages("ModInterface: Connected with " + client + " over port " + this.gameServerPort);
OnConnected?.Invoke();
}
catch (SocketException)
{
// Ignore
}
catch (Exception e)
{
ClientMessages(e.GetType() + ": " + e.Message);
client = null;
}
}
Thread.Sleep(1000);
}
}
// Called in a thread!
private void PackageReceivedDelegate(ModProtocol con, ModProtocol.Package p)
{
GameEventReceived(p);
}
// Called in a thread!
private void DisconnectedDelegate(ModProtocol prot)
{
ClientMessages("DisconnectedDelegate called");
Disconnect();
}
public void Send(CmdId cmdId, ushort seqNr, object data)
{
//ClientMessages("Sending request event: c=" + cmdId + " sNr=" + seqNr + " d=" + data + " client="+client);
// Send events of the network
ModProtocol c = client;
if (c != null)
{
ModProtocol.Package p = new ModProtocol.Package(cmdId, clientId, seqNr, data);
c.AddToSendQueue(p);
}
}
public void Disconnect()
{
if(client != null)
{
client.Close();
client = null;
}
}
}
}
@@ -0,0 +1,92 @@
using System;
namespace EPMConnector
{
public class ModLoging
{
public static string Pfad = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) + "\\";
public static string Datei = "Log.txt";
public enum eTyp
{
Information,
Warning,
Exception
}
public static Exception LastException = null;
public static void Log_Exception(Exception ex, string ZusatzInfo = "", string LogDatei = "")
{
Log("Error: " + ex.ToString() + ": " + (string.IsNullOrEmpty(ZusatzInfo) ? "" : "More Infos: " + ZusatzInfo + " - "), eTyp.Exception, LogDatei);
}
public static void Log(string Message, eTyp Typ = eTyp.Information, string LogDatei = "")
{
System.IO.FileStream stmFile = default(System.IO.FileStream);
System.IO.StreamWriter binWriter = default(System.IO.StreamWriter);
bool FileExists = false;
try
{
if (string.IsNullOrEmpty(LogDatei))
{
if (string.IsNullOrEmpty(Datei))
Datei = "Log.txt";
if (System.IO.Directory.Exists(Pfad) == false)
{
return;
}
LogDatei = Pfad + Datei;
}
else
{
if (System.IO.Directory.Exists(System.IO.Path.GetDirectoryName(LogDatei)) == false)
{
return;
}
}
FileExists = System.IO.File.Exists(LogDatei);
stmFile = new System.IO.FileStream(LogDatei, System.IO.FileMode.Append, System.IO.FileAccess.Write);
binWriter = new System.IO.StreamWriter(stmFile);
if (FileExists == false)
binWriter.WriteLine("Date" + "\t" + "Time " + "\t" + "Status" + "\t" + "Message");
binWriter.WriteLine(Get_LogString(Message, Typ));
binWriter.Flush();
binWriter.Close();
stmFile.Close();
}
catch (Exception)
{
}
}
public static string Get_LogString(string Message, eTyp Typ = eTyp.Information)
{
string functionReturnValue = null;
switch (Typ)
{
case eTyp.Information:
functionReturnValue = String.Format("{0:dd.MM.yyyy}", DateTime.Now) + "\t" + String.Format("{0:HH:mm:ss.fff}", DateTime.Now) + "\t" + "I" + "\t" + Message;
break;
case eTyp.Warning:
functionReturnValue = String.Format("{0:dd.MM.yyyy}", DateTime.Now) + "\t" + String.Format("{0:HH:mm:ss.fff}", DateTime.Now) + "\t" + "W" + "\t" + Message;
break;
case eTyp.Exception:
functionReturnValue = String.Format("{0:dd.MM.yyyy}", DateTime.Now) + "\t" + String.Format("{0:HH:mm:ss.fff}", DateTime.Now) + "\t" + "E" + "\t" + Message;
break;
default:
functionReturnValue = "";
break;
}
return functionReturnValue;
}
}
}
@@ -0,0 +1,776 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Sockets;
using System.Threading;
using ProtoBuf;
using Eleon.Modding;
namespace EPMConnector
{
public class ModProtocol
{
public struct Package
{
public CmdId cmd;
public int clientId;
public object data;
public ushort seqNr;
public Package(CmdId cmdId, int nClientId, ushort nSeqNr, object nData)
{
cmd = cmdId;
clientId = nClientId;
seqNr = nSeqNr;
data = nData;
}
}
public delegate void DelegatePackageReceived(ModProtocol con, Package p);
private DelegatePackageReceived packageReceivedDelegate;
public delegate void DelegateDisconnected(ModProtocol con);
private DelegateDisconnected disconnectedDelegate;
public readonly TcpClient tcpClient;
private List<Package> writingPackages = new List<Package>();
ModThreadHelper.Info readerThread;
ModThreadHelper.Info writerThread;
volatile BinaryReader readerStream;
volatile BinaryWriter writerStream;
AutoResetEvent evWritePackages = new AutoResetEvent(false);
volatile bool bDisconnected = false;
public ModProtocol(TcpClient nTcpClient, DelegatePackageReceived nPackageReceivedDelegate, DelegateDisconnected nDisconnectedDelegate)
{
tcpClient = nTcpClient;
packageReceivedDelegate = nPackageReceivedDelegate;
disconnectedDelegate = nDisconnectedDelegate;
readerThread = ModThreadHelper.StartThread("Reader-" + tcpClient.Client.RemoteEndPoint, ReaderThread, ThreadPriority.Lowest);
writerThread = ModThreadHelper.StartThread("Writer-" + tcpClient.Client.RemoteEndPoint, WriterThread, ThreadPriority.Lowest);
}
public void AddToSendQueue(Package p)
{
lock (writingPackages)
{
writingPackages.Add(p);
}
evWritePackages.Set();
}
private void ReaderThread(ModThreadHelper.Info ti)
{
readerStream = new BinaryReader(tcpClient.GetStream());
try
{
while (!ti.eventRunning.WaitOne(0))
{
CmdId cmd = (CmdId)readerStream.ReadByte();
int clientId = readerStream.ReadInt32();
ushort seqNr = readerStream.ReadUInt16();
int len = readerStream.ReadInt32();
int bytesRead = 0;
byte[] data = null;
if (len > 0)
{
data = new byte[len]; // allocations are bad! maybe use pooled buffers?
do
{
bytesRead += readerStream.Read(data, bytesRead, (len - bytesRead));
} while (bytesRead < len && !readerThread.eventRunning.WaitOne(0));
}
object obj = null;
if (len > 0)
{
MemoryStream ms = new MemoryStream(data);
switch (cmd)
{
case Eleon.Modding.CmdId.Event_Player_Connected:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_Disconnected:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_ChangedPlayfield:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdPlayfield>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_Inventory:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Inventory>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_List:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdList>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_Info:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PlayerInfo>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_Info:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_GetInventory:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_SetInventory:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Inventory>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_AddItem:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdItemStack>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_Credits:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_SetCredits:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdCredits>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_AddCredits:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdCredits>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_Credits:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdCredits>(ms);
break;
case Eleon.Modding.CmdId.Request_Entity_PosAndRot:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Event_Entity_PosAndRot:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdPositionRotation>(ms);
break;
case Eleon.Modding.CmdId.Request_InGameMessage_SinglePlayer:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdMsgPrio>(ms);
break;
case Eleon.Modding.CmdId.Request_InGameMessage_AllPlayers:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdMsgPrio>(ms);
break;
case Eleon.Modding.CmdId.Request_ShowDialog_SinglePlayer:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.DialogBoxData>(ms);
break;
case Eleon.Modding.CmdId.Event_DialogButtonIndex:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdAndIntValue>(ms);
break;
case Eleon.Modding.CmdId.Event_Playfield_Loaded:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PlayfieldLoad>(ms);
break;
case Eleon.Modding.CmdId.Event_Playfield_Unloaded:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PlayfieldLoad>(ms);
break;
case Eleon.Modding.CmdId.Request_Playfield_Stats:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PString>(ms);
break;
case Eleon.Modding.CmdId.Event_Playfield_Stats:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PlayfieldStats>(ms);
break;
case Eleon.Modding.CmdId.Event_Dedi_Stats:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.DediStats>(ms);
break;
case Eleon.Modding.CmdId.Event_Playfield_List:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PlayfieldList>(ms);
break;
case Eleon.Modding.CmdId.Event_GlobalStructure_List:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.GlobalStructureList>(ms);
break;
case Eleon.Modding.CmdId.Request_Entity_Teleport:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdPositionRotation>(ms);
break;
case Eleon.Modding.CmdId.Request_GlobalStructure_Update:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PString>(ms);
break;
case Eleon.Modding.CmdId.Event_Faction_Changed:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.FactionChangeInfo>(ms);
break;
case CmdId.Request_Blueprint_Finish:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Request_Blueprint_Resources:
obj = Serializer.Deserialize<BlueprintResources>(ms);
break;
case CmdId.Request_Player_ChangePlayerfield:
obj = Serializer.Deserialize<IdPlayfieldPositionRotation>(ms);
break;
case CmdId.Request_Entity_ChangePlayfield:
obj = Serializer.Deserialize<IdPlayfieldPositionRotation>(ms);
break;
case CmdId.Request_Entity_Destroy:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Request_Player_ItemExchange:
case CmdId.Event_Player_ItemExchange:
obj = Serializer.Deserialize<ItemExchangeInfo>(ms);
break;
case CmdId.Event_Statistics:
obj = Serializer.Deserialize<StatisticsParam>(ms);
break;
case CmdId.Event_Error:
obj = Serializer.Deserialize<ErrorInfo>(ms);
break;
case CmdId.Request_Structure_Touch:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Request_Get_Factions:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Event_Get_Factions:
obj = Serializer.Deserialize<FactionInfoList>(ms);
break;
case CmdId.Request_Player_SetPlayerInfo:
obj = Serializer.Deserialize<PlayerInfoSet>(ms);
break;
case CmdId.Event_NewEntityId:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Request_Entity_Spawn:
obj = Serializer.Deserialize<EntitySpawnInfo>(ms);
break;
case CmdId.Event_Player_DisconnectedWaiting:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Event_ChatMessage:
obj = Serializer.Deserialize<ChatInfo>(ms);
break;
case CmdId.Request_ConsoleCommand:
obj = Serializer.Deserialize<PString>(ms);
break;
case CmdId.Request_Structure_BlockStatistics:
obj = Serializer.Deserialize<Id>(ms);
break;
case CmdId.Event_Structure_BlockStatistics:
obj = Serializer.Deserialize<IdStructureBlockInfo>(ms);
break;
case CmdId.Event_AlliancesAll:
obj = Serializer.Deserialize<AlliancesTable>(ms);
break;
case CmdId.Request_AlliancesFaction:
obj = Serializer.Deserialize<AlliancesFaction>(ms);
break;
case CmdId.Event_AlliancesFaction:
obj = Serializer.Deserialize<AlliancesFaction>(ms);
break;
case CmdId.Event_BannedPlayers:
obj = Serializer.Deserialize<BannedPlayerData>(ms);
break;
case Eleon.Modding.CmdId.Request_InGameMessage_Faction:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdMsgPrio>(ms);
break;
case Eleon.Modding.CmdId.Event_TraderNPCItemSold:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.TraderNPCItemSoldInfo>(ms);
break;
case Eleon.Modding.CmdId.Request_Player_GetAndRemoveInventory:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Id>(ms);
break;
case Eleon.Modding.CmdId.Event_Player_GetAndRemoveInventory:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.Inventory>(ms);
break;
case Eleon.Modding.CmdId.Request_Playfield_Entity_List:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PString>(ms);
break;
case Eleon.Modding.CmdId.Event_Playfield_Entity_List:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PlayfieldEntityList>(ms);
break;
case Eleon.Modding.CmdId.Request_Entity_Destroy2:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdPlayfield>(ms);
break;
case Eleon.Modding.CmdId.Request_Entity_Export:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.EntityExportInfo>(ms);
break;
case Eleon.Modding.CmdId.Event_ConsoleCommand:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.ConsoleCommandInfo>(ms);
break;
case Eleon.Modding.CmdId.Request_Entity_SetName:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.IdPlayfieldName>(ms);
break;
case Eleon.Modding.CmdId.Event_PdaStateChange:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.PdaStateInfo>(ms);
break;
case Eleon.Modding.CmdId.Event_GameEvent:
obj = ProtoBuf.Serializer.Deserialize<Eleon.Modding.GameEventData>(ms);
break;
}
}
if (packageReceivedDelegate != null && bytesRead == len)
{
packageReceivedDelegate(this, new Package(cmd, clientId, seqNr, obj));
}
}
}
catch (IOException e)
{
Console.WriteLine(string.Format("Connection closed while reading ({0})", e.Message));
ModLoging.Log(string.Format("MDP: Connection closed while reading ({0})", e.Message), ModLoging.eTyp.Exception);
CloseConnection();
}
catch (ObjectDisposedException e)
{
Console.WriteLine(string.Format("ObjectDisposed: Connection closed while reading ({0})", e.Message));
ModLoging.Log(string.Format("MDP: ObjectDisposed: Connection closed while reading ({0})", e.Message), ModLoging.eTyp.Exception);
CloseConnection();
}
catch (Exception e)
{
Console.WriteLine(string.Format("Exception while reading ({0})", e.Message));
ModLoging.Log(string.Format("MDP: Exception while reading ({0})", e.Message), ModLoging.eTyp.Exception);
Console.WriteLine(e.GetType() + ": " + e.Message);
CloseConnection();
}
}
private void WriterThread(ModThreadHelper.Info ti)
{
writerStream = new BinaryWriter(tcpClient.GetStream());
List<Package> writingPackagesCopy = new List<Package>();
try
{
while (!ti.eventRunning.WaitOne(0))
{
evWritePackages.WaitOne();
// Do a copy of the list else we need to hold the lock when writing and this could block the main thread
writingPackagesCopy.Clear();
lock (writingPackages)
{
writingPackagesCopy.AddRange(writingPackages);
writingPackages.Clear();
}
for (int i = 0; i < writingPackagesCopy.Count; i++)
{
Package p = writingPackagesCopy[i];
MemoryStream ms = new MemoryStream();
switch (p.cmd)
{
case Eleon.Modding.CmdId.Event_Player_Connected:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_Disconnected:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_ChangedPlayfield:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPlayfield>(ms, (Eleon.Modding.IdPlayfield)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_Inventory:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Inventory>(ms, (Eleon.Modding.Inventory)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_Info:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayerInfo>(ms, (Eleon.Modding.PlayerInfo)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_List:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdList>(ms, (Eleon.Modding.IdList)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_Info:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_GetInventory:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_SetInventory:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Inventory>(ms, (Eleon.Modding.Inventory)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_AddItem:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdItemStack>(ms, (Eleon.Modding.IdItemStack)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_Credits:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_SetCredits:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdCredits>(ms, (Eleon.Modding.IdCredits)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_AddCredits:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdCredits>(ms, (Eleon.Modding.IdCredits)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_Credits:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdCredits>(ms, (Eleon.Modding.IdCredits)p.data);
break;
case Eleon.Modding.CmdId.Request_Entity_PosAndRot:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Event_Entity_PosAndRot:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPositionRotation>(ms, (Eleon.Modding.IdPositionRotation)p.data);
break;
case Eleon.Modding.CmdId.Request_InGameMessage_AllPlayers:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdMsgPrio>(ms, (Eleon.Modding.IdMsgPrio)p.data);
break;
case Eleon.Modding.CmdId.Request_InGameMessage_SinglePlayer:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdMsgPrio>(ms, (Eleon.Modding.IdMsgPrio)p.data);
break;
case Eleon.Modding.CmdId.Request_ShowDialog_SinglePlayer:
ProtoBuf.Serializer.Serialize<Eleon.Modding.DialogBoxData>(ms, (Eleon.Modding.DialogBoxData)p.data);
break;
case Eleon.Modding.CmdId.Event_DialogButtonIndex:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdAndIntValue>(ms, (Eleon.Modding.IdAndIntValue)p.data);
break;
case Eleon.Modding.CmdId.Event_Playfield_Loaded:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayfieldLoad>(ms, (Eleon.Modding.PlayfieldLoad)p.data);
break;
case Eleon.Modding.CmdId.Event_Playfield_Unloaded:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayfieldLoad>(ms, (Eleon.Modding.PlayfieldLoad)p.data);
break;
case Eleon.Modding.CmdId.Request_Playfield_Stats:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PString>(ms, (Eleon.Modding.PString)p.data);
break;
case Eleon.Modding.CmdId.Event_Playfield_Stats:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayfieldStats>(ms, (Eleon.Modding.PlayfieldStats)p.data);
break;
case Eleon.Modding.CmdId.Event_Dedi_Stats:
ProtoBuf.Serializer.Serialize<Eleon.Modding.DediStats>(ms, (Eleon.Modding.DediStats)p.data);
break;
case Eleon.Modding.CmdId.Event_Playfield_List:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayfieldList>(ms, (Eleon.Modding.PlayfieldList)p.data);
break;
case Eleon.Modding.CmdId.Event_GlobalStructure_List:
ProtoBuf.Serializer.Serialize<Eleon.Modding.GlobalStructureList>(ms, (Eleon.Modding.GlobalStructureList)p.data);
break;
case Eleon.Modding.CmdId.Request_Entity_Teleport:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPositionRotation>(ms, (Eleon.Modding.IdPositionRotation)p.data);
break;
case Eleon.Modding.CmdId.Request_GlobalStructure_Update:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PString>(ms, (Eleon.Modding.PString)p.data);
break;
case Eleon.Modding.CmdId.Event_Faction_Changed:
ProtoBuf.Serializer.Serialize<Eleon.Modding.FactionChangeInfo>(ms, (Eleon.Modding.FactionChangeInfo)p.data);
break;
case CmdId.Request_Blueprint_Finish:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Id)p.data);
break;
case CmdId.Request_Blueprint_Resources:
ProtoBuf.Serializer.Serialize<Eleon.Modding.BlueprintResources>(ms, (BlueprintResources)p.data);
break;
case CmdId.Request_Player_ChangePlayerfield:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPlayfieldPositionRotation>(ms, (Eleon.Modding.IdPlayfieldPositionRotation)p.data);
break;
case CmdId.Request_Entity_ChangePlayfield:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPlayfieldPositionRotation>(ms, (Eleon.Modding.IdPlayfieldPositionRotation)p.data);
break;
case CmdId.Request_Entity_Destroy:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case CmdId.Request_Player_ItemExchange:
case CmdId.Event_Player_ItemExchange:
ProtoBuf.Serializer.Serialize<Eleon.Modding.ItemExchangeInfo>(ms, (Eleon.Modding.ItemExchangeInfo)p.data);
break;
case CmdId.Event_Statistics:
ProtoBuf.Serializer.Serialize<Eleon.Modding.StatisticsParam>(ms, (Eleon.Modding.StatisticsParam)p.data);
break;
case CmdId.Event_Error:
ProtoBuf.Serializer.Serialize<Eleon.Modding.ErrorInfo>(ms, (Eleon.Modding.ErrorInfo)p.data);
break;
case CmdId.Request_Structure_Touch:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case CmdId.Request_Get_Factions:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case CmdId.Event_Get_Factions:
ProtoBuf.Serializer.Serialize<Eleon.Modding.FactionInfoList>(ms, (Eleon.Modding.FactionInfoList)p.data);
break;
case CmdId.Request_Player_SetPlayerInfo:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayerInfoSet>(ms, (Eleon.Modding.PlayerInfoSet)p.data);
break;
case CmdId.Event_NewEntityId:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case CmdId.Request_Entity_Spawn:
ProtoBuf.Serializer.Serialize<Eleon.Modding.EntitySpawnInfo>(ms, (Eleon.Modding.EntitySpawnInfo)p.data);
break;
case CmdId.Event_Player_DisconnectedWaiting:
Serializer.Serialize(ms, (Id)p.data);
break;
case CmdId.Event_ChatMessage:
ProtoBuf.Serializer.Serialize<Eleon.Modding.ChatInfo>(ms, (Eleon.Modding.ChatInfo)p.data);
break;
case CmdId.Request_ConsoleCommand:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PString>(ms, (Eleon.Modding.PString)p.data);
break;
case CmdId.Request_Structure_BlockStatistics:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case CmdId.Event_Structure_BlockStatistics:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdStructureBlockInfo>(ms, (Eleon.Modding.IdStructureBlockInfo)p.data);
break;
case CmdId.Event_AlliancesAll:
ProtoBuf.Serializer.Serialize<Eleon.Modding.AlliancesTable>(ms, (Eleon.Modding.AlliancesTable)p.data);
break;
case CmdId.Request_AlliancesFaction:
ProtoBuf.Serializer.Serialize<Eleon.Modding.AlliancesFaction>(ms, (Eleon.Modding.AlliancesFaction)p.data);
break;
case CmdId.Event_AlliancesFaction:
ProtoBuf.Serializer.Serialize<Eleon.Modding.AlliancesFaction>(ms, (Eleon.Modding.AlliancesFaction)p.data);
break;
case CmdId.Event_BannedPlayers:
ProtoBuf.Serializer.Serialize<Eleon.Modding.BannedPlayerData>(ms, (Eleon.Modding.BannedPlayerData)p.data);
break;
case CmdId.Request_InGameMessage_Faction:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdMsgPrio>(ms, (Eleon.Modding.IdMsgPrio)p.data);
break;
case Eleon.Modding.CmdId.Event_TraderNPCItemSold:
ProtoBuf.Serializer.Serialize<Eleon.Modding.TraderNPCItemSoldInfo>(ms, (Eleon.Modding.TraderNPCItemSoldInfo)p.data);
break;
case Eleon.Modding.CmdId.Request_Player_GetAndRemoveInventory:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Id>(ms, (Eleon.Modding.Id)p.data);
break;
case Eleon.Modding.CmdId.Event_Player_GetAndRemoveInventory:
ProtoBuf.Serializer.Serialize<Eleon.Modding.Inventory>(ms, (Eleon.Modding.Inventory)p.data);
break;
case Eleon.Modding.CmdId.Request_Playfield_Entity_List:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PString>(ms, (Eleon.Modding.PString)p.data);
break;
case Eleon.Modding.CmdId.Event_Playfield_Entity_List:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PlayfieldEntityList>(ms, (Eleon.Modding.PlayfieldEntityList)p.data);
break;
case Eleon.Modding.CmdId.Request_Entity_Destroy2:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPlayfield>(ms, (Eleon.Modding.IdPlayfield)p.data);
break;
case Eleon.Modding.CmdId.Request_Entity_Export:
ProtoBuf.Serializer.Serialize<Eleon.Modding.EntityExportInfo>(ms, (Eleon.Modding.EntityExportInfo)p.data);
break;
case Eleon.Modding.CmdId.Event_ConsoleCommand:
ProtoBuf.Serializer.Serialize<Eleon.Modding.ConsoleCommandInfo>(ms, (Eleon.Modding.ConsoleCommandInfo)p.data);
break;
case Eleon.Modding.CmdId.Request_Entity_SetName:
ProtoBuf.Serializer.Serialize<Eleon.Modding.IdPlayfieldName>(ms, (Eleon.Modding.IdPlayfieldName)p.data);
break;
case Eleon.Modding.CmdId.Event_PdaStateChange:
ProtoBuf.Serializer.Serialize<Eleon.Modding.PdaStateInfo>(ms, (Eleon.Modding.PdaStateInfo)p.data);
break;
case Eleon.Modding.CmdId.Event_GameEvent:
ProtoBuf.Serializer.Serialize<Eleon.Modding.GameEventData>(ms, (Eleon.Modding.GameEventData)p.data);
break;
default:
break;
}
writerStream.Write((byte)p.cmd);
writerStream.Write((Int32)p.clientId);
writerStream.Write((UInt16)p.seqNr);
byte[] data = ms.GetBuffer();
int len = (int)ms.Length;
writerStream.Write((Int32)len);
int offset = 0;
do
{
int sendLen = Math.Min(8192, len);
writerStream.Write(data, offset, sendLen);
len -= sendLen;
offset += sendLen;
} while (len > 0);
}
writerStream.Flush();
}
}
catch (IOException)
{
//Console.WriteLine(string.Format("Connection closed while writing ({0})", e.Message)); silent
CloseConnection();
}
catch (ObjectDisposedException e)
{
Console.WriteLine(string.Format("ObjectDisposed: Connection closed while writing ({0})", e.Message));
ModLoging.Log(string.Format("MDP: ObjectDisposed: Connection closed while writing ({0})", e.Message), ModLoging.eTyp.Exception);
CloseConnection();
}
catch (Exception e)
{
Console.WriteLine(string.Format("Connection closed while writing ({0})", e.Message));
ModLoging.Log(string.Format("MDP: Connection closed while writing ({0})", e.Message), ModLoging.eTyp.Exception);
Console.WriteLine(e.GetType() + ": " + e.Message);
CloseConnection();
}
}
void CloseConnection()
{
try
{
if (bDisconnected)
{
return;
}
bDisconnected = true;
tcpClient.Close();
readerStream.Close();
readerThread.eventRunning.Set();
writerStream.Close();
writerThread.eventRunning.Set();
evWritePackages.Set();
if (disconnectedDelegate != null)
{
disconnectedDelegate(this);
}
}
catch (Exception e)
{
ModLoging.Log_Exception(e, "MDP: Close Connection");
}
//readerThread.WaitForEnd();
//writerThread.WaitForEnd();
}
public void Close()
{
try
{
tcpClient.Close();
readerStream.Close();
readerThread.eventRunning.Set();
writerStream.Close();
writerThread.eventRunning.Set();
evWritePackages.Set();
}
catch (Exception e)
{
ModLoging.Log_Exception(e, "MDP: Close");
}
}
}
}
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Threading;
public class ModThreadHelper
{
public class Info
{
public ThreadFunc threadDelegate;
public string name;
public Thread thread;
public ManualResetEvent eventRunning = new ManualResetEvent(false);
public void WaitForEnd()
{
eventRunning.Set();
}
}
public delegate void ThreadFunc(Info ti);
public static Dictionary<string, Info> RunningThreads = new Dictionary<string, Info>();
public static Info StartThread(ThreadFunc nThreadStart, System.Threading.ThreadPriority nThreadPriority)
{
return StartThread(nThreadStart.Method.Name, nThreadStart, nThreadPriority);
}
public static Info StartThread(string nName, ThreadFunc nThreadFunc, System.Threading.ThreadPriority nThreadPrio)
{
Thread t = new Thread(new ParameterizedThreadStart(ThreadInvoke));
t.Priority = nThreadPrio;
Info threadInfo = new Info();
threadInfo.threadDelegate = nThreadFunc;
threadInfo.thread = t;
lock (RunningThreads)
{
while (RunningThreads.ContainsKey(nName))
{
nName += "[new]";
}
threadInfo.name = nName;
RunningThreads.Add(nName, threadInfo);
}
ThreadPool.UnsafeQueueUserWorkItem(ThreadInvoke, threadInfo);
return threadInfo;
}
private static void ThreadInvoke(object ti)
{
Info threadInfo = (Info)ti;
try
{
threadInfo.threadDelegate(threadInfo);
}
catch (Exception e)
{
Console.WriteLine(string.Format("Thread {0} exception:", threadInfo.name));
Console.WriteLine(e.GetType() + ": " + e.Message);
EPMConnector.ModLoging.Log(string.Format("MTH: Thread {0} exception: {1}", threadInfo.name, e.GetType() + ": " + e.Message));
}
finally
{
//Console.WriteLine(string.Format("Thread {0} exited", threadInfo.name));
lock (RunningThreads)
{
RunningThreads.Remove(threadInfo.name);
}
}
}
}
+618
View File
@@ -0,0 +1,618 @@
using System.Reflection;
using Eleon.Modding;
namespace EmpyrionBridge;
// EAH-parity verbs grouped by the Empyrion Admin Helper tab they map to.
//
// Categories:
// - Players : kick, ban/unban, give-item, give-credits, teleport, info, inventory
// - Factions : list, alliances, message
// - Chat : broadcast (server-wide), faction chat, single-player whisper
// - Structures : list global, set-name, transfer-faction (via console)
// - Playfields : list, load, wipe (via console), entity list
// - Console : raw console line, save, restart-warning
//
// Most actions either go through a typed CmdId (Request_Player_AddItem) or
// fall back to Request_ConsoleCommand with a templated string. Console
// commands are documented at https://empyrion.fandom.com/wiki/Server_commands
// — the wiki is mostly accurate as of Empyrion 1.16.x.
public static class EndpointMaps
{
public static void MapAll(WebApplication app, BridgePool pool, TimeSpan timeout)
{
MapPlayers(app, pool, timeout);
MapFactions(app, pool, timeout);
MapChat(app, pool, timeout);
MapStructures(app, pool, timeout);
MapStructureOps(app, pool, timeout);
MapPlayfields(app, pool, timeout);
MapConsole(app, pool, timeout);
MapBans(app, pool, timeout);
MapEventStream(app, pool, timeout);
}
// ---------- SSE event stream ----------
// GET /api/v1/events?host=&port= — text/event-stream of unsolicited
// Event_* packages (chat, player join/leave, structure changes, etc).
// Each event line: data: { "cmd": "...", "seq": N, "data": {...} }\n\n
static void MapEventStream(WebApplication app, BridgePool pool, TimeSpan timeout)
{
app.MapGet("/api/v1/events", async (HttpContext ctx) =>
{
var ep = Endpoint(ctx.Request);
if (ep is null) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsync("missing host or port"); return; }
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
ctx.Response.Headers["Content-Type"] = "text/event-stream";
ctx.Response.Headers["Cache-Control"] = "no-cache";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
var queue = new System.Threading.Channels.UnboundedChannelOptions { SingleReader = true, SingleWriter = false };
var ch = System.Threading.Channels.Channel.CreateUnbounded<string>(queue);
using var sub = conn.Subscribe(p =>
{
try
{
var payload = System.Text.Json.JsonSerializer.Serialize(new
{
cmd = p.cmd.ToString(),
cmdId = (int)p.cmd,
seq = (int)p.seqNr,
clientId = p.clientId,
data = p.data,
type = p.data?.GetType()?.Name,
});
ch.Writer.TryWrite(payload);
}
catch { /* don't let serialization errors take down the connection */ }
});
// Initial heartbeat so client knows the stream is live.
await ctx.Response.WriteAsync("event: ready\ndata: {}\n\n");
await ctx.Response.Body.FlushAsync();
var heartbeat = TimeSpan.FromSeconds(15);
using var hbCts = new CancellationTokenSource();
var hbTask = Task.Run(async () =>
{
try
{
while (!hbCts.IsCancellationRequested)
{
await Task.Delay(heartbeat, hbCts.Token);
ch.Writer.TryWrite(":heartbeat");
}
}
catch (TaskCanceledException) { }
});
try
{
await foreach (var msg in ch.Reader.ReadAllAsync(ctx.RequestAborted))
{
if (msg.StartsWith(":")) await ctx.Response.WriteAsync(msg + "\n\n");
else await ctx.Response.WriteAsync("data: " + msg + "\n\n");
await ctx.Response.Body.FlushAsync(ctx.RequestAborted);
}
}
catch (OperationCanceledException) { /* client went away */ }
finally
{
hbCts.Cancel();
ch.Writer.TryComplete();
}
});
}
static (string host, int port)? Endpoint(HttpRequest r)
{
var h = r.Query["host"].FirstOrDefault();
var p = r.Query["port"].FirstOrDefault();
if (string.IsNullOrEmpty(h) || !int.TryParse(p, out var pi)) return null;
return (h, pi);
}
static async Task<(BridgeConnection conn, IResult? err)> Connect(BridgePool pool, HttpRequest r)
{
var ep = Endpoint(r);
if (ep is null) return (null!, Results.BadRequest(new { error = "missing host or port" }));
return (await pool.GetAsync(ep.Value.host, ep.Value.port), null);
}
// ---------- Players ----------
static void MapPlayers(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Already covered in Program.cs: GET /api/v1/players, /api/v1/players/{id}
app.MapPost("/api/v1/players/{id:int}/kick", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var reason = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(reason)) reason = "kicked by admin";
// Use console verb — there is no typed Request_Kick.
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"kick {id} '{reason.Replace("'", "")}'"));
return Results.Json(new { ok = true, sent = $"kick {id} '{reason}'" });
});
app.MapPost("/api/v1/players/{id:int}/ban", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// Two duration shapes supported:
// ?dur=<string> — passed verbatim to Empyrion (e.g. "30s", "5m", "7h", "30d", "1y", "0" for permanent)
// ?days=N — legacy integer days, kept for back-compat
// dur takes precedence when both are set.
var rawDur = r.Query["dur"].FirstOrDefault();
string dur;
if (!string.IsNullOrWhiteSpace(rawDur))
{
// Allow only [0-9smhdy] to keep injection out of the console verb.
var clean = new string(rawDur.Where(c => char.IsDigit(c) || c is 's' or 'm' or 'h' or 'd' or 'y').ToArray());
if (string.IsNullOrEmpty(clean)) return Results.BadRequest(new { error = "dur must be like 30s / 5m / 7h / 30d / 1y / 0" });
dur = clean;
}
else
{
var days = int.TryParse(r.Query["days"].FirstOrDefault(), out var d) ? d : 1;
dur = $"{days}d";
}
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"ban add {id} {dur}"));
return Results.Json(new { ok = true, sent = $"ban add {id} {dur}" });
});
app.MapPost("/api/v1/players/{id:int}/unban", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"ban remove {id}"));
return Results.Json(new { ok = true, sent = $"ban remove {id}" });
});
// Set Empyrion's per-player role. Wraps the
// `setrole "<playerName>" <role>` console verb — Empyrion's setrole
// looks up by name (or steam id), NOT by entity id, so we resolve
// the entity id → name via a quick PlayerInfo round-trip first.
// Sending `setrole <entityId> <role>` silently fails (Empyrion
// returns no error but the permission level never changes).
app.MapPost("/api/v1/players/{id:int}/set-role", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var role = (r.Query["role"].FirstOrDefault() ?? "").Trim().ToLowerInvariant();
if (role is not ("admin" or "gamemaster" or "moderator" or "player"))
return Results.BadRequest(new { error = "role must be one of: admin, gamemaster, moderator, player" });
var info = await conn.RequestAsync<Eleon.Modding.PlayerInfo>(
CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), timeout);
var name = (info?.playerName ?? "").Replace("\"", "");
if (string.IsNullOrEmpty(name)) return Results.NotFound(new { error = "player not found / not spawned", id });
var cmd = $"setrole \"{name}\" {role}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, sent = cmd, id, playerName = name, role });
});
app.MapPost("/api/v1/players/{id:int}/give-credits", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var body = (await sr.ReadToEndAsync()).Trim();
if (!double.TryParse(body, out var amount)) return Results.BadRequest(new { error = "body must be a number (credits to add; negative subtracts)" });
conn.SendFireAndForget(CmdId.Request_Player_AddCredits, new IdCredits { id = id, credits = amount });
return Results.Json(new { ok = true, id, credits = amount });
});
app.MapPost("/api/v1/players/{id:int}/set-credits", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var body = (await sr.ReadToEndAsync()).Trim();
if (!double.TryParse(body, out var amount)) return Results.BadRequest(new { error = "body must be a number (absolute credits)" });
conn.SendFireAndForget(CmdId.Request_Player_SetCredits, new IdCredits { id = id, credits = amount });
return Results.Json(new { ok = true, id, credits = amount });
});
// Give an item to a player. We use Empyrion's `give "<name>" <itemId> <count>`
// console command rather than Request_Player_AddItem because the typed
// RPC requires a slotIdx — without one the API defaults to slot 0 in the
// toolbar, which is normally occupied (starter pickaxe etc), and Empyrion
// silently refuses the overwrite. The console command auto-places into
// the first free bag slot, the natural "give-me-this-item" behavior.
// Caller can still pass slotIdx explicitly to use the typed path.
app.MapPost("/api/v1/players/{id:int}/give-item", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// Body: { "itemId": 4307, "count": 1, "slotIdx": 0|null, "ammo": 0, "decay": 0 }
var body = await JsonContent.ReadAsync<GiveItemReq>(r);
if (body is null || body.itemId <= 0) return Results.BadRequest(new { error = "itemId required" });
var count = body.count <= 0 ? 1 : body.count;
// Path A: caller pinned a specific slot — go through typed RPC.
if (body.slotIdx.HasValue)
{
var stack = new ItemStack
{
id = body.itemId,
count = count,
slotIdx = body.slotIdx.Value,
ammo = body.ammo,
decay = body.decay,
};
conn.SendFireAndForget(CmdId.Request_Player_AddItem, new IdItemStack { id = id, itemStack = stack });
return Results.Json(new { ok = true, id, item = stack, via = "typed" });
}
// Path B (default): auto-place via console — needs the player NAME.
var info = await conn.RequestAsync<Eleon.Modding.PlayerInfo>(
CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), timeout);
var name = (info?.playerName ?? "").Replace("\"", "");
if (string.IsNullOrEmpty(name)) return Results.NotFound(new { error = "player not found / not spawned", id });
var cmd = $"give \"{name}\" {body.itemId} {count}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, id, playerName = name, itemId = body.itemId, count, sent = cmd, via = "console" });
});
app.MapGet("/api/v1/players/{id:int}/inventory", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<Inventory>(CmdId.Request_Player_GetInventory, CmdId.Event_Player_Inventory, new Id(id), timeout);
if (resp is null) return Results.NotFound(new { error = "no inventory for player", id });
return Results.Json(resp);
});
app.MapPost("/api/v1/players/{id:int}/teleport", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// Body: { "x":0, "y":0, "z":0, "playfield":"optional" }
var body = await JsonContent.ReadAsync<TeleportReq>(r);
if (body is null) return Results.BadRequest(new { error = "x/y/z required" });
if (!string.IsNullOrEmpty(body.playfield))
{
var pkt = new IdPlayfieldPositionRotation
{
id = id,
playfield = body.playfield,
pos = new PVector3 { x = body.x, y = body.y, z = body.z },
rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
};
conn.SendFireAndForget(CmdId.Request_Player_ChangePlayerfield, pkt);
return Results.Json(new { ok = true, id, playfield = body.playfield, x = body.x, y = body.y, z = body.z });
}
else
{
var pkt = new IdPositionRotation
{
id = id,
pos = new PVector3 { x = body.x, y = body.y, z = body.z },
rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
};
conn.SendFireAndForget(CmdId.Request_Entity_Teleport, pkt);
return Results.Json(new { ok = true, id, x = body.x, y = body.y, z = body.z });
}
});
}
// ---------- Factions ----------
static void MapFactions(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Already covered in Program.cs: GET /api/v1/factions
app.MapGet("/api/v1/factions/alliances", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<AlliancesTable>(CmdId.Request_AlliancesAll, CmdId.Event_AlliancesAll, null, timeout);
return Results.Json(resp ?? new AlliancesTable());
});
// POST /api/v1/factions/alliance-state — set alliance state between two factions
// state mapping: ally=0, neutral=1, hostile=2 (Empyrion console verb values)
app.MapPost("/api/v1/factions/alliance-state", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<AllianceStateReq>(r);
if (body is null) return Results.BadRequest(new { error = "factionA, factionB, and state required" });
int stateCode;
switch (body.state?.Trim().ToLowerInvariant())
{
case "ally": stateCode = 0; break;
case "neutral": stateCode = 1; break;
case "hostile": stateCode = 2; break;
default: return Results.BadRequest(new { error = "state must be one of: ally, neutral, hostile" });
}
var cmd = $"setalliancestate {body.factionA} {body.factionB} {stateCode}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
}
// ---------- Chat ----------
static void MapChat(WebApplication app, BridgePool pool, TimeSpan timeout)
{
app.MapPost("/api/v1/chat/broadcast", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var msg = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" });
// prio: 0=info, 1=warning, 2=alert. Default to alert for visibility.
var prio = byte.TryParse(r.Query["prio"].FirstOrDefault(), out var pp) ? pp : (byte)2;
var time = float.TryParse(r.Query["time"].FirstOrDefault(), out var tt) ? tt : 8.0f;
conn.SendFireAndForget(CmdId.Request_InGameMessage_AllPlayers, new IdMsgPrio { id = 0, msg = msg, prio = prio, time = time });
return Results.Json(new { ok = true, msg, prio, time });
});
app.MapPost("/api/v1/chat/whisper/{playerId:int}", async (int playerId, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var msg = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" });
var prio = byte.TryParse(r.Query["prio"].FirstOrDefault(), out var pp) ? pp : (byte)0;
var time = float.TryParse(r.Query["time"].FirstOrDefault(), out var tt) ? tt : 8.0f;
conn.SendFireAndForget(CmdId.Request_InGameMessage_SinglePlayer, new IdMsgPrio { id = playerId, msg = msg, prio = prio, time = time });
return Results.Json(new { ok = true, playerId, msg });
});
app.MapPost("/api/v1/chat/faction/{factionId:int}", async (int factionId, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var msg = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" });
conn.SendFireAndForget(CmdId.Request_InGameMessage_Faction, new IdMsgPrio { id = factionId, msg = msg, prio = 2, time = 8.0f });
return Results.Json(new { ok = true, factionId, msg });
});
}
// ---------- Structures ----------
static void MapStructures(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Empyrion's pattern: send Request_GlobalStructure_Update, the server
// pushes Event_GlobalStructure_List back. We correlate by expected cmd.
app.MapGet("/api/v1/structures", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<GlobalStructureList>(CmdId.Request_GlobalStructure_Update, CmdId.Event_GlobalStructure_List, new PString(""), timeout);
return Results.Json(new
{
playfields = resp?.globalStructures?.Keys ?? Enumerable.Empty<string>(),
count = resp?.globalStructures?.Sum(kv => kv.Value?.Count ?? 0) ?? 0,
structures = resp?.globalStructures?.SelectMany(kv => (kv.Value ?? new List<GlobalStructureInfo>()).Select(gs => new
{
playfield = kv.Key,
id = gs.id,
name = gs.name,
type = gs.type,
pos = new[] { gs.pos.x, gs.pos.y, gs.pos.z },
factionId = gs.factionId,
factionGroup = gs.factionGroup,
coreType = gs.coreType,
classNr = gs.classNr,
blocks = gs.cntBlocks,
devices = gs.cntDevices,
triangles = gs.cntTriangles,
lights = gs.cntLights,
fuel = gs.fuel,
pilotId = gs.pilotId,
powered = gs.powered,
})) ?? Enumerable.Empty<object>(),
});
});
app.MapPost("/api/v1/structures/{id:int}/rename", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
using var sr = new StreamReader(r.Body);
var newName = (await sr.ReadToEndAsync()).Trim();
if (string.IsNullOrEmpty(newName)) return Results.BadRequest(new { error = "empty name" });
// The Mod API has Request_Entity_SetName but it requires playfield+name, not just id.
// Easier path: console "rename ID NEW_NAME"
var safe = newName.Replace("\"", "");
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"rename '{id}' '{safe}'"));
return Results.Json(new { ok = true, id, name = newName });
});
app.MapPost("/api/v1/structures/{id:int}/destroy", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
conn.SendFireAndForget(CmdId.Request_Entity_Destroy, new Id(id));
return Results.Json(new { ok = true, id });
});
}
// ---------- Structure Operations ----------
static void MapStructureOps(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// POST /api/v1/structures/{id:int}/faction — transfer structure to a faction
app.MapPost("/api/v1/structures/{id:int}/faction", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<FactionReq>(r);
if (body is null) return Results.BadRequest(new { error = "factionId required" });
var cmd = $"faction structure {id} {body.factionId}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/heal — heal structure to 100%
app.MapPost("/api/v1/structures/{id:int}/heal", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"heal {id} 100";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/refuel — refuel structure
app.MapPost("/api/v1/structures/{id:int}/refuel", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"refuel {id}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/set-core — set core type
app.MapPost("/api/v1/structures/{id:int}/set-core", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<SetCoreReq>(r);
if (body is null || body.mode is null) return Results.BadRequest(new { error = "mode required (admin|player|hardcore)" });
var mode = body.mode.Trim().ToLowerInvariant();
if (mode is not ("admin" or "player" or "hardcore"))
return Results.BadRequest(new { error = "mode must be one of: admin, player, hardcore" });
var cmd = $"setadmincore {id} {mode}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/move — move structure (same playfield or cross-playfield)
app.MapPost("/api/v1/structures/{id:int}/move", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<MoveStructureReq>(r);
if (body is null) return Results.BadRequest(new { error = "x/y/z required" });
if (!string.IsNullOrEmpty(body.playfield))
{
var pkt = new IdPlayfieldPositionRotation
{
id = id,
playfield = body.playfield,
pos = new PVector3 { x = body.x, y = body.y, z = body.z },
rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
};
conn.SendFireAndForget(CmdId.Request_Entity_ChangePlayfield, pkt);
return Results.Json(new { ok = true, id, playfield = body.playfield, x = body.x, y = body.y, z = body.z });
}
else
{
var pkt = new IdPositionRotation
{
id = id,
pos = new PVector3 { x = body.x, y = body.y, z = body.z },
rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
};
conn.SendFireAndForget(CmdId.Request_Entity_Teleport, pkt);
return Results.Json(new { ok = true, id, x = body.x, y = body.y, z = body.z });
}
});
// POST /api/v1/structures/{id:int}/save-as-blueprint — save structure as blueprint
app.MapPost("/api/v1/structures/{id:int}/save-as-blueprint", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<SaveBlueprintReq>(r);
var cmd = string.IsNullOrEmpty(body?.name) ? $"bp {id}" : $"bp {id} \"{body.name.Replace("\"", "")}\"";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/structures/{id:int}/regenerate — regenerate a structure (optionally with faction override)
app.MapPost("/api/v1/structures/{id:int}/regenerate", async (int id, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var body = await JsonContent.ReadAsync<RegenerateReq>(r);
var cmd = string.IsNullOrEmpty(body?.faction) ? $"regenerate {id}" : $"regenerate {id} {body.faction}";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
}
// ---------- Playfields ----------
static void MapPlayfields(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Already covered in Program.cs: GET /api/v1/playfields, /api/v1/playfields/{name}/entities
app.MapGet("/api/v1/playfields/{name}/stats", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<PlayfieldStats>(CmdId.Request_Playfield_Stats, CmdId.Event_Playfield_Stats, new PString(name), timeout);
return Results.Json(resp ?? new PlayfieldStats { playfield = name });
});
app.MapPost("/api/v1/playfields/{name}/wipe", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// wipe playfield POI/all/terrain/deposit
var what = r.Query["what"].FirstOrDefault() ?? "poi";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"wipe '{name}' {what}"));
return Results.Json(new { ok = true, playfield = name, scope = what });
});
// POST /api/v1/playfields/{name}/load — load a playfield
app.MapPost("/api/v1/playfields/{name}/load", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"loadpf '{name}'";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/playfields/{name}/unload — unload a playfield
app.MapPost("/api/v1/playfields/{name}/unload", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"unloadpf '{name}'";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
// POST /api/v1/playfields/{name}/reset — full playfield reset (aggressive)
app.MapPost("/api/v1/playfields/{name}/reset", async (string name, HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var cmd = $"resetplayfield '{name}'";
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
return Results.Json(new { ok = true, command = cmd });
});
}
// ---------- Console ----------
static void MapConsole(WebApplication app, BridgePool pool, TimeSpan timeout)
{
// Already covered in Program.cs: POST /api/v1/console (raw command)
app.MapPost("/api/v1/save", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
// Empyrion's "saveandexit" command saves and shuts down — we don't want that.
// The "save" alias triggers an immediate save without exit; exists in 1.16+.
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString("save"));
return Results.Json(new { ok = true, sent = "save" });
});
app.MapPost("/api/v1/dedi/stats", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<DediStats>(CmdId.Request_Dedi_Stats, CmdId.Event_Dedi_Stats, null, timeout);
return Results.Json(resp ?? new DediStats());
});
}
// ---------- Bans ----------
static void MapBans(WebApplication app, BridgePool pool, TimeSpan timeout)
{
app.MapGet("/api/v1/banned", async (HttpRequest r) =>
{
var (conn, err) = await Connect(pool, r); if (err != null) return err;
var resp = await conn.RequestAsync<BannedPlayerData>(CmdId.Request_GetBannedPlayers, CmdId.Event_BannedPlayers, null, timeout);
return Results.Json(resp ?? new BannedPlayerData());
});
}
}
public class GiveItemReq { public int itemId { get; set; } public int count { get; set; } public byte? slotIdx { get; set; } public int ammo { get; set; } public int decay { get; set; } }
public class TeleportReq { public float x { get; set; } public float y { get; set; } public float z { get; set; } public float rx { get; set; } public float ry { get; set; } public float rz { get; set; } public string? playfield { get; set; } }
public class FactionReq { public int factionId { get; set; } }
public class SetCoreReq { public string? mode { get; set; } }
public class MoveStructureReq { public float x { get; set; } public float y { get; set; } public float z { get; set; } public float rx { get; set; } public float ry { get; set; } public float rz { get; set; } public string? playfield { get; set; } }
public class SaveBlueprintReq { public string? name { get; set; } }
public class AllianceStateReq { public int factionA { get; set; } public int factionB { get; set; } public string? state { get; set; } }
public class RegenerateReq { public string? faction { get; set; } }
internal static class JsonContent
{
public static async Task<T?> ReadAsync<T>(HttpRequest r) where T : class
{
try { return await r.ReadFromJsonAsync<T>(); }
catch { return null; }
}
}
+169
View File
@@ -0,0 +1,169 @@
using System.Reflection;
using Eleon.Modding;
using EmpyrionBridge;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(o =>
{
o.SingleLine = true;
o.TimestampFormat = "HH:mm:ss ";
});
builder.Services.AddSingleton<BridgePool>();
builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("BRIDGE_URLS") ?? "http://0.0.0.0:8090");
// Eleon's protobuf-net contracts (PlayerInfo, FactionInfo, etc.) expose
// their data as PUBLIC FIELDS, not properties. System.Text.Json ignores
// public fields by default, which made `Results.Json(playerInfo)` emit
// `{}` even though the deserialized object had real data inside. Turning
// IncludeFields on (globally for every endpoint that doesn't project
// explicitly) fixes the player-list, player-info, dedi-stats, and any
// future endpoint that returns a raw mod-API DTO.
//
// AllowNamedFloatingPointLiterals lets us serialize `NaN` / `Infinity`
// as the JSON string forms. Empyrion's mod API hands those back for
// uninitialized float fields (heading, posY, etc.) and STJ will throw
// at serialization time without this — breaking the entire response.
builder.Services.ConfigureHttpJsonOptions(o =>
{
o.SerializerOptions.IncludeFields = true;
o.SerializerOptions.NumberHandling =
System.Text.Json.Serialization.JsonNumberHandling.AllowNamedFloatingPointLiterals;
});
var app = builder.Build();
var pool = app.Services.GetRequiredService<BridgePool>();
var requestTimeout = TimeSpan.FromSeconds(double.TryParse(Environment.GetEnvironmentVariable("BRIDGE_REQUEST_TIMEOUT_S"), out var t) ? t : 8);
var apiToken = (Environment.GetEnvironmentVariable("BRIDGE_API_TOKEN") ?? "").Trim();
if (string.IsNullOrEmpty(apiToken))
{
app.Logger.LogWarning("BRIDGE_API_TOKEN is not set — auth is OFF. Anyone reaching the bridge can drive it. Suitable for loopback-only deployments.");
}
// Bearer-token middleware. Loopback (127.0.0.1, ::1) and unix sockets are
// always allowed without a token, matching the panel's existing pattern.
// Health endpoint always allowed.
app.Use(async (ctx, next) =>
{
if (string.IsNullOrEmpty(apiToken)) { await next(); return; }
if (ctx.Request.Path.StartsWithSegments("/healthz")) { await next(); return; }
var ip = ctx.Connection.RemoteIpAddress;
if (ip is not null && (System.Net.IPAddress.IsLoopback(ip))) { await next(); return; }
var auth = ctx.Request.Headers["Authorization"].FirstOrDefault() ?? "";
if (auth == $"Bearer {apiToken}") { await next(); return; }
ctx.Response.StatusCode = 401;
await ctx.Response.WriteAsJsonAsync(new { error = "unauthorized" });
});
string? Q(HttpRequest r, string k) => r.Query[k].FirstOrDefault();
(string host, int port)? Endpoint(HttpRequest r)
{
var h = Q(r, "host");
var p = Q(r, "port");
if (string.IsNullOrEmpty(h) || !int.TryParse(p, out var pi)) return null;
return (h, pi);
}
app.MapGet("/healthz", () => Results.Json(new
{
ok = true,
version = typeof(Program).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion,
pid = Environment.ProcessId,
authMode = string.IsNullOrEmpty(apiToken) ? "none" : "bearer-token",
}));
app.MapGet("/api/v1/factions", async (HttpRequest r) =>
{
var ep = Endpoint(r);
if (ep is null) return Results.BadRequest(new { error = "missing host or port" });
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
var resp = await conn.RequestAsync<FactionInfoList>(CmdId.Request_Get_Factions, CmdId.Event_Get_Factions, new Id(1), requestTimeout);
return Results.Json(new
{
host = ep.Value.host,
port = ep.Value.port,
factions = resp?.factions?.Select(f => new
{
origin = f.origin,
factionId = f.factionId,
name = f.name,
abbrev = f.abbrev,
}) ?? Enumerable.Empty<object>(),
});
});
app.MapGet("/api/v1/players", async (HttpRequest r) =>
{
var ep = Endpoint(r);
if (ep is null) return Results.BadRequest(new { error = "missing host or port" });
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
var resp = await conn.RequestAsync<IdList>(CmdId.Request_Player_List, CmdId.Event_Player_List, null, requestTimeout);
return Results.Json(new
{
host = ep.Value.host,
port = ep.Value.port,
playerIds = resp?.list ?? new List<int>(),
count = resp?.list?.Count ?? 0,
});
});
app.MapGet("/api/v1/players/{id:int}", async (int id, HttpRequest r) =>
{
var ep = Endpoint(r);
if (ep is null) return Results.BadRequest(new { error = "missing host or port" });
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
var resp = await conn.RequestAsync<PlayerInfo>(CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), requestTimeout);
if (resp is null) return Results.NotFound(new { error = "no info for player", id });
return Results.Json(resp);
});
app.MapGet("/api/v1/playfields", async (HttpRequest r) =>
{
var ep = Endpoint(r);
if (ep is null) return Results.BadRequest(new { error = "missing host or port" });
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
var resp = await conn.RequestAsync<PlayfieldList>(CmdId.Request_Playfield_List, CmdId.Event_Playfield_List, null, requestTimeout);
return Results.Json(new
{
host = ep.Value.host,
port = ep.Value.port,
playfields = resp?.playfields ?? new List<string>(),
});
});
app.MapGet("/api/v1/playfields/{name}/entities", async (string name, HttpRequest r) =>
{
var ep = Endpoint(r);
if (ep is null) return Results.BadRequest(new { error = "missing host or port" });
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
var resp = await conn.RequestAsync<PlayfieldEntityList>(CmdId.Request_Playfield_Entity_List, CmdId.Event_Playfield_Entity_List, new PString(name), requestTimeout);
return Results.Json(new
{
host = ep.Value.host,
port = ep.Value.port,
playfield = name,
entities = resp?.entities ?? new List<EntityInfo>(),
});
});
app.MapPost("/api/v1/console", async (HttpRequest r) =>
{
var ep = Endpoint(r);
if (ep is null) return Results.BadRequest(new { error = "missing host or port" });
using var sr = new StreamReader(r.Body);
var body = await sr.ReadToEndAsync();
var line = body.Trim();
if (string.IsNullOrEmpty(line)) return Results.BadRequest(new { error = "empty body" });
var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);
conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(line));
return Results.Json(new { sent = line });
});
EndpointMaps.MapAll(app, pool, requestTimeout);
app.Logger.LogInformation("panel-empyrion-bridge listening; request_timeout={Timeout}s authMode={Mode}",
requestTimeout.TotalSeconds, string.IsNullOrEmpty(apiToken) ? "none" : "bearer");
app.Run();
public partial class Program { }