This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.WeaponGrant.cs
T

189 lines
9.1 KiB
C#
Raw Normal View History

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();
}