panel v0.9.1 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# panel-empyrion-bridge — .NET 8 sidecar that proxies Empyrion's Mod API
|
||||
# (TCP/protobuf-net via the EPM mod) to a small REST surface for the panel.
|
||||
#
|
||||
# Build:
|
||||
# docker build -t panel-empyrion-bridge:latest modules/empyrion-bridge
|
||||
#
|
||||
# Run:
|
||||
# docker run --rm -p 8090:8090 panel-empyrion-bridge:latest
|
||||
# then:
|
||||
# curl 'http://localhost:8090/api/v1/factions?host=<your-server-ip>&port=7028'
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY bridge/ ./bridge/
|
||||
COPY lib/ ./lib/
|
||||
RUN dotnet publish bridge/Bridge.csproj -c Release -o /out --no-self-contained \
|
||||
-p:DebugType=embedded -p:PublishSingleFile=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/ ./
|
||||
ENV BRIDGE_URLS=http://0.0.0.0:8090 \
|
||||
BRIDGE_REQUEST_TIMEOUT_S=8 \
|
||||
DOTNET_RUNNING_IN_CONTAINER=true \
|
||||
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
|
||||
EXPOSE 8090
|
||||
USER 1000:1000
|
||||
ENTRYPOINT ["dotnet", "panel-empyrion-bridge.dll"]
|
||||
@@ -0,0 +1,97 @@
|
||||
# panel-empyrion-bridge
|
||||
|
||||
A small .NET 8 ASP.NET Core sidecar that proxies Empyrion's native Mod API
|
||||
(TCP/protobuf-net) into a JSON REST surface for the panel dashboard.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Empyrion's only first-class admin tool is a Windows-only desktop app
|
||||
(Empyrion Admin Helper / EAH) speaking protobuf-net to an in-process mod
|
||||
called EPM. Re-implementing protobuf-net contract decoding in Go is a
|
||||
losing battle — every Empyrion update could shift `[ProtoMember]` tags.
|
||||
|
||||
Instead, this bridge:
|
||||
|
||||
- References Eleon's `Mif.dll` directly (the official mod-API SDK).
|
||||
- Re-uses `EPMConnector` (TCP framing + protobuf-net dispatch) by
|
||||
compiling its source files in.
|
||||
- Exposes a thin REST API the panel calls.
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
docker build -t panel-empyrion-bridge:latest modules/empyrion-bridge
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
The bridge needs network reach to wherever EPM is listening on each
|
||||
Empyrion instance. On a single-host setup with `network_mode: host`
|
||||
empyrion containers, hostname `127.0.0.1` works:
|
||||
|
||||
```
|
||||
docker run -d --name panel-bridge --network host \
|
||||
-e BRIDGE_URLS=http://127.0.0.1:8090 \
|
||||
panel-empyrion-bridge:latest
|
||||
```
|
||||
|
||||
For multi-host setups, pass each Empyrion's LAN IP + EAH_API_PORT in the
|
||||
query string per request.
|
||||
|
||||
## API (Phase 2 — read-only)
|
||||
|
||||
All endpoints take `?host=…&port=…` query parameters identifying the
|
||||
Empyrion EPM instance.
|
||||
|
||||
| Method | Path | Returns |
|
||||
|--------|--------------------------------------------|--------------------------------------------------|
|
||||
| GET | `/healthz` | bridge health + version |
|
||||
| GET | `/api/v1/factions` | `[{ origin, factionId, name, abbrev }]` |
|
||||
| GET | `/api/v1/players` | `{ playerIds: [int], count }` |
|
||||
| GET | `/api/v1/players/{id}` | full `PlayerInfo` (raw protobuf-net contract) |
|
||||
| GET | `/api/v1/playfields` | `{ playfields: [name] }` |
|
||||
| GET | `/api/v1/playfields/{name}/entities` | `{ entities: [...] }` |
|
||||
| POST | `/api/v1/console` | fire-and-forget; body is the console line |
|
||||
|
||||
`POST /api/v1/console` is currently EXPERIMENTAL — see "Known issues" below.
|
||||
|
||||
## Known issues / Phase 3+ work
|
||||
|
||||
- **Console command cmd-byte truncation suspected.** Empyrion's `CmdId`
|
||||
enum has grown past 255 entries; the legacy `EPMConnector.ModProtocol`
|
||||
writes `cmd` as a single byte, which means commands with id > 255 alias
|
||||
to other commands. Symptom seen: sending `Request_ConsoleCommand` shows
|
||||
up on the EPM side as `Event_ChatMessageEx`. Fix: extend the protocol
|
||||
encoding to use a UInt16 cmd OR build a tested mapping table from
|
||||
Mif.dll's CmdId enum values. Tracked for Phase 3.
|
||||
- **Authentication.** EPM has none. The bridge currently has none either.
|
||||
Before exposing any port to the WAN, add a shared-secret bearer token
|
||||
matching the panel's existing service-API pattern.
|
||||
- **Streaming events.** Right now unsolicited Event_* packages (chat,
|
||||
player join, etc) are dropped. A `GET /api/v1/stream` SSE endpoint will
|
||||
fan these out to subscribers in Phase 4.
|
||||
- **Connection persistence.** The pool reaps idle connections after 5
|
||||
minutes. If Empyrion restarts, we don't yet auto-reconnect; the next
|
||||
request opens a fresh connection but in-flight requests fail. Phase 5.
|
||||
|
||||
## Source layout
|
||||
|
||||
```
|
||||
empyrion-bridge/
|
||||
bridge/ ASP.NET Core minimal API project
|
||||
Bridge.csproj
|
||||
Program.cs endpoint definitions
|
||||
BridgePool.cs per-(host,port) connection cache
|
||||
BridgeConnection.cs request/response correlation by (seq, expectedCmd)
|
||||
EPMConnector/ source files compiled in (from MichaelGoulding/sample-empyrion-mod, MIT)
|
||||
lib/
|
||||
Mif.dll Eleon Modding SDK reference
|
||||
Dockerfile multi-stage build, alpine runtime
|
||||
```
|
||||
|
||||
## Verified end-to-end on 2026-04-30
|
||||
|
||||
```
|
||||
curl 'http://<agent-host>:8090/api/v1/factions?host=127.0.0.1&port=7028'
|
||||
→ {"factions":[{"origin":1,"factionId":1,"name":"Human","abbrev":"Hum"}]}
|
||||
```
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
Reference in New Issue
Block a user