#nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using ReplacedPerson.Core; using ShrinkEventBus; using ShrinkNetwork; using UnityEngine; namespace ReplacedPerson.Runtime { public sealed class ReplacedLanRuntime : MonoBehaviour { private const int TcpPort = 18140; private const int KcpPort = 18141; private const int DiscoveryPort = 18142; private IShrinkNetworkTransport? _server; private IShrinkNetworkTransport? _client; private CancellationTokenSource? _discoveryCts; private readonly object _discoveryLock = new(); private readonly List _discoveredRooms = new(); private readonly Dictionary _serverPlayers = new(); private readonly ConcurrentQueue _mainThreadActions = new(); private ReplacedRoomAuthority? _room; private ReplacedMatchState? _networkState; private string _roomId = string.Empty; private string _localPlayerId = string.Empty; private long _clientSessionId; private bool _clientUsesKcp; private bool _isHost; private bool _autoPlay; private bool _autoReported; private float _nextAutoActionAt; public static ReplacedLanRuntime? Instance { get; private set; } public string Status { get; private set; } = "尚未连接"; public IReadOnlyList DiscoveredRooms { get { lock (_discoveryLock) return _discoveredRooms.ToArray(); } } public ReplacedMatchState? MatchState => _isHost ? _room?.Match?.State : _networkState; public ReplacedContentCatalog Content => ReplacedPersonGameService.Current?.Content.Current ?? ReplacedBuiltInContent.Create(); public int LocalPlayerIndex { get; private set; } = -1; public bool IsMatchReady => MatchState != null; public bool AwaitingCommand { get; private set; } public event Action? Changed; public void EnableAutoPlay() => _autoPlay = true; public static ReplacedLanRuntime Ensure() { if (Instance != null) return Instance; var host = new GameObject("ReplacedPersonLanRuntime"); DontDestroyOnLoad(host); return host.AddComponent(); } private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; _localPlayerId = LoadPlayerId(); _discoveryCts = new CancellationTokenSource(); ListenForDiscoveryAsync(_discoveryCts.Token); } private void OnDestroy() { _discoveryCts?.Cancel(); _server?.Stop(); _client?.Stop(); if (Instance == this) Instance = null; } private void Update() { var processed = 0; while (processed++ < 100 && _mainThreadActions.TryDequeue(out var action)) action(); if (!_autoPlay || Time.unscaledTime < _nextAutoActionAt) return; var state = MatchState; if (state == null || LocalPlayerIndex < 0 || AwaitingCommand) return; if (state.Phase == ReplacedMatchPhase.Completed) { if (!_autoReported) { _autoReported = true; Debug.Log($"[ReplacedPerson.LAN.Auto] completed winner={state.Winner} rounds={state.Round}"); } return; } if (_isHost && state.Round >= 12 && _room?.Seats[1] != null) { var timedOutPlayer = _room.Seats[1]!.PlayerId; _room.RecordTurnTimeout(timedOutPlayer); _room.RecordTurnTimeout(timedOutPlayer); BroadcastSnapshots(); return; } var expected = state.Phase == ReplacedMatchPhase.AwaitingLead ? state.LeadPlayer : 1 - state.LeadPlayer; if (expected != LocalPlayerIndex) return; _nextAutoActionAt = Time.unscaledTime + .15f; var submission = new ReplacedAi(Content).Choose(state.Clone(), LocalPlayerIndex); var result = Submit(submission); if (!result.Accepted) Debug.LogError($"[ReplacedPerson.LAN.Auto] rejected {result.ErrorCode}: {result.Message}"); } public void StartHost(bool useKcp) { ResetSession(); _isHost = true; LocalPlayerIndex = 0; _roomId = "RP-" + Environment.MachineName; _room = new ReplacedRoomAuthority(_roomId, Content, Array.Empty(), unchecked((ulong)DateTime.UtcNow.Ticks)); _room.TryJoin(JoinRequest(_roomId, _localPlayerId, Environment.UserName), DateTime.UtcNow); _room.SetDeckAndReady(_localPlayerId, LocalDeck()); _server = useKcp ? new ShrinkKcpServerTransport(IPAddress.Any, KcpPort) : new ShrinkTcpServerTransport(IPAddress.Any, TcpPort); _server.OnEvent += QueueServerEvent; _server.Start(); Status = $"房间 {_roomId} 已开启 · {(useKcp ? "KCP" : "TCP")} · {LocalAddress()}:{(useKcp ? KcpPort : TcpPort)} · 等待第二名玩家"; Debug.Log("[ReplacedPerson.LAN] " + Status); Publish("hosting", Status); Changed?.Invoke(); BroadcastDiscoveryAsync(useKcp, _discoveryCts?.Token ?? CancellationToken.None); } public void Join(string address, bool useKcp) { ResetSession(); _isHost = false; _clientUsesKcp = useKcp; _roomId = string.Empty; _client = useKcp ? new ShrinkKcpClientTransport(address, KcpPort) : new ShrinkTcpClientTransport(address, TcpPort); _client.OnEvent += QueueClientEvent; _client.Start(); Status = $"正在连接 {address}:{(useKcp ? KcpPort : TcpPort)} ({(useKcp ? "KCP" : "TCP")})"; Debug.Log("[ReplacedPerson.LAN] " + Status); Publish("connecting", Status); Changed?.Invoke(); } public ReplacedCommandResult Submit(ReplacedTurnSubmission submission) { var state = MatchState; if (state == null || LocalPlayerIndex < 0) return Reject("match.not-started", "LAN 对局尚未开始。"); if (AwaitingCommand) return Reject("command.pending", "上一条指令仍在等待权威端确认。"); var command = new ReplacedMatchCommand { PlayerIndex = LocalPlayerIndex, Sequence = state.LastAcceptedSequence[LocalPlayerIndex] + 1, IdempotencyToken = $"lan-{_localPlayerId}-{Guid.NewGuid():N}", Submission = submission }; if (_isHost) { var result = _room!.Apply(_localPlayerId, command); if (result.Accepted) BroadcastSnapshots(); return result; } if (_client == null || _clientSessionId == 0) return Reject("network.disconnected", "尚未连接权威端。"); AwaitingCommand = true; Send(_client, _clientSessionId, new ReplacedNetworkEnvelope { Kind = ReplacedNetworkMessageKind.MatchCommand, RoomId = _roomId, PlayerId = _localPlayerId, Sequence = command.Sequence, IdempotencyToken = command.IdempotencyToken, Payload = JsonConvert.SerializeObject(command) }); Changed?.Invoke(); return new ReplacedCommandResult { Accepted = true, Message = "sent" }; } private void HandleServerEvent(ShrinkNetworkTransportEvent value) { if (_server == null) return; if (value.Type == ShrinkNetworkTransportEventType.Packet) { ReplacedNetworkEnvelope? envelope; try { envelope = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(value.PacketData)); } catch (Exception exception) { SendError(_server, value.SessionId, "protocol.invalid-json", exception.Message); return; } if (envelope == null) { SendError(_server, value.SessionId, "protocol.empty", "empty envelope"); return; } HandleAuthorityEnvelope(value.SessionId, envelope); } else if (value.Type == ShrinkNetworkTransportEventType.Disconnected && _serverPlayers.Remove(value.SessionId, out var playerId)) { _room?.Disconnect(playerId, DateTime.UtcNow); Status = "玩家断开;席位保留 30 秒"; Publish("disconnected", Status); Changed?.Invoke(); } } private void QueueServerEvent(ShrinkNetworkTransportEvent value) => _mainThreadActions.Enqueue(() => HandleServerEvent(value)); private void HandleAuthorityEnvelope(long sessionId, ReplacedNetworkEnvelope envelope) { if (_server == null || _room == null) return; try { switch (envelope.Kind) { case ReplacedNetworkMessageKind.Join: case ReplacedNetworkMessageKind.Reconnect: { var request = JsonConvert.DeserializeObject(envelope.Payload) ?? new ReplacedJoinRequest(); request.RoomId = _roomId; var compatibility = _room.TryJoin(request, DateTime.UtcNow); if (!compatibility.Compatible) { SendError(_server, sessionId, "mod.mismatch", string.Join(";", compatibility.Differences)); return; } _serverPlayers[sessionId] = request.PlayerId; Debug.Log($"[ReplacedPerson.LAN] player joined id={request.PlayerId} session={sessionId}"); SendResult(_server, sessionId, true, "joined", _roomId); break; } case ReplacedNetworkMessageKind.Deck: { var playerId = BoundPlayer(sessionId); var deck = JsonConvert.DeserializeObject(envelope.Payload) ?? new ReplacedDeck(); var error = _room.SetDeck(playerId, deck); SendResult(_server, sessionId, string.IsNullOrEmpty(error), error, string.IsNullOrEmpty(error) ? "deck-accepted" : error); break; } case ReplacedNetworkMessageKind.Ready: { var playerId = BoundPlayer(sessionId); var request = JsonConvert.DeserializeObject(envelope.Payload) ?? new ReplacedReadyRequest(); var error = _room.SetReady(playerId, request.Ready); Debug.Log($"[ReplacedPerson.LAN] ready id={playerId} accepted={string.IsNullOrEmpty(error)} matchStarted={_room.Match != null}"); SendResult(_server, sessionId, string.IsNullOrEmpty(error), error, string.IsNullOrEmpty(error) ? "ready" : error); BroadcastSnapshots(); break; } case ReplacedNetworkMessageKind.MatchCommand: { var playerId = BoundPlayer(sessionId); var command = JsonConvert.DeserializeObject(envelope.Payload) ?? new ReplacedMatchCommand(); var result = _room.Apply(playerId, command); SendResult(_server, sessionId, result.Accepted, result.ErrorCode, result.Duplicate ? "duplicate" : result.Message, result.Duplicate); if (result.Accepted) BroadcastSnapshots(); break; } default: SendError(_server, sessionId, "protocol.unsupported", envelope.Kind.ToString()); break; } } catch (Exception exception) { SendError(_server, sessionId, "authority.exception", exception.Message); } } private void HandleClientEvent(ShrinkNetworkTransportEvent value) { if (_client == null) return; if (value.Type == ShrinkNetworkTransportEventType.Connected) { _clientSessionId = value.SessionId; var join = JoinRequest(string.Empty, _localPlayerId, Environment.UserName); Send(_client, value.SessionId, Envelope(ReplacedNetworkMessageKind.Join, join)); Send(_client, value.SessionId, Envelope(ReplacedNetworkMessageKind.Deck, LocalDeck())); Send(_client, value.SessionId, Envelope(ReplacedNetworkMessageKind.Ready, new ReplacedReadyRequest { Ready = true })); } else if (value.Type == ShrinkNetworkTransportEventType.Packet) { var envelope = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(value.PacketData)); if (envelope == null) return; if (envelope.Kind == ReplacedNetworkMessageKind.MatchSnapshot) { var payload = JsonConvert.DeserializeObject(envelope.Payload); if (payload != null) { _roomId = payload.Room.RoomId; _networkState = payload.Match; LocalPlayerIndex = payload.LocalPlayerIndex; AwaitingCommand = false; Status = payload.Match == null ? "已准备,等待另一名玩家" : $"LAN 对局进行中 · 回合 {payload.Match.Round}"; if (_autoPlay && payload.Match != null) Debug.Log($"[ReplacedPerson.LAN] snapshot round={payload.Match.Round} phase={payload.Match.Phase} hash={envelope.StateHash}"); } } else if (envelope.Kind == ReplacedNetworkMessageKind.Error) { var result = JsonConvert.DeserializeObject(envelope.Payload); AwaitingCommand = false; Status = "加入或指令失败:" + (result?.Detail ?? envelope.Payload); } else if (envelope.Kind == ReplacedNetworkMessageKind.MatchEvent) { var result = JsonConvert.DeserializeObject(envelope.Payload); if (result?.Code == "joined") _roomId = result.Detail; if (result?.Accepted == false) AwaitingCommand = false; } Publish("network", Status); Changed?.Invoke(); } else if (value.Type == ShrinkNetworkTransportEventType.Disconnected) { AwaitingCommand = false; Status = "连接已断开,可在 30 秒内重连"; Publish("disconnected", Status); Changed?.Invoke(); } } private void QueueClientEvent(ShrinkNetworkTransportEvent value) => _mainThreadActions.Enqueue(() => HandleClientEvent(value)); private void BroadcastSnapshots() { if (_server == null || _room == null) return; foreach (var pair in _serverPlayers) { var payload = new ReplacedMatchSnapshotPayload { Room = _room.Snapshot(), Match = _room.PlayerMatchSnapshot(pair.Value), LocalPlayerIndex = _room.GetPlayerIndex(pair.Value) }; Send(_server, pair.Key, new ReplacedNetworkEnvelope { Kind = ReplacedNetworkMessageKind.MatchSnapshot, RoomId = _roomId, PlayerId = pair.Value, StateHash = _room.Match?.ComputeStateHash() ?? string.Empty, Payload = JsonConvert.SerializeObject(payload) }); } if (_room.Match != null) Status = $"LAN 对局进行中 · 回合 {_room.Match.State.Round}"; Changed?.Invoke(); } private string BoundPlayer(long sessionId) => _serverPlayers.TryGetValue(sessionId, out var playerId) ? playerId : throw new InvalidOperationException("session.not-joined"); private ReplacedNetworkEnvelope Envelope(ReplacedNetworkMessageKind kind, object payload) => new() { Kind = kind, RoomId = _roomId, PlayerId = _localPlayerId, Payload = JsonConvert.SerializeObject(payload) }; private static void Send(IShrinkNetworkTransport transport, long sessionId, ReplacedNetworkEnvelope envelope) => transport.Send(sessionId, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(envelope))); private static void SendResult(IShrinkNetworkTransport transport, long sessionId, bool accepted, string code, string detail, bool duplicate = false) { Send(transport, sessionId, new ReplacedNetworkEnvelope { Kind = accepted ? ReplacedNetworkMessageKind.MatchEvent : ReplacedNetworkMessageKind.Error, Payload = JsonConvert.SerializeObject(new ReplacedNetworkResultPayload { Accepted = accepted, Duplicate = duplicate, Code = code, Detail = detail }) }); } private static void SendError(IShrinkNetworkTransport transport, long sessionId, string code, string detail) => SendResult(transport, sessionId, false, code, detail); private ReplacedDeck LocalDeck() { var deck = ReplacedPersonGameService.Current?.SaveData.ActiveDeck ?? ReplacedBuiltInContent.CreateStarterDeck(); return deck.Clone(); } private static ReplacedJoinRequest JoinRequest(string roomId, string playerId, string displayName) => new() { RoomId = roomId, PlayerId = playerId, DisplayName = displayName, Mods = new List() }; private void ResetSession() { _server?.Stop(); _client?.Stop(); _server = null; _client = null; _room = null; _networkState = null; _serverPlayers.Clear(); while (_mainThreadActions.TryDequeue(out _)) { } _clientSessionId = 0; LocalPlayerIndex = -1; AwaitingCommand = false; _autoReported = false; _nextAutoActionAt = 0f; } private async void BroadcastDiscoveryAsync(bool useKcp, CancellationToken cancellationToken) { try { using var udp = new UdpClient(); udp.EnableBroadcast = true; var endpoint = new IPEndPoint(IPAddress.Broadcast, DiscoveryPort); while (!cancellationToken.IsCancellationRequested && _server?.IsStarted == true) { var message = $"RP14|{_roomId}|{(useKcp ? "KCP" : "TCP")}|{LocalAddress()}"; var bytes = Encoding.UTF8.GetBytes(message); await udp.SendAsync(bytes, bytes.Length, endpoint); await Task.Delay(1000, cancellationToken); } } catch (OperationCanceledException) { } catch (Exception exception) { Debug.LogWarning("[ReplacedPerson.LAN] Discovery broadcast: " + exception.Message); } } private async void ListenForDiscoveryAsync(CancellationToken cancellationToken) { try { using var udp = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true, ExclusiveAddressUse = false }; udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); udp.Client.Bind(new IPEndPoint(IPAddress.Any, DiscoveryPort)); while (!cancellationToken.IsCancellationRequested) { var result = await udp.ReceiveAsync(); var message = Encoding.UTF8.GetString(result.Buffer); if (!message.StartsWith("RP14|", StringComparison.Ordinal)) continue; lock (_discoveryLock) { var roomId = message.Split('|')[1]; _discoveredRooms.RemoveAll(value => value.StartsWith(roomId + "|", StringComparison.Ordinal)); _discoveredRooms.Add(message.Substring(5)); } Changed?.Invoke(); } } catch (ObjectDisposedException) { } catch (SocketException) when (cancellationToken.IsCancellationRequested) { } catch (Exception exception) { Debug.LogWarning("[ReplacedPerson.LAN] Discovery listener: " + exception.Message); } } private static string LocalAddress() { foreach (var address in Dns.GetHostEntry(Dns.GetHostName()).AddressList) if (address.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(address)) return address.ToString(); return "127.0.0.1"; } private static string LoadPlayerId() { foreach (var argument in Environment.GetCommandLineArgs()) if (argument.StartsWith("--rp-player=", StringComparison.OrdinalIgnoreCase)) return argument.Substring("--rp-player=".Length).Trim(); const string key = "ReplacedPerson.PlayerId"; var value = PlayerPrefs.GetString(key, string.Empty); if (!string.IsNullOrWhiteSpace(value)) return value; value = Guid.NewGuid().ToString("N"); PlayerPrefs.SetString(key, value); return value; } private static ReplacedCommandResult Reject(string code, string detail) => new() { ErrorCode = code, Message = detail }; private static void Publish(string status, string detail) => EventBus.Post(new ReplacedNetworkStatusEvent { Status = status, Detail = detail }); } }