190 lines
9.1 KiB
C#
190 lines
9.1 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
// 错峰:等进世界洪峰过去再发(donor §2.9.4 的经验也是「把负载挪出进世界链路」)。
|
||
internal const int GrantAllStartupDelayMs = 8000;
|
||
// 客户端挂起时最多等多久;超时就照发,避免武器永远发不出去。
|
||
internal const int GrantAllHangupWaitMs = 60000;
|
||
|
||
/// <summary>
|
||
/// 挂起感知的发放入口 —— 自动发放(unlockAll)和 GM 面板手动点都必须走这里。
|
||
///
|
||
/// 手动点按钮时玩家一定在浏览器里 ⇒ 客户端处于 hangup ⇒ 此刻往 socket 灌 671 个包
|
||
/// 与 2026-09-23 的断线风暴是同一形态。所以统一在这里先错峰、再等 hangup 解除。
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 等到客户端退出 hangup(切后台 / 失焦)再往下走,最多等 <paramref name="hangupWaitMs"/>。
|
||
/// 挂起中的客户端不消费网络消息 —— 往里灌批量包会把 TCP 缓冲填满,
|
||
/// 回到前台时判定「与服务器的网络连接已断开」⇒ Reconnect Module 12 秒超时
|
||
/// (2026-09-23 断线重连风暴的原始形态)。道具/武器发放都先过这一关。
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Reset the unlockAll one-shot guard so the next scene load replays the catalog.</summary>
|
||
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();
|
||
}
|