189 lines
9.1 KiB
C#
189 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();
|
|||
|
|
}
|