458 lines
20 KiB
C#
458 lines
20 KiB
C#
#nullable enable
|
|
|
|
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using Cysharp.Threading.Tasks;
|
|
using Demo2.Domain;
|
|
using Newtonsoft.Json;
|
|
using ShrinkEventBus;
|
|
using ShrinkNetwork;
|
|
|
|
namespace Demo2.Runtime
|
|
{
|
|
public sealed class Demo2LanRuntime : IDisposable
|
|
{
|
|
private sealed class Peer
|
|
{
|
|
public long SessionId;
|
|
public string PlayerId = string.Empty;
|
|
public string Token = string.Empty;
|
|
public bool Ready;
|
|
public DateTime ReconnectUntilUtc;
|
|
}
|
|
|
|
private readonly Demo2GameService _service;
|
|
private readonly ConcurrentQueue<ShrinkNetworkTransportEvent> _events = new();
|
|
private readonly Dictionary<long, Peer> _peers = new();
|
|
private readonly Dictionary<string, Peer> _reconnectable = new(StringComparer.Ordinal);
|
|
private readonly List<Demo2LobbyPlayer> _players = new();
|
|
private IShrinkNetworkTransport? _transport;
|
|
private UdpClient? _discoveryResponder;
|
|
private long _clientSessionId = 1;
|
|
private long _networkSequence;
|
|
private Demo2Phase _lastSyncedPhase = Demo2Phase.Title;
|
|
private int _lastSyncedCycle;
|
|
private int _port;
|
|
|
|
public bool IsHost { get; private set; }
|
|
public bool IsConnected { get; private set; }
|
|
public Demo2TransportMode TransportMode { get; private set; }
|
|
public string LocalPlayerId { get; private set; } = "local";
|
|
public string ReconnectToken { get; private set; } = string.Empty;
|
|
public string Status { get; private set; } = "offline";
|
|
public IReadOnlyList<Demo2LobbyPlayer> Players => _players;
|
|
|
|
public event Action? Changed;
|
|
|
|
public Demo2LanRuntime(Demo2GameService service)
|
|
{
|
|
_service = service;
|
|
_service.TickAdvanced += OnTickAdvanced;
|
|
_service.AuthoritativeBuildAccepted += OnAuthoritativeBuildAccepted;
|
|
}
|
|
|
|
public void Host(int port, Demo2TransportMode mode, string playerId = "host")
|
|
{
|
|
Stop();
|
|
IsHost = true;
|
|
IsConnected = true;
|
|
TransportMode = mode;
|
|
LocalPlayerId = string.IsNullOrWhiteSpace(playerId) ? "host" : playerId.Trim();
|
|
ReconnectToken = Guid.NewGuid().ToString("N");
|
|
_port = port;
|
|
_transport = mode == Demo2TransportMode.Tcp
|
|
? new ShrinkTcpServerTransport(IPAddress.Any, port)
|
|
: new ShrinkKcpServerTransport(IPAddress.Any, port);
|
|
BindAndStart();
|
|
_players.Add(new Demo2LobbyPlayer { PlayerId = LocalPlayerId, DisplayName = LocalPlayerId, Connected = true });
|
|
StartDiscoveryResponder(port + 1);
|
|
SetStatus("hosting", $"{mode} 0.0.0.0:{port}");
|
|
}
|
|
|
|
public void Join(string host, int port, Demo2TransportMode mode, string playerId = "client")
|
|
{
|
|
Stop();
|
|
IsHost = false;
|
|
TransportMode = mode;
|
|
LocalPlayerId = string.IsNullOrWhiteSpace(playerId) ? "client" : playerId.Trim();
|
|
_port = port;
|
|
_transport = mode == Demo2TransportMode.Tcp
|
|
? new ShrinkTcpClientTransport(host, port, 1)
|
|
: new ShrinkKcpClientTransport(host, port, sessionId: 1);
|
|
BindAndStart();
|
|
SetStatus("connecting", $"{mode} {host}:{port}");
|
|
}
|
|
|
|
public void Pump()
|
|
{
|
|
while (_events.TryDequeue(out var transportEvent))
|
|
{
|
|
switch (transportEvent.Type)
|
|
{
|
|
case ShrinkNetworkTransportEventType.Connected:
|
|
if (IsHost) _peers[transportEvent.SessionId] = new Peer { SessionId = transportEvent.SessionId };
|
|
else
|
|
{
|
|
_clientSessionId = transportEvent.SessionId;
|
|
Send(transportEvent.SessionId, "handshake", BuildHandshake());
|
|
}
|
|
break;
|
|
case ShrinkNetworkTransportEventType.Disconnected:
|
|
HandleDisconnected(transportEvent.SessionId);
|
|
break;
|
|
case ShrinkNetworkTransportEventType.Packet:
|
|
HandlePacket(transportEvent.SessionId, transportEvent.PacketData);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (IsHost)
|
|
{
|
|
var expired = _reconnectable.Where(pair => pair.Value.ReconnectUntilUtc < DateTime.UtcNow).Select(pair => pair.Key).ToArray();
|
|
foreach (var key in expired) _reconnectable.Remove(key);
|
|
if (_service.Phase != _lastSyncedPhase || _service.Cycle != _lastSyncedCycle)
|
|
{
|
|
_lastSyncedPhase = _service.Phase;
|
|
_lastSyncedCycle = _service.Cycle;
|
|
Broadcast("snapshot", Compress(_service.CaptureSnapshot()));
|
|
}
|
|
}
|
|
}
|
|
|
|
public void SendBuild(Demo2BuildCommand command)
|
|
{
|
|
if (IsHost) _service.SubmitBuild(command);
|
|
else Send(_clientSessionId, "build", command);
|
|
}
|
|
|
|
public void SetReady(bool ready)
|
|
{
|
|
if (IsHost)
|
|
{
|
|
var local = _players.FirstOrDefault(value => value.PlayerId == LocalPlayerId);
|
|
if (local != null) local.Ready = ready;
|
|
_service.SetReady(LocalPlayerId, ready);
|
|
BroadcastLobby();
|
|
}
|
|
else Send(_clientSessionId, "ready", ready);
|
|
}
|
|
|
|
public void ChooseReward(string ruleId, int slot)
|
|
{
|
|
if (IsHost) { _service.ChooseReward(ruleId, slot); Broadcast("snapshot", Compress(_service.CaptureSnapshot())); }
|
|
else Send(_clientSessionId, "reward", new RewardRequest { RuleId = ruleId, Slot = slot });
|
|
}
|
|
|
|
public async UniTask<IReadOnlyList<string>> DiscoverAsync(int discoveryPort)
|
|
{
|
|
using var udp = new UdpClient();
|
|
udp.EnableBroadcast = true;
|
|
var request = Encoding.UTF8.GetBytes("DEMO2_DISCOVER_V1");
|
|
await udp.SendAsync(request, request.Length, new IPEndPoint(IPAddress.Broadcast, discoveryPort));
|
|
var result = new List<string>();
|
|
var deadline = DateTime.UtcNow.AddSeconds(1.2);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
var receive = udp.ReceiveAsync();
|
|
var completed = await Task.WhenAny(receive, Task.Delay(150));
|
|
if (completed != receive) continue;
|
|
var packet = receive.Result;
|
|
var value = Encoding.UTF8.GetString(packet.Buffer);
|
|
if (value.StartsWith("DEMO2_ROOM|", StringComparison.Ordinal)) result.Add(packet.RemoteEndPoint.Address + "|" + value);
|
|
}
|
|
return result.Distinct(StringComparer.Ordinal).ToArray();
|
|
}
|
|
|
|
public void Stop()
|
|
{
|
|
if (_transport != null) _transport.OnEvent -= Enqueue;
|
|
_transport?.Stop();
|
|
_transport = null;
|
|
_discoveryResponder?.Close();
|
|
_discoveryResponder = null;
|
|
_peers.Clear();
|
|
_reconnectable.Clear();
|
|
_players.Clear();
|
|
IsConnected = false;
|
|
IsHost = false;
|
|
Status = "offline";
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Stop();
|
|
_service.TickAdvanced -= OnTickAdvanced;
|
|
_service.AuthoritativeBuildAccepted -= OnAuthoritativeBuildAccepted;
|
|
}
|
|
|
|
private void BindAndStart()
|
|
{
|
|
_transport!.OnEvent += Enqueue;
|
|
_transport.Start();
|
|
}
|
|
|
|
private void Enqueue(ShrinkNetworkTransportEvent value) => _events.Enqueue(value);
|
|
|
|
private void HandlePacket(long sessionId, byte[] packet)
|
|
{
|
|
Demo2NetworkEnvelope? envelope;
|
|
try { envelope = JsonConvert.DeserializeObject<Demo2NetworkEnvelope>(Encoding.UTF8.GetString(packet)); }
|
|
catch (Exception ex) { SetStatus("protocol-error", ex.Message); return; }
|
|
if (envelope == null) return;
|
|
|
|
if (IsHost)
|
|
{
|
|
switch (envelope.Type)
|
|
{
|
|
case "handshake": HandleHandshake(sessionId, Read<Demo2Handshake>(envelope)); break;
|
|
case "build": HandleHostBuild(sessionId, Read<Demo2BuildCommand>(envelope)); break;
|
|
case "snapshot-request": Send(sessionId, "snapshot", Compress(_service.CaptureSnapshot())); break;
|
|
case "ready": HandleReady(sessionId, Read<bool>(envelope)); break;
|
|
case "reward": HandleReward(sessionId, Read<RewardRequest>(envelope)); break;
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch (envelope.Type)
|
|
{
|
|
case "handshake-result": HandleHandshakeResult(Read<Demo2HandshakeResult>(envelope)); break;
|
|
case "authoritative-build": _service.SubmitBuild(Read<Demo2BuildCommand>(envelope), false); break;
|
|
case "build-result":
|
|
var result = Read<Demo2CommandResult>(envelope);
|
|
if (!result.Accepted) SetStatus("build-rejected", result.ErrorCode);
|
|
break;
|
|
case "hash": HandleHash(Read<Demo2HashBroadcast>(envelope)); break;
|
|
case "snapshot": _service.RestoreSnapshot(Decompress(Read<string>(envelope))); break;
|
|
case "lobby": ApplyLobby(Read<Demo2LobbyState>(envelope)); break;
|
|
}
|
|
}
|
|
|
|
private void HandleHandshake(long sessionId, Demo2Handshake handshake)
|
|
{
|
|
var result = Validate(handshake);
|
|
if (!result.Accepted) { Send(sessionId, "handshake-result", result); Disconnect(sessionId, result.ErrorCode); return; }
|
|
var peer = ResolvePeer(sessionId, handshake);
|
|
result.PlayerId = peer.PlayerId;
|
|
result.ReconnectToken = peer.Token;
|
|
Send(sessionId, "handshake-result", result);
|
|
Send(sessionId, "snapshot", Compress(_service.CaptureSnapshot()));
|
|
UpsertPlayer(peer.PlayerId, true, peer.Ready);
|
|
BroadcastLobby();
|
|
SetStatus("peer-connected", peer.PlayerId);
|
|
}
|
|
|
|
private Demo2HandshakeResult Validate(Demo2Handshake handshake)
|
|
{
|
|
if (!string.Equals(handshake.GameVersion, Demo2Protocol.GameVersion, StringComparison.Ordinal))
|
|
return new Demo2HandshakeResult { ErrorCode = "version.game", Difference = $"host={Demo2Protocol.GameVersion}; client={handshake.GameVersion}" };
|
|
if (handshake.SimulationVersion != Demo2Protocol.SimulationVersion)
|
|
return new Demo2HandshakeResult { ErrorCode = "version.simulation", Difference = $"host={Demo2Protocol.SimulationVersion}; client={handshake.SimulationVersion}" };
|
|
var local = LocalMods();
|
|
var remote = handshake.Mods.OrderBy(value => value.Id, StringComparer.Ordinal).ToArray();
|
|
if (local.Count != remote.Length || local.Where((value, index) => value.Id != remote[index].Id || value.Version != remote[index].Version || value.ContentSha256 != remote[index].ContentSha256).Any())
|
|
return new Demo2HandshakeResult { ErrorCode = "mods.mismatch", Difference = "host=" + JsonConvert.SerializeObject(local) + "; client=" + JsonConvert.SerializeObject(remote) };
|
|
return new Demo2HandshakeResult { Accepted = true };
|
|
}
|
|
|
|
private Peer ResolvePeer(long sessionId, Demo2Handshake handshake)
|
|
{
|
|
Peer peer;
|
|
if (!string.IsNullOrWhiteSpace(handshake.ReconnectToken) && _reconnectable.TryGetValue(handshake.ReconnectToken, out var old) && old.ReconnectUntilUtc >= DateTime.UtcNow)
|
|
{
|
|
peer = old;
|
|
_reconnectable.Remove(old.Token);
|
|
peer.SessionId = sessionId;
|
|
}
|
|
else
|
|
{
|
|
var requested = string.IsNullOrWhiteSpace(handshake.PlayerId) ? "player" : handshake.PlayerId.Trim();
|
|
var suffix = 1;
|
|
var playerId = requested;
|
|
while (_players.Any(value => value.PlayerId == playerId)) playerId = requested + "-" + ++suffix;
|
|
peer = new Peer { SessionId = sessionId, PlayerId = playerId, Token = Guid.NewGuid().ToString("N") };
|
|
}
|
|
_peers[sessionId] = peer;
|
|
return peer;
|
|
}
|
|
|
|
private void HandleHostBuild(long sessionId, Demo2BuildCommand command)
|
|
{
|
|
if (!_peers.TryGetValue(sessionId, out var peer)) return;
|
|
command.PlayerId = peer.PlayerId;
|
|
var result = _service.SubmitBuild(command);
|
|
Send(sessionId, "build-result", result);
|
|
}
|
|
|
|
private void HandleReady(long sessionId, bool ready)
|
|
{
|
|
if (!_peers.TryGetValue(sessionId, out var peer)) return;
|
|
peer.Ready = ready;
|
|
UpsertPlayer(peer.PlayerId, true, ready);
|
|
_service.SetReady(peer.PlayerId, ready);
|
|
BroadcastLobby();
|
|
}
|
|
|
|
private void HandleReward(long sessionId, RewardRequest request)
|
|
{
|
|
if (!_peers.ContainsKey(sessionId)) return;
|
|
if (_service.ChooseReward(request.RuleId, request.Slot)) Broadcast("snapshot", Compress(_service.CaptureSnapshot()));
|
|
}
|
|
|
|
private void HandleHandshakeResult(Demo2HandshakeResult result)
|
|
{
|
|
IsConnected = result.Accepted;
|
|
if (result.Accepted)
|
|
{
|
|
LocalPlayerId = result.PlayerId;
|
|
ReconnectToken = result.ReconnectToken;
|
|
SetStatus("connected", result.PlayerId);
|
|
}
|
|
else SetStatus("rejected:" + result.ErrorCode, result.Difference);
|
|
}
|
|
|
|
private void HandleHash(Demo2HashBroadcast broadcast)
|
|
{
|
|
var world = _service.EcsWorld;
|
|
if (world == null) { Send(_clientSessionId, "snapshot-request", broadcast.Tick); return; }
|
|
var local = world.EntityManager.GetComponentData<Demo2.ECS.Demo2SimulationState>(world.Singleton);
|
|
if (local.Tick == broadcast.Tick && local.StateHash != broadcast.StateHash)
|
|
{
|
|
SetStatus("hash-mismatch", $"tick={broadcast.Tick} local={local.StateHash:x16} host={broadcast.StateHash:x16}");
|
|
Send(_clientSessionId, "snapshot-request", broadcast.Tick);
|
|
}
|
|
}
|
|
|
|
private void HandleDisconnected(long sessionId)
|
|
{
|
|
if (IsHost && _peers.TryGetValue(sessionId, out var peer))
|
|
{
|
|
_peers.Remove(sessionId);
|
|
peer.ReconnectUntilUtc = DateTime.UtcNow.AddSeconds(30);
|
|
_reconnectable[peer.Token] = peer;
|
|
UpsertPlayer(peer.PlayerId, false, peer.Ready);
|
|
BroadcastLobby();
|
|
}
|
|
else if (!IsHost) { IsConnected = false; SetStatus("disconnected", "30-second reconnect window"); }
|
|
}
|
|
|
|
private void OnTickAdvanced(long tick, ulong hash)
|
|
{
|
|
if (IsHost && tick > 0 && tick % 20 == 0) Broadcast("hash", new Demo2HashBroadcast { Tick = tick, StateHash = hash });
|
|
}
|
|
|
|
private void OnAuthoritativeBuildAccepted(Demo2BuildCommand command)
|
|
{
|
|
if (IsHost) Broadcast("authoritative-build", command);
|
|
}
|
|
|
|
private void ApplyLobby(Demo2LobbyState state)
|
|
{
|
|
_players.Clear();
|
|
_players.AddRange(state.Players);
|
|
Changed?.Invoke();
|
|
}
|
|
|
|
private void BroadcastLobby() => Broadcast("lobby", new Demo2LobbyState { IsHost = true, Transport = TransportMode, Players = _players.ToList() });
|
|
|
|
private void UpsertPlayer(string playerId, bool connected, bool ready)
|
|
{
|
|
var row = _players.FirstOrDefault(value => value.PlayerId == playerId);
|
|
if (row == null) { row = new Demo2LobbyPlayer { PlayerId = playerId, DisplayName = playerId }; _players.Add(row); }
|
|
row.Connected = connected;
|
|
row.Ready = ready;
|
|
}
|
|
|
|
private Demo2Handshake BuildHandshake() => new()
|
|
{
|
|
GameVersion = Demo2Protocol.GameVersion,
|
|
SimulationVersion = Demo2Protocol.SimulationVersion,
|
|
PlayerId = LocalPlayerId,
|
|
ReconnectToken = ReconnectToken,
|
|
Mods = LocalMods()
|
|
};
|
|
|
|
private List<Demo2ModManifest> LocalMods() => new()
|
|
{
|
|
new Demo2ModManifest { Id = "demo2.builtin", Version = Demo2Protocol.GameVersion, ContentSha256 = _service.Content.ComputeContentHash() }
|
|
};
|
|
|
|
private void Broadcast(string type, object payload)
|
|
{
|
|
if (!IsHost || _transport == null) return;
|
|
foreach (var sessionId in _peers.Keys.ToArray()) Send(sessionId, type, payload);
|
|
}
|
|
|
|
private void Send(long sessionId, string type, object payload)
|
|
{
|
|
if (_transport == null || !_transport.IsStarted) return;
|
|
var envelope = new Demo2NetworkEnvelope { Type = type, Sequence = ++_networkSequence, PayloadJson = JsonConvert.SerializeObject(payload) };
|
|
_transport.Send(sessionId, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(envelope)));
|
|
}
|
|
|
|
private static T Read<T>(Demo2NetworkEnvelope envelope) => JsonConvert.DeserializeObject<T>(envelope.PayloadJson)!;
|
|
|
|
private static string Compress(Demo2Snapshot snapshot)
|
|
{
|
|
var input = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(snapshot));
|
|
using var output = new MemoryStream();
|
|
using (var gzip = new GZipStream(output, CompressionLevel.Fastest, true)) gzip.Write(input, 0, input.Length);
|
|
return Convert.ToBase64String(output.ToArray());
|
|
}
|
|
|
|
private static Demo2Snapshot Decompress(string value)
|
|
{
|
|
var input = Convert.FromBase64String(value);
|
|
using var source = new MemoryStream(input);
|
|
using var gzip = new GZipStream(source, CompressionMode.Decompress);
|
|
using var reader = new StreamReader(gzip, Encoding.UTF8);
|
|
return JsonConvert.DeserializeObject<Demo2Snapshot>(reader.ReadToEnd()) ?? throw new InvalidDataException("Invalid Demo2 snapshot.");
|
|
}
|
|
|
|
private void Disconnect(long sessionId, string reason)
|
|
{
|
|
if (_transport is IShrinkNetworkSessionControlTransport control) control.DisconnectSession(sessionId, reason);
|
|
}
|
|
|
|
private void SetStatus(string status, string detail)
|
|
{
|
|
Status = string.IsNullOrWhiteSpace(detail) ? status : status + " | " + detail;
|
|
EventBus.TriggerEvent(new Demo2NetworkEvent { Status = status, Detail = detail });
|
|
Changed?.Invoke();
|
|
}
|
|
|
|
private void StartDiscoveryResponder(int port)
|
|
{
|
|
try
|
|
{
|
|
_discoveryResponder = new UdpClient(port);
|
|
_ = Task.Run(async () =>
|
|
{
|
|
while (_discoveryResponder != null)
|
|
{
|
|
try
|
|
{
|
|
var request = await _discoveryResponder.ReceiveAsync();
|
|
if (Encoding.UTF8.GetString(request.Buffer) != "DEMO2_DISCOVER_V1") continue;
|
|
var response = Encoding.UTF8.GetBytes($"DEMO2_ROOM|{TransportMode}|{_port}|{_players.Count}");
|
|
await _discoveryResponder.SendAsync(response, response.Length, request.RemoteEndPoint);
|
|
}
|
|
catch (ObjectDisposedException) { break; }
|
|
catch (SocketException) { break; }
|
|
}
|
|
});
|
|
}
|
|
catch (SocketException ex) { SetStatus("discovery-unavailable", ex.Message); }
|
|
}
|
|
|
|
private sealed class RewardRequest { public string RuleId = string.Empty; public int Slot; }
|
|
}
|
|
}
|