Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.WeaponGrant.cs
T

190 lines
9.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}