Files
Workspace/Assets/Demos/Demo2/Runtime/Demo2LanRuntime.cs
T
2026-08-18 02:03:34 +08:00

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; }
}
}