Game/NetworkManager.cs
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;

/// <summary>
/// 联机管理(Component.INetworkListener):**本机本地组件,自身不联网**(此前挂在网络对象上
/// 复制不到客户端,导致食物/重生全废——实测)。食物与重生同步走**静态 RPC**(不依赖任何对象)。
/// 权威端(编辑器直接 Play 或房主):创建公开大厅(24 人)、审批连接、为每个连入的客户端
/// 生成归它所有的球(NetworkSpawn(channel) → 该端本地 IsProxy=false,owner 本地模拟);
/// OnActive 时把当前食物全量定向发给新连接;断线时销毁其球。
/// 客户端:通过静态 RequestRespawn RPC 请求重生(host 用 Rpc.Caller 找回连接)。
/// </summary>
public sealed class NetworkManager : Component, Component.INetworkListener
{
	/// <summary> 全局引用 </summary>
	public static NetworkManager Instance { get; private set; }

	/// <summary>
	/// 本机是否权威端:未联网的编辑器 Play 或房主。
	/// ⚠️ 不能只看 `Networking.IsActive`(v0.7.8.87 修):客户端一旦掉线,IsActive 翻 false,
	/// 本地的**镜像世界**就会被误判成权威端——Tick 里跑起吃判定/bot/分身/倒计时甚至 EndMatch,
	/// 本地杀球、本地结算、污染成绩上传。改用显式标志:以客户端身份进过会话后
	/// (_wasClient),断线也永远是非权威,直到 ResetSession(回菜单/换房)复位。
	/// </summary>
	public static bool IsAuthority
	{
		get
		{
			if ( !Networking.IsActive ) return !_wasClient;
			return Networking.IsHost;
		}
	}

	static bool _wasClient;   // 本机以客户端身份进过网络会话(掉线后不回到权威态)

	bool _hosted;

	protected override void OnStart()
	{
		base.OnStart();
		Instance = this;
		GameLog.Info( $"[net] OnStart authority={IsAuthority} active={Networking.IsActive} host={Networking.IsHost}" );
	}

	/// <summary>
	/// 未联网则创建公开大厅并生成房主球 + bot;已联网(作为客户端加入)则什么都不生成。
	/// 由 CircleroyaleGame.EnsureWorld 显式调用。
	/// createLobby=false:**人机对战**——不建大厅直接离线开局(IsAuthority 恒 true,一切照旧)。
	/// ⚠️ CreateLobby 异步生效:必须等 Networking.IsActive 翻转后再生成对象,
	/// 否则 NetworkSpawn 被跳过、球变成纯本地对象永不复制(实测大坑)。
	/// ⚠️ 客户端(已联网非房主)不做任何 host 流程——此前没拦,客户端也会打出
	/// "host ball + bots spawned" 一整套误导日志(实测)。
	/// </summary>
	public void RunHostFlow( bool createLobby = true )
	{
		if ( !IsAuthority )
		{
			GameLog.Info( "[net] client mode — skip host flow" );
			return;
		}

		if ( _hosted ) return;   // 防重入
		_hosted = true;
		_wasClient = false;      // 本机是房主:掉线也保持权威(单机语义)

		RunHostFlowAsync( createLobby );
	}

	async void RunHostFlowAsync( bool createLobby )
	{
		try
		{
			if ( createLobby && !Networking.IsActive )
			{
				Networking.CreateLobby( new global::Sandbox.Network.LobbyConfig
				{
					Privacy = global::Sandbox.Network.LobbyPrivacy.Public,
					MaxPlayers = GameConfig.MaxPlayers,
					// 房间名带房主名——房间列表页展示用(v0.6.7.0)
					Name = LobbyDisplayName(),
					// io 房间不做主机迁移(v0.7.8.105,审计 #3):房主离开即散房,而不是把某个客户端
					// 提升为新主机——提升会按引擎孤儿策略销毁全部 host 拥有的 bot,且本项目的
					// _hosted/_nextCellId/_food 全是镜像态,无 OnBecameHost 处理。
					// 注:不再显式写 AutoSwitchToBestHost——该属性在引擎里已过时("The host only changes
					// when the current host leaves"),且老引擎里 DestroyWhenHostLeaves=true 本就早退
					// 自动换主(SteamLobbySocket:586-588),故此一项已足够。
					DestroyWhenHostLeaves = true,
				} );
				GameLog.Info( "[net] lobby creating (Public/24)..." );

				// 等会话真正激活(CreateLobby 异步生效),最多 30 秒
				int wait = 0;
				while ( !Networking.IsActive && wait < 300 )
				{
					await Task.DelayRealtimeSeconds( 0.1f );
					wait++;
				}
				GameLog.Info( $"[net] session active={Networking.IsActive} (waited {wait * 0.1f:0.0}s)" );
			}
			else if ( !createLobby )
			{
				GameLog.Info( "[net] offline room (no lobby)" );
			}

			// M5:开局不再自动——进房间(蛰伏球就位),host 点 START 才 StartMatchFromLobby
			CircleroyaleGame.Current?.OnLobbyOpened();
			GameLog.Info( "[net] room ready — waiting for host to start" );
		}
		catch ( Exception e )
		{
			GameLog.Info( $"[net] host flow failed: {e.Message} — 离线兜底" );
			CircleroyaleGame.Current?.OnLobbyOpened();
		}
	}

	/// <summary>
	/// 房间列表点击加入(v0.6.7.0):按大厅 SteamId 直连(走 Steam 中继,跨机器可用)。
	/// 成功后标准加入流接管;失败回报浏览器页提示。
	/// </summary>
	public async void JoinLobby( global::Sandbox.Network.LobbyInformation lobby )
	{
		EnsureDisconnected();
		_wasClient = true;   // 客户端身份:此后掉线也不再回到权威态(见 IsAuthority 注释)
		_lastJoinedLobby = lobby;   // 记住目标:掉线自动重连走同一条 Steam 路径
		CircleroyaleGame.Current?.OnJoinAttemptStarted();

		try
		{
			GameLog.Info( $"[net] joining lobby '{lobby.Name}' ({lobby.Members}/{lobby.MaxMembers})" );

			if ( await Networking.TryConnectSteamId( lobby.LobbyId ) )
				return;

			Log.Warning( "[net] join lobby failed" );
			CircleroyaleGame.Current?.OnJoinAttemptFailed( "COULDN'T JOIN THAT ROOM — TRY AGAIN" );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[net] join lobby error: {e.Message}" );
			CircleroyaleGame.Current?.OnJoinAttemptFailed( "JOIN ERROR — TRY AGAIN" );
		}
	}

	/// <summary> 连接前掐掉残留会话(否则 TryConnectSteamId/Connect 静默失败——实测坑) </summary>
	void EnsureDisconnected()
	{
		if ( !Networking.IsActive ) return;

		try
		{
			Networking.Disconnect();
		}
		catch
		{
			// 断不开也无妨:最坏就是这次加入失败,有超时兜底
		}
		GameLog.Info( "[net] dropped stale session before connect" );
	}

	/// <summary> 公开大厅:默认放行(返回 false 可拒绝连接) </summary>
	public bool AcceptConnection( Connection channel, ref string reason ) => true;

	/// <summary> 主菜单"加入":连到指定地址(local = 本机房主)。失败由 CircleroyaleGame 的超时兜底显示 </summary>
	public void StartJoin( string address )
	{
		// 残留会话(上局当过 host/加入没断干净):不掐掉会一直静默失败
		// (实测:编辑器当过一次 host 后再点 JOIN,反复刷 "session already active, skip connect")
		EnsureDisconnected();
		_wasClient = true;   // 客户端身份:此后掉线也不再回到权威态,避免本地镜像模拟误上传成绩

		try
		{
			Networking.Connect( address );
			GameLog.Info( $"[net] connecting to '{address}' ..." );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[net] connect to '{address}' failed: {e.Message}" );
		}
	}

	/// <summary> 大厅显示名:CIRCLEROYALE — 房主名(会话未激活时 Local 可能为空,回退常量名) </summary>
	string LobbyDisplayName()
	{
		try
		{
			var host = Connection.Local?.DisplayName;
			return string.IsNullOrWhiteSpace( host ) ? "CIRCLEROYALE" : $"CIRCLEROYALE — {host}";
		}
		catch
		{
			return "CIRCLEROYALE";
		}
	}

	/// <summary>
	/// 新客户端握手到 Welcome 阶段(仅 host 收到;早于该客户端请求初始快照):
	/// 把生成请求**入队**,由主线程 OnUpdate 下一帧生成归它所有的球——球会随初始快照到达客户端。
	/// ⚠️ 本回调跑在网络线程:直接 SpawnBall(new GameObject/加载 prefab)会随机抛
	/// ".ctor must be called on the main thread!"(线程调度时好时坏,2026-09-05 实测炸穿握手,
	/// 客户端跟着在 MountedVPKs 阶段抛 UnauthorizedAccessException 断线)。
	/// ⚠️ 也不要放到 OnActive(快照拍完后)才生成:live create 广播到不了刚加入的连接,
	/// 而初始快照/定向 RPC 都通(2026-09-05 双开实测)。
	/// </summary>
	public void OnConnected( Connection channel )
	{
		GameLog.Info( $"[net] OnConnected '{channel.DisplayName}' id={channel.Id}" );

		// host 自己的本地连接:引擎在 ClientInfo 处直接 return,不会走到这里;双保险
		if ( Connection.Local is not null && channel.Id == Connection.Local.Id ) return;

		// 收回客户端的生成/销毁权(连接默认放任:CanSpawnObjects 默认 true):球由 host 用
		// NetworkSpawn(owner) 生成,客户端无需这些权限;不收回则任何客户端都能刷/删网络对象。
		// setter 断言 host-only——本回调只会在 host 侧触发;不动 CanRefreshObjects。
		channel.CanSpawnObjects = false;
		channel.CanDestroyObjects = false;

		// 主线程下一帧生成(0.1s << 客户端请求快照的 ~2s,球必然进快照)。
		// 房间阶段生成的是蛰伏球(Alive=false);名单变化由主线程统一广播 LobbyState
		bool lobby = CircleroyaleGame.Current?.IsLobbyOpen ?? false;
		lock ( _joinLock )
		{
			_pendingJoins.Add( new PendingJoin( channel, 0f, 0.1f, false, lobby ) );
		}
		CircleroyaleGame.Current?.MarkLobbyDirty();
		GameLog.Info( $"[net] pre-snapshot ball queued for '{channel.DisplayName}' (dormant={lobby})" );
	}

	/// <summary> 该连接是否已有一颗活球(host 侧查表) </summary>
	bool HasBallFor( Connection channel )
	{
		var game = CircleroyaleGame.Current;
		if ( game is null ) return false;

		var steamId = channel.SteamId.Value;
		foreach ( var b in game.Balls )
		{
			if ( b.IsValid() && !b.IsBot && b.OwnerSteamId == steamId ) return true;
		}
		return false;
	}

	/// <summary> 新客户端完全连入(仅 host 收到):食物全量立即定向;无球时兜底延后生成。
	/// ⚠️ 实测坑:引擎把 host 自己的本地连接也走一遍完整握手(每场开场都触发本回调),
	/// 它对应的球已由 OnNetworkReady 生成,这里再生成就是一颗无人模拟、相机不跟、
	/// 死了不重生的多余球(实测:每局开场双 OnStart 'host名')——直接跳过。
	/// ⚠️ 另一个实测坑:无论本回调内同步生成、还是延后 0.8s 由主线程生成,live create 广播
	/// 都到不了刚加入的连接(快照/定向 RPC 通、唯独 create 丢)——所以球的生成放在更早的
	/// OnConnected(进初始快照),这里只兜底竞态。 </summary>
	public async void OnActive( Connection channel )
	{
		GameLog.Info( $"[net] OnActive '{channel.DisplayName}' id={channel.Id}" );

		// host 自己的本地连接:球由 OnNetworkReady 生成,跳过(见类注释)
		if ( Connection.Local is not null && channel.Id == Connection.Local.Id )
		{
			GameLog.Info( "[net] OnActive: host's own connection — skip (host ball spawned by OnNetworkReady)" );
			return;
		}

		// 热重启竞态防护:等会话激活后再继续(见 RunHostFlow 注释)
		int wait = 0;
		while ( !Networking.IsActive && wait < 100 )
		{
			await Task.DelayRealtimeSeconds( 0.1f );
			wait++;
		}

		// 等待期间连接可能已断开;游戏系统也可能已重建
		var game = CircleroyaleGame.Current;
		if ( game is null )
		{
			GameLog.Info( "[net] OnActive: no game, abort" );
			return;
		}

		// 食物全量定向同步给新连接(静态 RPC 定向:实测可靠,立即发)
		var foods = game.Food?.Foods;
		if ( foods is not null )
		{
			using ( Rpc.FilterInclude( channel ) )
			{
				FoodFull( foods );
			}
		}

		// 绿刺位置(静态数据全量)
		var spikes = game.Spikes;
		if ( spikes is not null )
		{
			using ( Rpc.FilterInclude( channel ) )
			{
				SpikesFull( spikes );
			}
		}

		// 道具全量(对局进行中加入的客户端;房间阶段槽未生成则发空,开局另有定向补发)
		var powerWires = game.PowerUps?.ToWireForSync();
		if ( powerWires is not null )
		{
			using ( Rpc.FilterInclude( channel ) )
			{
				PowerUpFull( powerWires );
			}
		}

		// 房间/对局分支(M5):
		// - 房间开着 → 推房间信息(参数 + 玩家名单),客户端亮等待界面
		// - 对局已开始 → 同步玩家列表 + 推规则 + 直接通知开局(客户端房间页显示 3 秒加入倒计时后进入)
		if ( game.IsLobbyOpen )
		{
			using ( Rpc.FilterInclude( channel ) )
			{
				LobbyState( (byte)MatchState.PendingMode, (int)MatchState.PendingDuration,
					MatchState.PendingPlayerTarget, game.LobbyPlayerNames().ToArray() );
			}
		}
		else if ( game.IsMatchStarted )
		{
			using ( Rpc.FilterInclude( channel ) )
			{
				// 玩家列表先同步(房间页展示),再推规则与开局(客户端走 3 秒加入倒计时)
				LobbyState( (byte)MatchState.PendingMode, (int)MatchState.PendingDuration,
					MatchState.PendingPlayerTarget, game.LobbyPlayerNames().ToArray() );

				MatchSettings( (byte)MatchState.CurrentMode, (int)MatchState.Duration, MatchState.PlayerTarget );
				MatchStarted();
			}
		}

		// 玩家真正入局:刷新其球的出生保护——pre-snapshot 球在握手期间生成,3s 保护期
		// 在没人操控时就空跑完了(实测:加入 1.6s 即被 bot 吃掉)
		foreach ( var b in game.Balls )
		{
			if ( b.IsValid() && !b.IsBot && b.OwnerSteamId == channel.SteamId.Value )
				b.RefreshSpawnProtection();
		}

		// 球通常已在 OnConnected(快照前)生成;这里只兜底"当时世界未就绪"的竞态
		if ( !HasBallFor( channel ) )
		{
			lock ( _joinLock )
			{
				_pendingJoins.Add( new PendingJoin( channel, 0f, 0.8f, true ) );
			}
			GameLog.Info( $"[net] join queued for '{channel.DisplayName}' (fallback) — no pre-snapshot ball" );
		}
	}

	// ---- 加入者球的延迟生成 ----

	readonly struct PendingJoin
	{
		public readonly Connection Channel;
		public readonly TimeSince Since;
		public readonly float Delay;
		public readonly bool CheckActive;
		public readonly bool Dormant;      // 入队时是否房间阶段(蛰伏球)
		public PendingJoin( Connection channel, float start, float delay, bool checkActive, bool dormant = false )
		{
			Channel = channel; Since = start; Delay = delay; CheckActive = checkActive; Dormant = dormant;
		}
	}

	readonly List<PendingJoin> _pendingJoins = new();
	readonly object _joinLock = new();       // OnConnected/OnActive 在网络线程入队,主线程 OnUpdate 消费——List 跨线程必须持锁
	readonly ConcurrentQueue<Connection> _pendingLeaves = new();   // 断线清理队列(网络线程入队,主线程落地销毁/清分身)

	// ---- 客户端自动重连(M3,兜引擎握手偶发非主线程断言)----
	string _serverAddress;          // 见过就记:握手早期 HostConnection 就有值,首连失败也拿得到
	global::Sandbox.Network.LobbyInformation? _lastJoinedLobby;   // 房间列表加入的目标(v0.6.8.1):
	                                                              // Steam 中继连接的 Host.Address 可能为空,
	                                                              // 重连改用它记住的大厅目标走 TryConnectSteamId
	TimeSince _sinceJoinStuck;
	int _reconnectAttempts;

	protected override void OnUpdate()
	{
		base.OnUpdate();

		TickDisconnectRecovery();
		TickReconnectWatchdog();
		TickPendingLeaves();

		// 加入者球生成:网络线程入队(OnConnected/OnActive),这里主线程出队执行
		List<PendingJoin> due = null;

		lock ( _joinLock )
		{
			for ( int i = _pendingJoins.Count - 1; i >= 0; i-- )
			{
				var p = _pendingJoins[i];
				if ( p.Since < p.Delay ) continue;

				_pendingJoins.RemoveAt( i );
				( due ??= new List<PendingJoin>() ).Add( p );
			}
		}

		if ( due is null ) return;

		foreach ( var p in due )
			SpawnJoinBall( p.Channel, p.CheckActive, p.Dormant );
	}

	/// <summary> 断线清理落地:引擎在 OnLeave 路径同步 invoke 回调(与 OnConnected 同线程,
	/// 实测网络线程)——Destroy 球/清分身不能就地做,入队交给主线程(v0.7.8.33) </summary>
	void TickPendingLeaves()
	{
		while ( _pendingLeaves.TryDequeue( out var channel ) )
			ProcessDisconnected( channel );
	}

	// ---- 客户端掉线自动重连(v0.6.4.6 用户定稿):掉线 → 按 ReconnectRetrySeconds 重试 →
	//      耗尽 ReconnectMaxAttempts 次 → 回主菜单 ----

	/// <summary> 掉线自动重连进行中(CircleroyaleGame.ReconcileUi 据此把房间页留在原地) </summary>
	public static bool IsReconnecting => Instance is not null && Instance._reconnecting;

	bool _reconnecting;
	TimeSince _sinceReconnectAttempt;
	TimeSince _sinceReconnectStart;

	/// <summary>
	/// 掉线恢复:从会话里掉出来(房间等待/对局中都算)→ 定时重连,耗尽回调 OnReconnectFailed。
	/// 重连成功走标准加入流(NotifyReconnectStarted → 房间页 + RequestMatchState)。
	/// 菜单上主动断开(LEAVE/结算回菜单)经 ResetSession 清标记,不会触发重连。
	/// </summary>
	void TickDisconnectRecovery()
	{
		if ( Instance != this ) return;   // 快照带过来的 host 副本不跑本机流程(防双份重连)

		if ( Networking.IsActive || Networking.IsHost )
		{
			if ( !_reconnecting ) return;
			_reconnecting = false;
			GameLog.Info( "[net] reconnect: session active again" );
			CircleroyaleGame.Current?.NotifyReconnectStarted();
			return;
		}

		if ( !_reconnecting )
		{
			// 只有"刚才还在会话里"的掉线才重连(菜单上从没连上的归 JOIN FAILED 提示流管)
			if ( _serverAddress is null ) return;
			if ( !( CircleroyaleGame.Current?.IsClientInSession ?? false ) ) return;

			_reconnecting = true;
			_reconnectAttempts = 0;
			_sinceReconnectStart = 0;
			_sinceReconnectAttempt = 999f;   // 立即发起首次尝试
			Log.Warning( "[net] disconnected from host — auto reconnect starting" );
			CircleroyaleGame.Current?.OnDisconnectRecoveryStarted();
			return;
		}

		// 总时长上限(v0.6.8.0):单次 Connect 可能长时间挂在"正在握手"上,
		// 不管走到哪一步,超过上限一律放弃回主菜单(防个别尝试挂死导致永远不返回)
		if ( _sinceReconnectStart > GameConfig.ReconnectGiveUpSeconds )
		{
			_reconnecting = false;
			Log.Warning( "[net] reconnect window exceeded — giving up, back to menu" );
			CircleroyaleGame.Current?.OnReconnectFailed();
			return;
		}

		if ( Networking.IsConnecting ) return;   // 上一次连接还在握手,别叠加

		if ( _reconnectAttempts >= GameConfig.ReconnectMaxAttempts )
		{
			_reconnecting = false;
			Log.Warning( "[net] reconnect exhausted — giving up, back to menu" );
			CircleroyaleGame.Current?.OnReconnectFailed();
			return;
		}

		if ( _sinceReconnectAttempt < GameConfig.ReconnectRetrySeconds ) return;

		_reconnectAttempts++;
		_sinceReconnectAttempt = 0;

		// 首选房间列表记住的大厅目标(Steam 中继连接的 Host.Address 可能为空,实测重连没启动);
		// local 直连的旧路径保留(Address 有值就用)
		if ( _lastJoinedLobby is not null )
		{
			var lobby = _lastJoinedLobby.Value;
			Log.Warning( $"[net] reconnect attempt {_reconnectAttempts}/{GameConfig.ReconnectMaxAttempts} → steam lobby '{lobby.Name}'" );
			_ = Networking.TryConnectSteamId( lobby.LobbyId );
			return;
		}

		Log.Warning( $"[net] reconnect attempt {_reconnectAttempts}/{GameConfig.ReconnectMaxAttempts} → {_serverAddress}" );
		try
		{
			Networking.Connect( _serverAddress );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[net] reconnect connect failed: {e.Message}" );
		}
	}

	/// <summary>
	/// 客户端握手卡死自动重连:连入后 ReconnectAfterSeconds 秒仍没有自己的球 →
	/// 拆掉僵尸连接(重连发起与"耗尽回主菜单"统一由 TickDisconnectRecovery 处理)。
	/// 仅客户端跑;成功入局(本机球就位)即复位计数。已入局后的掉线/僵尸态不归它管
	/// (host 停 Play 的 TCP 僵尸无代码可救,不在这里空转重试)。
	/// </summary>
	void TickReconnectWatchdog()
	{
		if ( Instance != this ) return;   // 快照带过来的 host 副本不跑本机流程
		if ( !Networking.IsActive || Networking.IsHost ) return;

		// 随手记服务器地址(连接对象一出现就有值)
		var addr = Connection.Host?.Address;
		if ( !string.IsNullOrEmpty( addr ) ) _serverAddress = addr;

		var joined = CircleroyaleGame.Current?.LocalBall.IsValid() ?? false;
		if ( joined )
		{
			if ( _reconnectAttempts > 0 ) GameLog.Info( "[net] auto-reconnect: joined OK" );
			_reconnectAttempts = 0;
			_sinceJoinStuck = 0;
			return;
		}

		if ( Networking.IsConnecting ) return;   // 正在握手,别打断
		if ( _serverAddress is null ) return;    // 没拿到过地址,无从重连
		if ( _reconnectAttempts >= GameConfig.ReconnectMaxAttempts ) return;
		if ( _sinceJoinStuck < GameConfig.ReconnectAfterSeconds ) return;

		var stuck = _sinceJoinStuck;
		_reconnectAttempts++;
		_sinceJoinStuck = 0;
		Log.Warning( $"[net] join stuck {stuck:0}s without own ball — tearing down for reconnect {_reconnectAttempts}/{GameConfig.ReconnectMaxAttempts}" );

		try
		{
			Networking.Disconnect();
		}
		catch
		{
			// 拆不掉也照办(TickDisconnectRecovery 的重试循环兜着)
		}
		// 重连发起与"耗尽回主菜单"统一交给 TickDisconnectRecovery(!IsActive 分支)
	}

	/// <summary> 出队后真正生成加入者的球(主线程、普通 tick 上下文)。
	/// checkActive:OnActive 兜底路径要查(连接真断了就别生成);OnConnected 阶段
	/// **不能查**——Welcome 时 IsActive 本来就是 false,查了必误杀(2026-09-05 实测),
	/// 生成早了没关系,真断线由 OnDisconnected 按 SteamId 清理。
	/// dormant:房间阶段生成蛰伏球(Alive=false,开局激活),之后要在名单里亮出来。 </summary>
	void SpawnJoinBall( Connection channel, bool checkActive, bool dormant = false )
	{
		if ( checkActive && !channel.IsActive )
		{
			GameLog.Info( "[net] join ball: connection gone, skip" );
			return;
		}

		var game = CircleroyaleGame.Current;
		if ( game is null )
		{
			GameLog.Info( "[net] join ball: no game, skip" );
			return;
		}

		var ball = game.SpawnBall( game.RandomSpawnPos(), isBot: false, owner: channel, dormant: dormant );
		GameLog.Info( $"[net] join ball for '{channel.DisplayName}': ball={( ball is null ? "REFUSED" : "ok" )} dormant={dormant} netRoot={( ball?.GameObject.IsNetworkRoot ?? false )}" );

		if ( dormant ) game.MarkLobbyDirty();   // 名单亮出新人(本地面板轮刷也看得到,这里管推送)
	}

	/// <summary> 断线:销毁其球(销毁会同步到所有端),分身随葬,bot 由 TickBots 回填。
	/// 房间阶段还要把名单变化推给剩下的人(置脏标,主线程广播)。
	/// ⚠️ 本回调在网络线程被 invoke(与 OnConnected 同路径)——只入队,清理由主线程
	/// TickPendingLeaves 落地(v0.7.8.33) </summary>
	public void OnDisconnected( Connection channel )
	{
		_pendingLeaves.Enqueue( channel );
	}

	/// <summary> 断线清理实体工作(主线程):销毁其球、分身随葬、名单置脏。
	/// v0.7.8.87 两处加固:①撤销该连接还在排队的"生成球"请求(否则 0.1s 后仍会造出一颗
	/// 无人模拟、再也等不到 leave 的幽灵球);②同 SteamId 已有**新**连接在场时整体跳过,
	/// 免得迟到的旧 leave 把重连玩家的新球/新分身一起清掉 </summary>
	void ProcessDisconnected( Connection channel )
	{
		var game = CircleroyaleGame.Current;
		if ( game is null ) return;

		lock ( _joinLock )
		{
			for ( int i = _pendingJoins.Count - 1; i >= 0; i-- )
			{
				if ( _pendingJoins[i].Channel?.Id == channel?.Id ) _pendingJoins.RemoveAt( i );
			}
		}

		// 迟到的 leave:该 SteamId 已经用新连接回来了 → 不动它的球与分身。
		// ⚠️ 只在 SteamId 非 0 时启用(v0.7.8.87):双开/未登录会话的 SteamId 可能是 0,
		// 那会把"任何一条 0 号连接还在场"都误判成重连,导致真正该清的球留下来变幽灵
		var sid = channel.SteamId.Value;
		if ( sid != 0 )
		{
			foreach ( var c in Connection.All )
			{
				if ( c is null || !c.IsActive ) continue;
				if ( c.Id == channel.Id ) continue;
				if ( c.SteamId.Value != sid ) continue;

				GameLog.Info( $"[net] stale leave ignored (reconnected): {channel.DisplayName}" );
				if ( game.IsLobbyOpen ) game.MarkLobbyDirty();
				return;
			}
		}

		foreach ( var b in game.Balls.ToArray() )
		{
			if ( b.IsValid() && !b.IsBot && b.OwnerSteamId == channel.SteamId.Value )
			{
				b.GameObject.Destroy();
			}
		}
		game.PopPiecesOf( channel.SteamId.Value );

		if ( game.IsLobbyOpen ) game.MarkLobbyDirty();
	}

	/// <summary> 回主菜单:复位 host 流程与重连看门狗(下次开局重新走 RunHostFlow/OnNetworkReady) </summary>
	public void ResetSession()
	{
		_hosted = false;
		_wasClient = false;   // 回菜单/换房:身份复位(下次开局重新判定)
		lock ( _joinLock )
		{
			_pendingJoins.Clear();
		}
		while ( _pendingLeaves.TryDequeue( out _ ) ) { }
		_reconnectAttempts = 0;
		_sinceJoinStuck = 0;
		_reconnecting = false;
		GameLog.Info( "[net] session reset" );
	}

	// ---- 食物与重生:静态 RPC(不依赖任何网络对象)----
	// ⚠️ 下面所有 host→全端的状态广播一律带 NetFlags.Reliable | NetFlags.HostOnly:引擎只对标了
	// HostOnly 的 RPC 校验"调用方是 host"(Rpc.StaticRpc.HasStaticPermission),裸 [Rpc.Broadcast]
	// 任何客户端都能伪造并让全端执行(v0.7.8.33 收口)。⚠️ 但 HostOnly 单独用会**覆盖**特性构造
	// 函数的 Reliable 默认值(NetFlags.HostOnly=16 不含 Reliable=2 位),发送路径只在可靠位存在时
	// 分片/重传(Connection.cs:343-345)——状态广播会静默变成不可靠。所以必须显式带上 Reliable。
	// 新增广播 RPC 记得照带。 ----

	/// <summary> host 吃判定后广播:某食物被吃(eaterSteamId 供客户端判断是否自己的嘴,播吃音) </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void FoodEaten( int index, long eaterSteamId = 0 ) => CircleroyaleGame.Current?.OnFoodEatenRemote( index, eaterSteamId );

	/// <summary> host 重生食物后广播:新位置/颜色 </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void FoodRespawned( int index, FoodData food ) => CircleroyaleGame.Current?.OnFoodRespawnedRemote( index, food );

	/// <summary> 新连接加入时定向发送:食物全量 </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void FoodFull( FoodData[] foods ) => CircleroyaleGame.Current?.OnFoodFullRemote( foods );

	/// <summary> 绿刺位置(host 生成后广播 + 新连接定向补发;静态数据,此后不变) </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void SpikesFull( Vector2[] spikes ) => CircleroyaleGame.Current?.OnSpikesFullRemote( spikes );

	/// <summary> 喂刺充能(host 吸收孢子后广播;客户端镜像刺的累计喂食量,v0.7.8.24) </summary>
	[Rpc.Broadcast( NetFlags.Unreliable | NetFlags.HostOnly )]
	public static void SpikeFed( int index, float fed ) => CircleroyaleGame.Current?.OnSpikeFedRemote( index, fed );

	/// <summary> 刺爆(host 喂满触发后广播;全端爆效,客户端生成视觉弹幕) </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void SpikeBurst( int index ) => CircleroyaleGame.Current?.OnSpikeBurstRemote( index );

	/// <summary> 刺爆弹幕命中特效(host 命中判定后广播;全端放特效音效) </summary>
	[Rpc.Broadcast( NetFlags.Unreliable | NetFlags.HostOnly )]
	public static void SpineHit( float x, float y, byte colorIndex ) => CircleroyaleGame.Current?.OnSpineHitRemote( x, y, colorIndex );

	/// <summary> 道具全量(host 开局广播 + 新连接定向补发;固定槽位数组,幂等) </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void PowerUpFull( PowerUpWire[] slots ) => CircleroyaleGame.Current?.OnPowerUpFullRemote( slots );

	/// <summary> 道具被捡(host 拾取判定后广播;客户端放特效并置空槽) </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void PowerUpPicked( int slot, byte kind, long pickerSteamId ) => CircleroyaleGame.Current?.OnPowerUpPickedRemote( slot, kind, pickerSteamId );

	/// <summary> 道具重刷(host 延时补位后广播) </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void PowerUpRespawned( int slot, byte kind, float x, float y ) => CircleroyaleGame.Current?.OnPowerUpRespawnedRemote( slot, kind, x, y );

	/// <summary> 重生提示音广播(host 复活一颗球后):各端按"队友才响"落地(v0.7.8.11) </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void RespawnCue( long steamId, int teamIndex ) => CircleroyaleGame.Current?.OnRespawnCueRemote( steamId, teamIndex );

	/// <summary> 尖刺分身炸大奖广播(host 判定后):成就解锁在尖刺主人的机器上落地(v0.7.8.13) </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void SpikeReward( long ownerSteamId ) => GameAchievements.OnSpikeRewardRemote( ownerSteamId );

	/// <summary> 个人高光横幅广播(v0.7.6.0):mode 0=存入背包 / 1=喂养触发激活——
	/// host 本地已播,客户端按 ownerSteamId 判"是不是自己的"再显示 </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void PowerBanner( byte kind, long ownerSteamId, byte mode ) => CircleroyaleGame.Current?.OnPowerBannerRemote( kind, ownerSteamId, mode );

	/// <summary> 死亡/爆裂特效广播(不可靠传输——丢一帧特效无所谓,不占可靠通道)。
	/// ownerSteamId = 死亡实体的主人,客户端据此判断是否自己死亡/爆裂(只播自己的爆裂音) </summary>
	[Rpc.Broadcast( NetFlags.Unreliable | NetFlags.HostOnly )]
	public static void PoppedEffect( Vector3 pos, byte colorIndex, float radius, long ownerSteamId ) => CircleroyaleGame.Current?.OnPoppedEffectRemote( pos, colorIndex, radius, ownerSteamId );

	/// <summary> 吞球广播(eater/eaten 的 SteamId 供客户端判断自己是否参与,播吞球音 + 击杀播报) </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void BallEaten( long eaterSteamId, long eatenSteamId, float massGained ) => CircleroyaleGame.Current?.OnBallEatenRemote( eaterSteamId, eatenSteamId, massGained );

	// ---- 比赛规则与进程(M5)----

	/// <summary> 比赛规则下发(host 开局广播 + 新连接定向补发;一次性、幂等) </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void MatchSettings( byte mode, int durationSeconds, int playerTarget ) => MatchState.Apply( mode, durationSeconds, playerTarget );

	/// <summary> 倒计时校时(1Hz 不可靠广播;客户端本地每帧续走) </summary>
	[Rpc.Broadcast( NetFlags.Unreliable | NetFlags.HostOnly )]
	public static void MatchTick( float timeLeft ) => MatchState.ApplyTick( timeLeft );

	/// <summary> 比赛结束 + 结算榜(host 排好序广播;各端展示并各自上传成绩) </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void MatchOver( ScoreWire[] standings ) => CircleroyaleGame.Current?.OnMatchOverRemote( standings );

	/// <summary> 领土格状态快照(M7,host 变化广播 + 终局先行快照;全量替换式量极小,
	/// 可靠传输——变化才发,丢了没人补发,不可靠反而会永久漏一拍)。points = 四队占旗积分
	/// ⚠️ 必须显式写 NetFlags.Reliable:HostOnly 单独用会覆盖特性默认的 Reliable 位,
	/// 该广播就静默降级为不可靠(丢包无人补发——正是本方法最怕的)。 </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void TerritoryState( TerritoryWire[] cells, float[] points ) => TerritoryManager.ApplyRemote( cells, points );

	/// <summary> 客户端施放职业技能(M7.4):host 按 Rpc.Caller 找到活球,冷却/条件校验后执行 </summary>
	[Rpc.Host]
	public static void RequestClassSkill()
	{
		var conn = Rpc.Caller;
		if ( conn is null || !conn.IsActive ) return;

		CircleroyaleGame.Current?.CastClassSkillFor( conn.SteamId.Value );
	}

	/// <summary> 指挥官技能传送(M7.4):广播到各端,本机球瞬移到队友身边;host 回声被 IsAuthority 挡掉 </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void ClassTeleport( long steamId, float x, float y ) => CircleroyaleGame.Current?.OnClassTeleportRemote( steamId, x, y );

	/// <summary> 房主点了 START:客户端出房间进游戏(OnActive 对中途加入者也有定向版) </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void MatchStarted() => CircleroyaleGame.Current?.OnMatchStartedRemote();

	/// <summary> host 按 P 快速退局(v0.6.9.0):广播取消——所有客户端一起回房间等待页 </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void MatchCancelled() => CircleroyaleGame.Current?.OnMatchCancelledRemote();

	/// <summary> 房间信息推送(参数 + 玩家名单;名单/参数变化与新人 OnActive 定向共用)。
	/// names[0] 恒为房主。 </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void LobbyState( byte mode, int durationSeconds, int playerTarget, string[] names ) => CircleroyaleGame.Current?.OnLobbyStateRemote( mode, durationSeconds, playerTarget, names );

	/// <summary> 主球被吃后最大分身晋升(v0.6.2.0):定向发给该玩家——把主球瞬移到晋升位置
	/// 继续 owner 模拟(质量经 [Sync] 自动同步)。host 自己的球不走此 RPC(本地已直接设位)。 </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void MainPromoted( float x, float y ) => CircleroyaleGame.Current?.OnMainPromotedRemote( x, y );

	/// <summary> 客户端请求当前对局状态(v0.6.3.4):进房间/重连/热重载后向 host 要一次——
	/// 房间开着推房间信息,对局进行中直接推规则+开局。让任意时刻的客户端状态可幂等恢复。 </summary>
	[Rpc.Host]
	public static void RequestMatchState()
	{
		var conn = Rpc.Caller;
		if ( conn is null || !conn.IsActive ) return;

		var game = CircleroyaleGame.Current;
		if ( game is null ) return;

		using ( Rpc.FilterInclude( conn ) )
		{
			if ( game.IsLobbyOpen )
			{
				LobbyState( (byte) MatchState.PendingMode, (int) MatchState.PendingDuration,
					MatchState.PendingPlayerTarget, game.LobbyPlayerNames().ToArray() );
			}
			else if ( game.IsMatchStarted )
			{
				MatchSettings( (byte) MatchState.CurrentMode, (int) MatchState.Duration, MatchState.PlayerTarget );
				MatchStarted();
			}
			else
			{
				// host 也在菜单(会话残留):按菜单设置推一局空房间信息
				LobbyState( (byte) MatchState.PendingMode, (int) MatchState.PendingDuration,
					MatchState.PendingPlayerTarget, game.LobbyPlayerNames().ToArray() );
			}
		}
	}

	/// <summary>
	/// host 按固定节拍广播:分身/孢子状态快照(纯数据实体,各端镜像渲染)。
	/// 广播对刚加入的连接也有效(与食物增量同款路径,实测可靠)——中途加入也能看到正在飞的分身。
	/// </summary>
	[Rpc.Broadcast( NetFlags.Reliable | NetFlags.HostOnly )]
	public static void CellsState( CellWire[] cells ) => CircleroyaleGame.Current?.OnCellsStateRemote( cells );

	/// <summary>
	/// 客户端请求重生(静态 RPC):host 用 Rpc.Caller 取回调用者连接并为其原地复活。
	/// 领土模式 frontLine=true 复活到最近己方占领格(M7.2 二选一,默认大本营)。
	/// 不要让客户端自报 SteamId——同机双开时连接是合成 ID,与 Game.SteamId 对不上(实测踩坑)。
	/// </summary>
	[Rpc.Host]
	public static void RequestRespawn( bool frontLine )
	{
		var conn = Rpc.Caller;
		GameLog.Info( $"[net] RequestRespawn from '{conn?.DisplayName ?? "null"}' front={frontLine}" );   // 生命周期日志:定位客户端重生链路断点
		if ( conn is null || !conn.IsActive ) return;

		CircleroyaleGame.Current?.RespawnConnection( conn, frontLine );
	}

	/// <summary> 客户端本地食物列表未就绪(热重载/新实例)时,向 host 请求全量(定向回发);
	/// 绿刺顺带补发(客户端不清请求,重复收无害——数据幂等) </summary>
	[Rpc.Host]
	public static void RequestFoodFull()
	{
		var conn = Rpc.Caller;
		if ( conn is null || !conn.IsActive ) return;

		var game = CircleroyaleGame.Current;

		var foods = game?.Food?.Foods;
		if ( foods is not null )
		{
			using ( Rpc.FilterInclude( conn ) )
			{
				FoodFull( foods );
			}
		}

		var spikes = game?.Spikes;
		if ( spikes is not null )
		{
			using ( Rpc.FilterInclude( conn ) )
			{
				SpikesFull( spikes );
			}
		}
	}
}