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.
483 lines
23 KiB
C#
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 });
|
|
}
|
|
}
|