using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; namespace Ananta.Server.Handlers.Game; /// /// Admin/config weapon grant: push SyncArmoryAddWeapon for account-catalog weapons. /// Instance ids are deterministic (accountInstanceByTemplate), so the existing /// AskLoadWeaponToSlot wheel flow picks the granted weapon up with no further /// server work — equip happens in-game via the armory/weapon wheel. /// internal sealed partial class GameRouter { internal static async Task<(bool Ok, string Message)> GrantWeaponAsync(TcpSession session, uint templateId) { var def = CombatCatalogRepository.AccountWeapons.FirstOrDefault(w => w.TemplateId == templateId); if (def is null) return (false, $"weapon template {templateId} is not in the account armory catalog"); try { await session.NotifyAsync(MethodId.SyncArmoryAddWeapon, UxSerializer.Serialize(RuntimePayloadFactory.WeaponData(def)), CancellationToken.None); session.Log.Info($"[WEAPON] grant template={templateId} name={def.Name} instance={def.InstanceId}"); return (true, $"granted {def.Name} (template {templateId}, instance {def.InstanceId}) — equip it from the armory/weapon wheel in game"); } catch (Exception ex) { return (false, $"grant failed: {ex.Message}"); } } /// /// Bulk unlock: push SyncArmoryAddWeapon for every account-catalog weapon so the whole /// catalog lands in the player's armory/backpack. Pushes are idempotent (deterministic /// instance ids) and are batched with a short delay so the client UI can digest ~600 /// updates without a single burst. /// internal static async Task<(bool Ok, string Message)> GrantAllWeaponsAsync(TcpSession session) { var catalog = CombatCatalogRepository.AccountWeapons; if (catalog.Count == 0) return (false, "account armory catalog is empty"); // 摊平发放:25 个/批 × 10ms 会把 671 个包压进 ~0.5 秒,客户端主线程直接被砸死 // (Player.log: [Davinci] type=TimeScaleAnomaly 紧跟「与服务器的网络连接已断开」)。 // 10 个/批 × 50ms ⇒ ~4 秒发完,客户端有喘息余地。 const int batchSize = 10; const int batchDelayMs = 50; // 断线即停:连续失败 5 个就放弃,不要往一个死 socket 上灌完剩下几百个 // (console-20260923-205458.log 里 148 条 grant-all failed 全是这么来的)。 const int maxConsecutiveFailures = 5; var granted = 0; var failed = 0; var consecutiveFailures = 0; var lastError = string.Empty; for (var i = 0; i < catalog.Count; i++) { var def = catalog[i]; try { await session.NotifyAsync(MethodId.SyncArmoryAddWeapon, UxSerializer.Serialize(RuntimePayloadFactory.WeaponData(def)), CancellationToken.None); granted++; consecutiveFailures = 0; } catch (Exception ex) { failed++; consecutiveFailures++; lastError = ex.Message; session.Log.Warn($"[WEAPON] grant-all failed template={def.TemplateId}: {ex.Message}"); if (consecutiveFailures >= maxConsecutiveFailures) { session.Log.Warn($"[WEAPON] grant-all aborted after {consecutiveFailures} consecutive failures (granted={granted})"); break; } } if ((i + 1) % batchSize == 0 && i + 1 < catalog.Count) await Task.Delay(batchDelayMs, CancellationToken.None); } session.Log.Info($"[WEAPON] grant-all complete granted={granted} failed={failed} total={catalog.Count}"); var message = failed == 0 ? $"all weapons granted: {granted}/{catalog.Count} added to the armory — equip them from the weapon wheel in game" : $"granted {granted}/{catalog.Count}, {failed} failed (last: {lastError})"; return (failed == 0, message); } /// /// Login/scene-load hook for the unlockAll config: replay the full account armory once per /// scene lifecycle (guard reset in World.cs, mirroring the vehicle ownership one-shots). /// // 错峰:等进世界洪峰过去再发(donor §2.9.4 的经验也是「把负载挪出进世界链路」)。 internal const int GrantAllStartupDelayMs = 8000; // 客户端挂起时最多等多久;超时就照发,避免武器永远发不出去。 internal const int GrantAllHangupWaitMs = 60000; /// /// 挂起感知的发放入口 —— 自动发放(unlockAll)和 GM 面板手动点都必须走这里。 /// /// 手动点按钮时玩家一定在浏览器里 ⇒ 客户端处于 hangup ⇒ 此刻往 socket 灌 671 个包 /// 与 2026-09-23 的断线风暴是同一形态。所以统一在这里先错峰、再等 hangup 解除。 /// internal static async Task<(bool Ok, string Message)> GrantAllWeaponsWhenReadyAsync( TcpSession session, int startupDelayMs, int hangupWaitMs) { if (startupDelayMs > 0) await Task.Delay(startupDelayMs, CancellationToken.None); await WaitForClientInteractiveAsync(session, hangupWaitMs); return await GrantAllWeaponsAsync(session); } /// /// 等到客户端退出 hangup(切后台 / 失焦)再往下走,最多等 。 /// 挂起中的客户端不消费网络消息 —— 往里灌批量包会把 TCP 缓冲填满, /// 回到前台时判定「与服务器的网络连接已断开」⇒ Reconnect Module 12 秒超时 /// (2026-09-23 断线重连风暴的原始形态)。道具/武器发放都先过这一关。 /// internal static async Task WaitForClientInteractiveAsync(TcpSession session, int hangupWaitMs) { var waited = 0; while (IsBackgroundPushPaused(session) && waited < hangupWaitMs) { await Task.Delay(250, CancellationToken.None); waited += 250; } if (waited > 0) session.Log.Info($"[PUSH] waited {waited}ms for the client to leave hangup"); } internal static Task PublishAllAccountWeaponsIfEnabled(RpcContext ctx) { if (!PrivateServerConfigStore.Current.Gameplay.Combat.Weapon.UnlockAll) return Task.CompletedTask; var state = GetStateIfExists(ctx.Session); if (state is null) return Task.CompletedTask; lock (state.SyncRoot) { if (state.AccountArmoryAllWeaponsSent) return Task.CompletedTask; state.AccountArmoryAllWeaponsSent = true; } // ⛑️⛑️ 绝不能在 handler 里同步发这 671 个包(2026-09-23 断线重连真凶)。 // TcpServer.HandleClientAsync 的收包循环是**严格串行**的: // while (...) { frame = await ReadFrameAsync(); await _router.DispatchAsync(...); } // handler 不返回,这个 session 的 socket 就一帧都不读。而客户端此时正在发 // AskLoadSceneCompleted / GetSpriteToken ×3 / RequestPlayerStopHangup 等一大堆 RPC, // 全部得不到应答 ⇒ 客户端 5 秒超时 ⇒「与服务器的网络连接已断开」⇒ Reconnect Module // 12 秒硬超时 ⇒ 重连 ⇒ 又进世界 ⇒ 又发 671 ⇒ 风暴 // (console-20260923-205458.log:21:02:26~21:03:55 每 18 秒重连一次,全卡在 grant-all 中途)。 // 所以这里只起后台任务,handler 立刻返回,收包循环继续转。 var session = ctx.Session; _ = Task.Run(async () => { try { var result = await GrantAllWeaponsWhenReadyAsync( session, GrantAllStartupDelayMs, GrantAllHangupWaitMs); session.Log.Info($"[WEAPON] unlockAll hook: {result.Message}"); } catch (Exception ex) { session.Log.Warn($"[WEAPON] unlockAll hook failed: {ex.Message}"); } }); return Task.CompletedTask; } /// Reset the unlockAll one-shot guard so the next scene load replays the catalog. internal static void ResetAllWeaponsSentFlag(TcpSession session) { if (!session.Items.TryGetValue(WorldStateKey, out var raw) || raw is not WorldEntryState state) return; lock (state.SyncRoot) state.AccountArmoryAllWeaponsSent = false; } internal static IReadOnlyList<(uint TemplateId, string Name, ulong InstanceId)> WeaponCatalog() => CombatCatalogRepository.AccountWeapons .OrderBy(w => w.TemplateId) .Select(w => (w.TemplateId, w.Name, w.InstanceId)) .ToList(); }