Files
cneicy ad5a7b68a3 feat(sdk): migrate to EventBus 2.0
Replace the legacy EventBase runtime with generated multi-bus bindings and explicit scheduling. Migrate app, data, network, demo, and mod consumers; add generated network-event registration and owner-scoped mod content overrides.
2026-08-26 01:15:48 +08:00

483 lines
23 KiB
C#

#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<string> _discoveredRooms = new();
private readonly Dictionary<long, string> _serverPlayers = new();
private readonly ConcurrentQueue<Action> _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<string> 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<ReplacedLanRuntime>();
}
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<ReplacedPersonModManifest>(),
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<ReplacedNetworkEnvelope>(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<ReplacedJoinRequest>(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<ReplacedDeck>(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<ReplacedReadyRequest>(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<ReplacedMatchCommand>(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<ReplacedNetworkEnvelope>(Encoding.UTF8.GetString(value.PacketData));
if (envelope == null) return;
if (envelope.Kind == ReplacedNetworkMessageKind.MatchSnapshot)
{
var payload = JsonConvert.DeserializeObject<ReplacedMatchSnapshotPayload>(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<ReplacedNetworkResultPayload>(envelope.Payload);
AwaitingCommand = false;
Status = "加入或指令失败:" + (result?.Detail ?? envelope.Payload);
}
else if (envelope.Kind == ReplacedNetworkMessageKind.MatchEvent)
{
var result = JsonConvert.DeserializeObject<ReplacedNetworkResultPayload>(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<ReplacedPersonModManifest>()
};
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 });
}
}