#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 _events = new(); private readonly Dictionary _peers = new(); private readonly Dictionary _reconnectable = new(StringComparer.Ordinal); private readonly List _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 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> 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(); 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(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(envelope)); break; case "build": HandleHostBuild(sessionId, Read(envelope)); break; case "snapshot-request": Send(sessionId, "snapshot", Compress(_service.CaptureSnapshot())); break; case "ready": HandleReady(sessionId, Read(envelope)); break; case "reward": HandleReward(sessionId, Read(envelope)); break; } return; } switch (envelope.Type) { case "handshake-result": HandleHandshakeResult(Read(envelope)); break; case "authoritative-build": _service.SubmitBuild(Read(envelope), false); break; case "build-result": var result = Read(envelope); if (!result.Accepted) SetStatus("build-rejected", result.ErrorCode); break; case "hash": HandleHash(Read(envelope)); break; case "snapshot": _service.RestoreSnapshot(Decompress(Read(envelope))); break; case "lobby": ApplyLobby(Read(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(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 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(Demo2NetworkEnvelope envelope) => JsonConvert.DeserializeObject(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(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; } } }