Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ChannelsNS = System.Threading.Channels;
|
||||
using XWorld.Framework.Protocol;
|
||||
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
// 一个 WebSocket 连接:收循环解码帧→Submit;发循环排空出站队列→SendAsync(每 socket 仅一个发循环,发送串行)
|
||||
public sealed class Connection : IClientConnection
|
||||
{
|
||||
public int PlayerId { get; }
|
||||
|
||||
private readonly WebSocket _socket;
|
||||
private readonly ServerLoop _loop;
|
||||
private readonly ChannelsNS.Channel<byte[]> _outbound = ChannelsNS.Channel.CreateUnbounded<byte[]>();
|
||||
|
||||
public Connection(int playerId, WebSocket socket, ServerLoop loop)
|
||||
{
|
||||
PlayerId = playerId; _socket = socket; _loop = loop;
|
||||
}
|
||||
|
||||
public void Send(byte[] frame) => _outbound.Writer.TryWrite(frame);
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var sendTask = SendLoopAsync(ct);
|
||||
try { await ReceiveLoopAsync(ct).ConfigureAwait(false); }
|
||||
finally
|
||||
{
|
||||
_outbound.Writer.TryComplete();
|
||||
await sendTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||
{
|
||||
var buf = new byte[8192];
|
||||
using var ms = new MemoryStream();
|
||||
try
|
||||
{
|
||||
while (_socket.State == WebSocketState.Open && !ct.IsCancellationRequested)
|
||||
{
|
||||
ms.SetLength(0);
|
||||
WebSocketReceiveResult res;
|
||||
do
|
||||
{
|
||||
res = await _socket.ReceiveAsync(new ArraySegment<byte>(buf), ct).ConfigureAwait(false);
|
||||
if (res.MessageType == WebSocketMessageType.Close) return;
|
||||
ms.Write(buf, 0, res.Count);
|
||||
} while (!res.EndOfMessage);
|
||||
|
||||
Frame frame;
|
||||
try { frame = FrameCodec.Decode(ms.ToArray()); }
|
||||
catch (FormatException) { continue; } // 畸形帧:丢弃,不拖垮连接
|
||||
|
||||
_loop.Submit(new FrameCommand { PlayerId = PlayerId, Frame = frame });
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { } // 主机关停/请求中止
|
||||
catch (WebSocketException) { } // 对端突然断开(TCP 掉线)
|
||||
}
|
||||
|
||||
private async Task SendLoopAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var frame in _outbound.Reader.ReadAllAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
if (_socket.State != WebSocketState.Open) break;
|
||||
await _socket.SendAsync(new ArraySegment<byte>(frame),
|
||||
WebSocketMessageType.Binary, true, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (WebSocketException) { } // 对端已断,停止发送
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using XWorld.Framework.Protocol;
|
||||
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
public sealed class DevDiscoveryResponder
|
||||
{
|
||||
private readonly DevDiscoveryReply _template;
|
||||
private readonly string _token;
|
||||
private readonly UdpClient _udp;
|
||||
private CancellationTokenSource _cts;
|
||||
private Task _loop;
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
public DevDiscoveryResponder(string serverName, string gatewayUrl, string resourceBaseUrl,
|
||||
string token, int listenPort = DevDiscovery.Port)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) throw new ArgumentException("token cannot be empty", nameof(token));
|
||||
_token = token;
|
||||
_template = new DevDiscoveryReply
|
||||
{
|
||||
ProtoVersion = DevDiscovery.ProtoVersion,
|
||||
ServerName = serverName ?? "dev-server",
|
||||
GatewayUrl = gatewayUrl ?? "",
|
||||
ResourceBaseUrl = resourceBaseUrl ?? "",
|
||||
Token = token,
|
||||
};
|
||||
_udp = new UdpClient(AddressFamily.InterNetwork);
|
||||
_udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
_udp.Client.Bind(new IPEndPoint(IPAddress.Any, listenPort));
|
||||
Port = ((IPEndPoint)_udp.Client.LocalEndPoint).Port;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_loop != null) return;
|
||||
_cts = new CancellationTokenSource();
|
||||
_loop = ReceiveLoopAsync(_cts.Token);
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||
{
|
||||
using (ct.Register(() => { try { _udp.Close(); } catch { } }))
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
UdpReceiveResult res;
|
||||
try { res = await _udp.ReceiveAsync().ConfigureAwait(false); }
|
||||
catch (ObjectDisposedException) { break; }
|
||||
catch (SocketException) { break; }
|
||||
|
||||
if (!DevDiscoveryCodec.TryDecodeQuery(res.Buffer, out var q)) continue;
|
||||
if (q.ProtoVersion != DevDiscovery.ProtoVersion) continue;
|
||||
if (!string.Equals(q.Token, _token, StringComparison.Ordinal)) continue;
|
||||
|
||||
DevDiscoveryReply reply = _template;
|
||||
reply.Nonce = q.Nonce;
|
||||
byte[] bytes = DevDiscoveryCodec.EncodeReply(reply);
|
||||
try { await _udp.SendAsync(bytes, bytes.Length, res.RemoteEndPoint).ConfigureAwait(false); }
|
||||
catch (ObjectDisposedException) { break; }
|
||||
catch (SocketException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_cts == null) return;
|
||||
_cts.Cancel();
|
||||
try { await _loop.ConfigureAwait(false); }
|
||||
catch { }
|
||||
_cts.Dispose();
|
||||
_cts = null;
|
||||
_loop = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using XWorld.Framework.Protocol;
|
||||
using XWorld.Server.Host;
|
||||
using XWorld.Server.Auth;
|
||||
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
// 建 Kestrel + /ws 端点,把 socket 接入 ServerLoop。Start 返回实际监听地址(端口由系统分配)。
|
||||
public sealed class GameServerHost
|
||||
{
|
||||
private WebApplication _app;
|
||||
private ServerLoop _loop;
|
||||
private CancellationTokenSource _cts;
|
||||
private Task _loopTask;
|
||||
private DevDiscoveryResponder _discovery;
|
||||
private AccountStore _accounts;
|
||||
|
||||
public string WsBaseUrl { get; private set; } // 例如 ws://127.0.0.1:54321
|
||||
public int DevDiscoveryPort => _discovery?.Port ?? 0;
|
||||
|
||||
public async Task StartAsync(string gamesRoot, int tickIntervalMs, long reconnectWindowTicks = 100,
|
||||
long matchTimeoutTicks = 150, float? logicalDt = null, string publicKeyPem = null, int listenPort = 0,
|
||||
bool bindAllInterfaces = false,
|
||||
string devDiscoveryToken = null, string advertiseGatewayUrl = null, string advertiseResourceBaseUrl = null,
|
||||
int devDiscoveryPort = DevDiscovery.Port,
|
||||
byte[] authSecret = null, string authDbPath = null)
|
||||
{
|
||||
_loop = new ServerLoop(gamesRoot, new ConsoleLogger("[loop]"), reconnectWindowTicks, matchTimeoutTicks, publicKeyPem);
|
||||
_cts = new CancellationTokenSource();
|
||||
float dt = logicalDt ?? tickIntervalMs / 1000f;
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.WebHost.ConfigureKestrel(o =>
|
||||
o.Listen(bindAllInterfaces ? IPAddress.Any : IPAddress.Loopback, listenPort)); // 0 = 临时端口
|
||||
|
||||
TokenService tokens = null;
|
||||
if (authSecret != null)
|
||||
{
|
||||
tokens = new TokenService(authSecret);
|
||||
_accounts = new AccountStore(string.IsNullOrEmpty(authDbPath)
|
||||
? "Data Source=accounts.db" : authDbPath);
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseWebSockets();
|
||||
if (tokens != null)
|
||||
AuthEndpoints.Map(app, _accounts, tokens, TimeSpan.FromDays(7));
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
if (ctx.Request.Path == "/ws" && ctx.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
int pid;
|
||||
if (tokens != null)
|
||||
{
|
||||
string token = ctx.Request.Query["token"];
|
||||
if (!tokens.Verify(token, out pid))
|
||||
{
|
||||
ctx.Response.StatusCode = 401;
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (!int.TryParse(ctx.Request.Query["pid"], out pid))
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
return;
|
||||
}
|
||||
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
|
||||
var conn = new Connection(pid, ws, _loop);
|
||||
_loop.Submit(new ConnectCommand { PlayerId = pid, Connection = conn });
|
||||
try { await conn.RunAsync(ctx.RequestAborted); }
|
||||
catch (OperationCanceledException) { } // 关停/中止:正常收束
|
||||
finally
|
||||
{
|
||||
if (ws.State == WebSocketState.Open)
|
||||
{
|
||||
try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "server stopping", CancellationToken.None); }
|
||||
catch { /* 关闭握手尽力而为 */ }
|
||||
}
|
||||
_loop.Submit(new DisconnectCommand { PlayerId = pid, Connection = conn });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await next();
|
||||
}
|
||||
});
|
||||
|
||||
await app.StartAsync();
|
||||
_app = app;
|
||||
|
||||
var addresses = app.Services.GetRequiredService<IServer>()
|
||||
.Features.Get<IServerAddressesFeature>().Addresses;
|
||||
string http = addresses.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Kestrel 已启动但未注册任何监听地址");
|
||||
WsBaseUrl = http.Replace("http://", "ws://");
|
||||
|
||||
if (!string.IsNullOrEmpty(devDiscoveryToken))
|
||||
{
|
||||
string adGw = advertiseGatewayUrl;
|
||||
if (string.IsNullOrEmpty(adGw))
|
||||
{
|
||||
// 绑所有网卡时 WsBaseUrl 形如 ws://0.0.0.0:port,对内网其它设备无意义 ——
|
||||
// 用本机真实 LAN IP 广而告之(仿 C++ XBroadcastServer.GetLocalIPAddress)。
|
||||
if (bindAllInterfaces)
|
||||
adGw = $"ws://{LanIp.Resolve()}:{new Uri(WsBaseUrl).Port}/ws";
|
||||
else
|
||||
adGw = WsBaseUrl + "/ws";
|
||||
}
|
||||
_discovery = new DevDiscoveryResponder(
|
||||
Environment.MachineName, adGw, advertiseResourceBaseUrl ?? "", devDiscoveryToken, devDiscoveryPort);
|
||||
_discovery.Start();
|
||||
}
|
||||
|
||||
_loopTask = _loop.RunAsync(tickIntervalMs, dt, _cts.Token);
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_discovery != null)
|
||||
{
|
||||
await _discovery.StopAsync();
|
||||
_discovery = null;
|
||||
}
|
||||
_cts.Cancel();
|
||||
try { await _loopTask; } catch { /* 忽略取消 */ }
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
_accounts?.Dispose();
|
||||
_accounts = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<AssemblyName>XWorld.Server.Gateway</AssemblyName>
|
||||
<RootNamespace>XWorld.Server.Gateway</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\Server.Host\Server.Host.csproj" />
|
||||
<ProjectReference Include="..\Auth\Auth.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
// actor 把出站帧交给它(非阻塞入队);生产实现写 WebSocket 出站队列,测试用假实现记录
|
||||
public interface IClientConnection
|
||||
{
|
||||
int PlayerId { get; }
|
||||
// frame 在调用后不会被修改:实现可保留该引用(广播会把同一数组发给多个连接)。
|
||||
void Send(byte[] frame);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
// 取本机内网主 IPv4,用于广播 advertise(仿 C++ XBroadcastServer.GetLocalIPAddress)。
|
||||
// 优先选 Up、非 loopback、非隧道、带网关的网卡上的 IPv4;都没有则回退 127.0.0.1。
|
||||
public static class LanIp
|
||||
{
|
||||
public static string Resolve()
|
||||
{
|
||||
// 1) 优先:有默认网关的活动网卡(最可能是真实内网口)
|
||||
string withGateway = PickFromNics(requireGateway: true);
|
||||
if (withGateway != null) return withGateway;
|
||||
|
||||
// 2) 退一步:任意 Up 的非 loopback/隧道网卡上的私有 IPv4
|
||||
string anyUp = PickFromNics(requireGateway: false);
|
||||
if (anyUp != null) return anyUp;
|
||||
|
||||
// 3) 兜底:DNS 解析本机名里的第一个 IPv4
|
||||
foreach (IPAddress addr in Dns.GetHostAddresses(Dns.GetHostName()))
|
||||
{
|
||||
if (addr.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(addr))
|
||||
return addr.ToString();
|
||||
}
|
||||
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
private static string PickFromNics(bool requireGateway)
|
||||
{
|
||||
foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (nic.OperationalStatus != OperationalStatus.Up) continue;
|
||||
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||
if (nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) continue;
|
||||
|
||||
IPInterfaceProperties props = nic.GetIPProperties();
|
||||
if (requireGateway && !props.GatewayAddresses.Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork))
|
||||
continue;
|
||||
|
||||
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
|
||||
{
|
||||
IPAddress ip = ua.Address;
|
||||
if (ip.AddressFamily != AddressFamily.InterNetwork) continue;
|
||||
if (IPAddress.IsLoopback(ip)) continue;
|
||||
return ip.ToString();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
public sealed class Matchmaker
|
||||
{
|
||||
public sealed class Seat { public int PlayerId; public bool IsAi; }
|
||||
public sealed class Match { public string GameId; public int Version; public List<Seat> Seats = new List<Seat>(); }
|
||||
|
||||
private sealed class Waiter { public int Pid; public long EnqueuedTick; }
|
||||
private sealed class Bucket { public int PlayerCount; public List<Waiter> Waiters = new List<Waiter>(); }
|
||||
|
||||
private readonly long _timeoutTicks;
|
||||
private int _aiSeq;
|
||||
private readonly Dictionary<string, Bucket> _buckets = new Dictionary<string, Bucket>();
|
||||
|
||||
public Matchmaker(long timeoutTicks) { _timeoutTicks = timeoutTicks; }
|
||||
private static string Key(string g, int v) => $"{g}@{v}";
|
||||
|
||||
public void Enqueue(int pid, string gameId, int version, int playerCount, long tick)
|
||||
{
|
||||
string k = Key(gameId, version);
|
||||
if (!_buckets.TryGetValue(k, out var b)) { b = new Bucket { PlayerCount = playerCount }; _buckets[k] = b; }
|
||||
b.Waiters.Add(new Waiter { Pid = pid, EnqueuedTick = tick });
|
||||
}
|
||||
|
||||
// 从等待队列移除某玩家(断线时调用);返回是否移除
|
||||
public bool Remove(int pid)
|
||||
{
|
||||
foreach (var kv in _buckets)
|
||||
{
|
||||
var ws = kv.Value.Waiters;
|
||||
for (int i = 0; i < ws.Count; i++)
|
||||
if (ws[i].Pid == pid) { ws.RemoveAt(i); return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 返回本次可成形的对局(凑齐或超时 AI 补足)
|
||||
public IReadOnlyList<Match> Poll(long currentTick)
|
||||
{
|
||||
List<Match> formed = null;
|
||||
foreach (var kv in _buckets)
|
||||
{
|
||||
var b = kv.Value;
|
||||
string[] gv = kv.Key.Split('@');
|
||||
while (b.Waiters.Count > 0 &&
|
||||
(b.Waiters.Count >= b.PlayerCount ||
|
||||
currentTick - b.Waiters[0].EnqueuedTick >= _timeoutTicks))
|
||||
{
|
||||
var m = new Match { GameId = gv[0], Version = int.Parse(gv[1]) };
|
||||
int take = b.Waiters.Count >= b.PlayerCount ? b.PlayerCount : b.Waiters.Count;
|
||||
for (int i = 0; i < take; i++)
|
||||
m.Seats.Add(new Seat { PlayerId = b.Waiters[i].Pid, IsAi = false });
|
||||
b.Waiters.RemoveRange(0, take);
|
||||
while (m.Seats.Count < b.PlayerCount)
|
||||
m.Seats.Add(new Seat { PlayerId = -(++_aiSeq), IsAi = true }); // AI 用负 pid
|
||||
(formed ??= new List<Match>()).Add(m);
|
||||
}
|
||||
}
|
||||
return formed ?? (IReadOnlyList<Match>)System.Array.Empty<Match>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Collections.Generic;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
using XWorld.Server.Host;
|
||||
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
// 房间广播 → 解析各玩家当前连接(重连后自动指向新连接)→ 包成 Game 通道帧入对应出站队列
|
||||
public sealed class RoomOutputSink : IRoomOutput
|
||||
{
|
||||
private readonly string _roomId; // 保留作诊断/日志标识
|
||||
private readonly IReadOnlyList<int> _players;
|
||||
private readonly SessionManager _sessions;
|
||||
|
||||
public RoomOutputSink(string roomId, IReadOnlyList<int> players, SessionManager sessions)
|
||||
{
|
||||
_roomId = roomId; _players = players; _sessions = sessions;
|
||||
}
|
||||
|
||||
public void Broadcast(NetMessage message)
|
||||
{
|
||||
byte[] frame = FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload));
|
||||
foreach (var pid in _players)
|
||||
_sessions.GetConnection(pid)?.Send(frame);
|
||||
}
|
||||
|
||||
public void SendTo(int playerId, NetMessage message)
|
||||
{
|
||||
var conn = _sessions.GetConnection(playerId);
|
||||
if (conn != null)
|
||||
conn.Send(FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using XWorld.Framework.Protocol;
|
||||
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
public abstract class ServerCommand { public int PlayerId; }
|
||||
|
||||
public sealed class ConnectCommand : ServerCommand { public IClientConnection Connection; }
|
||||
|
||||
public sealed class DisconnectCommand : ServerCommand { public IClientConnection Connection; }
|
||||
|
||||
public sealed class FrameCommand : ServerCommand { public Frame Frame; }
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ChannelsNS = System.Threading.Channels;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
using XWorld.Server.Host;
|
||||
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
// 单线程 actor:独占 RoomHost 与 SessionManager;命令入队、周期性排空+推进。
|
||||
public sealed class ServerLoop
|
||||
{
|
||||
private readonly RoomHost _host;
|
||||
private readonly SessionManager _sessions = new SessionManager();
|
||||
private readonly ILogger _logger;
|
||||
private readonly long _reconnectWindowTicks;
|
||||
private readonly string _gamesRoot;
|
||||
private readonly Matchmaker _matchmaker;
|
||||
private readonly ChannelsNS.Channel<ServerCommand> _cmds =
|
||||
ChannelsNS.Channel.CreateUnbounded<ServerCommand>();
|
||||
private readonly Dictionary<string, List<int>> _roomPlayers = new Dictionary<string, List<int>>();
|
||||
private readonly Dictionary<int, LobbyPlayerMsg> _lobbyPlayers = new Dictionary<int, LobbyPlayerMsg>();
|
||||
private readonly Dictionary<string, int> _playerCountCache = new Dictionary<string, int>(); // "gameId@version" -> playerCount
|
||||
private readonly HashSet<int> _queuedPids = new HashSet<int>(); // pids currently in matchmaker queue
|
||||
private readonly Random _random = new Random(1);
|
||||
private long _tick;
|
||||
private int _roomSeq;
|
||||
|
||||
public ServerLoop(string gamesRoot, ILogger logger, long reconnectWindowTicks = 100,
|
||||
long matchTimeoutTicks = 150, string publicKeyPem = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_reconnectWindowTicks = reconnectWindowTicks;
|
||||
_gamesRoot = gamesRoot;
|
||||
_host = new RoomHost(new GameModuleRegistry(new GameModuleLoader(publicKeyPem), gamesRoot), logger);
|
||||
_matchmaker = new Matchmaker(matchTimeoutTicks);
|
||||
}
|
||||
|
||||
// 线程安全:网络线程调用,仅入队
|
||||
public void Submit(ServerCommand cmd) => _cmds.Writer.TryWrite(cmd);
|
||||
|
||||
// 仅 actor 线程调用(_sessions 无锁);测试在单线程下使用
|
||||
public bool HasSession(int pid) => _sessions.Get(pid) != null;
|
||||
|
||||
// 测试直调;真实循环周期性调它。
|
||||
// 两步排空:先处理所有 Connect/Disconnect/Framework(含 MatchRequest 入队),
|
||||
// 再 Poll 匹配器建房,再处理 Game 帧(此时 RoomId 已就位)。
|
||||
public void DrainAndTick(float dt)
|
||||
{
|
||||
// 第一遍:收集所有命令,立刻处理非游戏帧(connect/disconnect/framework)
|
||||
var deferred = new List<FrameCommand>(); // 本批游戏帧,延后到建房后处理
|
||||
while (_cmds.Reader.TryRead(out var cmd))
|
||||
{
|
||||
if (cmd is FrameCommand fc && fc.Frame.Channel == Channel.Game)
|
||||
deferred.Add(fc);
|
||||
else
|
||||
Process(cmd);
|
||||
}
|
||||
|
||||
// Poll 匹配器 → 对本 tick 内已凑齐或超时的队列建房
|
||||
var matches = _matchmaker.Poll(_tick);
|
||||
for (int i = 0; i < matches.Count; i++)
|
||||
{
|
||||
_logger.Info($"match formed {matches[i].GameId}@{matches[i].Version} seats={matches[i].Seats.Count} tick={_tick}");
|
||||
CreateRoomForSeats(matches[i]);
|
||||
}
|
||||
|
||||
// 第二遍:处理本批游戏帧(此时 RoomId 已就位)
|
||||
for (int i = 0; i < deferred.Count; i++)
|
||||
{
|
||||
var fc = deferred[i];
|
||||
Route(fc.PlayerId, fc.Frame);
|
||||
}
|
||||
|
||||
_tick++;
|
||||
var ended = _host.TickAll(dt);
|
||||
for (int i = 0; i < ended.Count; i++) OnRoomEnded(ended[i].RoomId, ended[i].Result);
|
||||
var expired = _sessions.SweepExpired(_tick, _reconnectWindowTicks);
|
||||
for (int i = 0; i < expired.Count; i++)
|
||||
_logger.Info($"session {expired[i].PlayerId} 重连窗口过期,清除");
|
||||
}
|
||||
|
||||
public async Task RunAsync(int tickIntervalMs, CancellationToken ct)
|
||||
=> await RunAsync(tickIntervalMs, tickIntervalMs / 1000f, ct);
|
||||
|
||||
public async Task RunAsync(int tickIntervalMs, float logicalDt, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
DrainAndTick(logicalDt);
|
||||
try { await Task.Delay(tickIntervalMs, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
private void Process(ServerCommand cmd)
|
||||
{
|
||||
switch (cmd)
|
||||
{
|
||||
case ConnectCommand cc:
|
||||
_sessions.OnConnect(cc.PlayerId, cc.Connection);
|
||||
_logger.Info($"session connect pid={cc.PlayerId}");
|
||||
break;
|
||||
case DisconnectCommand dc:
|
||||
_sessions.OnDisconnect(dc.PlayerId, dc.Connection, _tick);
|
||||
_logger.Info($"session disconnect pid={dc.PlayerId}");
|
||||
if (_queuedPids.Remove(dc.PlayerId)) _matchmaker.Remove(dc.PlayerId);
|
||||
if (_lobbyPlayers.Remove(dc.PlayerId)) BroadcastLobbyState();
|
||||
break;
|
||||
case FrameCommand fc: Route(fc.PlayerId, fc.Frame); break;
|
||||
}
|
||||
}
|
||||
|
||||
private void Route(int pid, Frame frame)
|
||||
{
|
||||
if (frame.Channel == Channel.Framework) HandleFramework(pid, frame);
|
||||
else
|
||||
{
|
||||
var s = _sessions.Get(pid);
|
||||
if (s?.RoomId != null)
|
||||
_host.DeliverTo(s.RoomId, pid, new NetMessage(frame.Opcode, frame.Payload));
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleFramework(int pid, Frame frame)
|
||||
{
|
||||
FrameworkOpcode op = (FrameworkOpcode)frame.Opcode;
|
||||
if (op != FrameworkOpcode.LobbyMove)
|
||||
{
|
||||
_logger.Info($"framework pid={pid} op={op} bytes={frame.Payload?.Length ?? 0}");
|
||||
}
|
||||
switch (op)
|
||||
{
|
||||
case FrameworkOpcode.Heartbeat:
|
||||
SendFramework(pid, FrameworkOpcode.Heartbeat, frame.Payload); // 原样回显
|
||||
break;
|
||||
case FrameworkOpcode.MatchRequest:
|
||||
var req = MatchRequestMsg.Decode(frame.Payload);
|
||||
EnqueueMatch(pid, req.GameId, req.Version);
|
||||
break;
|
||||
case FrameworkOpcode.GameListRequest:
|
||||
SendFramework(pid, FrameworkOpcode.GameListResponse, BuildGameList().Encode());
|
||||
break;
|
||||
case FrameworkOpcode.RandomMatchRequest:
|
||||
EnqueueRandomMatch(pid);
|
||||
break;
|
||||
case FrameworkOpcode.LobbyJoin:
|
||||
HandleLobbyJoin(pid, frame.Payload);
|
||||
break;
|
||||
case FrameworkOpcode.LobbyMove:
|
||||
HandleLobbyMove(pid, frame.Payload);
|
||||
break;
|
||||
case FrameworkOpcode.LobbyLeave:
|
||||
if (_lobbyPlayers.Remove(pid)) BroadcastLobbyState();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleLobbyJoin(int pid, byte[] payload)
|
||||
{
|
||||
var req = LobbyJoinMsg.Decode(payload);
|
||||
string name = string.IsNullOrWhiteSpace(req.Name) ? $"P{pid}" : req.Name;
|
||||
int figure = req.Figure <= 0 ? 1 : req.Figure;
|
||||
if (!_lobbyPlayers.TryGetValue(pid, out var player))
|
||||
{
|
||||
float lane = (_lobbyPlayers.Count % 6) - 2.5f;
|
||||
player = new LobbyPlayerMsg
|
||||
{
|
||||
PlayerId = pid,
|
||||
Name = name,
|
||||
Figure = figure,
|
||||
X = lane * 1.5f,
|
||||
Y = 0f,
|
||||
Z = 0f,
|
||||
DirX = 0f,
|
||||
DirZ = 1f,
|
||||
Moving = false
|
||||
};
|
||||
_lobbyPlayers[pid] = player;
|
||||
}
|
||||
else
|
||||
{
|
||||
player.Name = name;
|
||||
player.Figure = figure;
|
||||
}
|
||||
_logger.Info($"lobby join pid={pid} name={name} figure={figure} players={_lobbyPlayers.Count} pids={string.Join(",", _lobbyPlayers.Keys)}");
|
||||
BroadcastLobbyState();
|
||||
}
|
||||
|
||||
private void HandleLobbyMove(int pid, byte[] payload)
|
||||
{
|
||||
if (!_lobbyPlayers.TryGetValue(pid, out var player))
|
||||
{
|
||||
_logger.Warn($"lobby move ignored pid={pid} reason=not_joined players={_lobbyPlayers.Count} pids={string.Join(",", _lobbyPlayers.Keys)}");
|
||||
return;
|
||||
}
|
||||
var move = LobbyMoveMsg.Decode(payload);
|
||||
player.X = move.X;
|
||||
player.Y = move.Y;
|
||||
player.Z = move.Z;
|
||||
player.DirX = move.DirX;
|
||||
player.DirZ = move.DirZ;
|
||||
player.Moving = move.Moving;
|
||||
|
||||
move.PlayerId = pid;
|
||||
byte[] encoded = move.Encode();
|
||||
foreach (int targetPid in _lobbyPlayers.Keys)
|
||||
{
|
||||
SendFramework(targetPid, FrameworkOpcode.LobbyMove, encoded);
|
||||
}
|
||||
}
|
||||
|
||||
private void BroadcastLobbyState()
|
||||
{
|
||||
if (_lobbyPlayers.Count == 0) return;
|
||||
var state = new LobbyStateMsg();
|
||||
foreach (var player in _lobbyPlayers.Values)
|
||||
{
|
||||
state.Players.Add(player);
|
||||
}
|
||||
byte[] payload = state.Encode();
|
||||
_logger.Info($"lobby state players={_lobbyPlayers.Count} pids={string.Join(",", _lobbyPlayers.Keys)}");
|
||||
foreach (int pid in _lobbyPlayers.Keys)
|
||||
{
|
||||
SendFramework(pid, FrameworkOpcode.LobbyState, payload);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnqueueRandomMatch(int pid)
|
||||
{
|
||||
var games = BuildGameList().Games;
|
||||
if (games.Count == 0)
|
||||
{
|
||||
SendFramework(pid, FrameworkOpcode.Error,
|
||||
new ErrorMsg { Code = 1, Message = "no minigame available" }.Encode());
|
||||
return;
|
||||
}
|
||||
|
||||
var selected = games[_random.Next(games.Count)];
|
||||
_logger.Info($"random match pid={pid} assigned {selected.GameId}@{selected.Version}");
|
||||
SendFramework(pid, FrameworkOpcode.MatchAssigned,
|
||||
new MatchAssignedMsg { GameId = selected.GameId, Version = selected.Version }.Encode());
|
||||
EnqueueMatch(pid, selected.GameId, selected.Version);
|
||||
}
|
||||
|
||||
private GameListResponseMsg BuildGameList()
|
||||
{
|
||||
var latest = new Dictionary<string, GameInfoMsg>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!Directory.Exists(_gamesRoot)) return new GameListResponseMsg();
|
||||
|
||||
foreach (var gameDir in Directory.GetDirectories(_gamesRoot))
|
||||
{
|
||||
foreach (var versionDir in Directory.GetDirectories(gameDir))
|
||||
{
|
||||
string manifestPath = Path.Combine(versionDir, "game.json");
|
||||
if (!File.Exists(manifestPath)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var manifest = GameManifest.Load(manifestPath);
|
||||
if (string.Equals(manifest.GameId, "lobby", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (manifest.PlayerCount < 2 || manifest.PlayerCount > 8) continue;
|
||||
|
||||
if (!latest.TryGetValue(manifest.GameId, out var old) || manifest.Version > old.Version)
|
||||
{
|
||||
latest[manifest.GameId] = new GameInfoMsg
|
||||
{
|
||||
GameId = manifest.GameId,
|
||||
Version = manifest.Version,
|
||||
DisplayName = manifest.GameId,
|
||||
MinPlayers = 2,
|
||||
MaxPlayers = Math.Max(2, Math.Min(8, manifest.PlayerCount)),
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn($"skip invalid game manifest {manifestPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var msg = new GameListResponseMsg();
|
||||
foreach (var game in latest.Values) msg.Games.Add(game);
|
||||
msg.Games.Sort((a, b) => string.Compare(a.GameId, b.GameId, StringComparison.OrdinalIgnoreCase));
|
||||
return msg;
|
||||
}
|
||||
|
||||
private void EnqueueMatch(int pid, string gameId, int version)
|
||||
{
|
||||
var s = _sessions.Get(pid);
|
||||
if (s == null) return; // 未建立会话,忽略
|
||||
|
||||
if (s.RoomId != null || _queuedPids.Contains(pid))
|
||||
{
|
||||
// 已在房间内或已在队列中:拒绝
|
||||
SendFramework(pid, FrameworkOpcode.Error,
|
||||
new ErrorMsg { Code = 2, Message = "already in room or queue" }.Encode());
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取游戏的 playerCount(缓存)
|
||||
string cacheKey = $"{gameId}@{version}";
|
||||
if (!_playerCountCache.TryGetValue(cacheKey, out int playerCount))
|
||||
{
|
||||
string manifestPath = Path.Combine(_gamesRoot, gameId, version.ToString(), "game.json");
|
||||
try
|
||||
{
|
||||
var manifest = GameManifest.Load(manifestPath);
|
||||
playerCount = manifest.PlayerCount;
|
||||
_playerCountCache[cacheKey] = playerCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"读取 game.json 失败 {gameId}@{version}: {ex.Message}");
|
||||
SendFramework(pid, FrameworkOpcode.Error,
|
||||
new ErrorMsg { Code = 1, Message = "game not found" }.Encode());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_queuedPids.Add(pid);
|
||||
_matchmaker.Enqueue(pid, gameId, version, playerCount, _tick);
|
||||
_logger.Info($"enqueue match pid={pid} game={gameId}@{version} playerCount={playerCount} tick={_tick}");
|
||||
}
|
||||
|
||||
private void CreateRoomForSeats(Matchmaker.Match match)
|
||||
{
|
||||
string roomId = $"r{++_roomSeq}";
|
||||
var seats = match.Seats;
|
||||
int aiCount = 0;
|
||||
for (int i = 0; i < seats.Count; i++)
|
||||
{
|
||||
if (seats[i].IsAi) aiCount++;
|
||||
}
|
||||
_logger.Info($"create room {roomId} game={match.GameId}@{match.Version} real={seats.Count - aiCount} ai={aiCount}");
|
||||
|
||||
// Build PlayerInfo[] for all seats
|
||||
var playerInfos = new PlayerInfo[seats.Count];
|
||||
var realPids = new List<int>();
|
||||
for (int i = 0; i < seats.Count; i++)
|
||||
{
|
||||
var seat = seats[i];
|
||||
playerInfos[i] = new PlayerInfo
|
||||
{
|
||||
PlayerId = seat.PlayerId,
|
||||
Name = seat.IsAi ? "AI" : $"P{seat.PlayerId}",
|
||||
IsAI = seat.IsAi
|
||||
};
|
||||
if (!seat.IsAi)
|
||||
{
|
||||
realPids.Add(seat.PlayerId);
|
||||
_queuedPids.Remove(seat.PlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
var sink = new RoomOutputSink(roomId, realPids, _sessions);
|
||||
var cfg = new RoomConfig
|
||||
{
|
||||
GameId = match.GameId,
|
||||
Version = match.Version,
|
||||
Seed = (ulong)_roomSeq,
|
||||
PlayerCount = seats.Count
|
||||
};
|
||||
|
||||
Room room;
|
||||
try
|
||||
{
|
||||
room = _host.CreateRoom(roomId, match.GameId, match.Version, cfg, playerInfos, sink);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"建房失败 {match.GameId}@{match.Version}: {ex.Message}");
|
||||
// Notify real players of error
|
||||
var errPayload = new ErrorMsg { Code = 1, Message = "create room failed" }.Encode();
|
||||
foreach (int rpid in realPids)
|
||||
SendFramework(rpid, FrameworkOpcode.Error, errPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build MatchFound message with all players info
|
||||
var allPlayers = new List<PlayerInfo>(playerInfos);
|
||||
|
||||
// For each real player: set session.RoomId + send MatchFound
|
||||
foreach (int rpid in realPids)
|
||||
{
|
||||
var s = _sessions.Get(rpid);
|
||||
if (s != null) s.RoomId = roomId;
|
||||
|
||||
var found = new MatchFoundMsg
|
||||
{
|
||||
RoomId = roomId,
|
||||
Version = match.Version,
|
||||
SelfPlayerId = rpid
|
||||
};
|
||||
foreach (var pi in allPlayers) found.Players.Add(pi);
|
||||
SendFramework(rpid, FrameworkOpcode.MatchFound, found.Encode());
|
||||
}
|
||||
|
||||
_roomPlayers[roomId] = realPids;
|
||||
|
||||
if (!room.IsActive) OnRoomEnded(roomId, room.TrustedEndResult); // 极端:Start 即结束
|
||||
}
|
||||
|
||||
private void OnRoomEnded(string roomId, RoomEndResult result)
|
||||
{
|
||||
if (!_roomPlayers.TryGetValue(roomId, out var players)) return;
|
||||
_roomPlayers.Remove(roomId);
|
||||
var msg = new RoomEndMsg
|
||||
{
|
||||
WinnerPlayerId = result?.WinnerPlayerId ?? 0,
|
||||
ResultBlob = result?.ResultBlob ?? Array.Empty<byte>()
|
||||
};
|
||||
byte[] payload = msg.Encode();
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
int pid = players[i];
|
||||
SendFramework(pid, FrameworkOpcode.RoomEnd, payload);
|
||||
var s = _sessions.Get(pid);
|
||||
if (s != null) s.RoomId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SendFramework(int pid, FrameworkOpcode op, byte[] payload)
|
||||
{
|
||||
_sessions.GetConnection(pid)?.Send(
|
||||
FrameCodec.Encode(new Frame(Channel.Framework, (ushort)op, payload)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
public sealed class Session
|
||||
{
|
||||
public int PlayerId;
|
||||
public IClientConnection Connection; // 当前连接;断线中仍保留会话但 Connected=false
|
||||
public bool Connected;
|
||||
public string RoomId; // 所在房间,可空
|
||||
public long DisconnectedAtTick; // 断线时的逻辑 tick,用于窗口过期
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
// pid -> Session。只在 actor 线程被改,故无锁。
|
||||
public sealed class SessionManager
|
||||
{
|
||||
private readonly Dictionary<int, Session> _byPid = new Dictionary<int, Session>();
|
||||
|
||||
public Session OnConnect(int pid, IClientConnection conn)
|
||||
{
|
||||
if (_byPid.TryGetValue(pid, out var s))
|
||||
{
|
||||
s.Connection = conn; // 重连:重绑新连接,保留 RoomId
|
||||
s.Connected = true;
|
||||
return s;
|
||||
}
|
||||
s = new Session { PlayerId = pid, Connection = conn, Connected = true };
|
||||
_byPid[pid] = s;
|
||||
return s;
|
||||
}
|
||||
|
||||
public void OnDisconnect(int pid, IClientConnection conn, long currentTick)
|
||||
{
|
||||
// 仅当断的是"当前连接"才标离线,避免旧连接的迟到断线清掉已重连的新连接
|
||||
if (_byPid.TryGetValue(pid, out var s) && ReferenceEquals(s.Connection, conn))
|
||||
{
|
||||
s.Connected = false;
|
||||
s.DisconnectedAtTick = currentTick;
|
||||
}
|
||||
}
|
||||
|
||||
public IClientConnection GetConnection(int pid)
|
||||
=> _byPid.TryGetValue(pid, out var s) && s.Connected ? s.Connection : null;
|
||||
|
||||
public bool IsConnected(int pid) => _byPid.TryGetValue(pid, out var s) && s.Connected;
|
||||
|
||||
public Session Get(int pid) => _byPid.TryGetValue(pid, out var s) ? s : null;
|
||||
|
||||
/// <summary>移除断线且"严格越过"重连窗口的会话(currentTick - DisconnectedAtTick > windowTicks)。返回被移除的会话;无则空数组。</summary>
|
||||
public IReadOnlyList<Session> SweepExpired(long currentTick, long windowTicks)
|
||||
{
|
||||
List<Session> removed = null;
|
||||
foreach (var kv in _byPid)
|
||||
{
|
||||
var s = kv.Value;
|
||||
if (!s.Connected && currentTick - s.DisconnectedAtTick > windowTicks)
|
||||
(removed ??= new List<Session>()).Add(s);
|
||||
}
|
||||
if (removed != null)
|
||||
foreach (var s in removed) _byPid.Remove(s.PlayerId);
|
||||
return removed ?? (IReadOnlyList<Session>)Array.Empty<Session>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
// 独立内网 CDN:Kestrel 静态文件服务,HTTP 根 = root(发布产物 client/ 目录)。
|
||||
// 仿 GameServerHost 的 Start/Stop 形态;缺失文件返回 404,无目录浏览。
|
||||
public sealed class StaticFileServer
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly int _port;
|
||||
private readonly bool _bindAll;
|
||||
private WebApplication _app;
|
||||
|
||||
public string HttpBaseUrl { get; private set; } // 例如 http://127.0.0.1:15081
|
||||
|
||||
public StaticFileServer(string root, int port = 15081, bool bindAllInterfaces = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root)) throw new ArgumentException("root 不能为空", nameof(root));
|
||||
_root = Path.GetFullPath(root);
|
||||
_port = port;
|
||||
_bindAll = bindAllInterfaces;
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
Directory.CreateDirectory(_root); // 根目录不存在则建空目录(GET 全 404,而非启动崩溃)
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.WebHost.ConfigureKestrel(o =>
|
||||
o.Listen(_bindAll ? System.Net.IPAddress.Any : System.Net.IPAddress.Loopback, _port)); // 0 = 临时端口
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var provider = new PhysicalFileProvider(_root);
|
||||
var opts = new StaticFileOptions
|
||||
{
|
||||
FileProvider = provider,
|
||||
ServeUnknownFileTypes = true, // .bytes/.unity3d/.sig 等无标准 MIME 也要发
|
||||
DefaultContentType = "application/octet-stream",
|
||||
};
|
||||
app.UseStaticFiles(opts); // 仅静态文件,不开目录浏览;未命中落到下面的 404
|
||||
|
||||
await app.StartAsync();
|
||||
_app = app;
|
||||
|
||||
string http = app.Services.GetRequiredService<IServer>()
|
||||
.Features.Get<IServerAddressesFeature>().Addresses.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Kestrel 已启动但未注册任何监听地址");
|
||||
HttpBaseUrl = http;
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_app == null) return;
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
_app = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user