308 lines
15 KiB
C#
308 lines
15 KiB
C#
using Ananta.SDK.Rpc;
|
||
using Ananta.Server.Handlers;
|
||
using Ananta.Server.Gameplay;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>Protagonist traversal/web buff handling. Buff ids come from private-server.json.</summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
static bool IsActiveProtagonist(WorldEntryState state, ulong unitId = 0)
|
||
=> WebTraversal.IsProtagonist(state.ActiveSpiritTemplateId)
|
||
&& (unitId == 0 || unitId == state.ActiveSpiritUnitId);
|
||
|
||
async Task EnsureFeiSuoActionBuff(RpcContext ctx)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
if (!IsActiveProtagonist(state))
|
||
return;
|
||
if (state.ActiveFeiSuoBuffInstanceId != 0)
|
||
return;
|
||
|
||
var instanceId = state.NextBuffInstanceId++;
|
||
state.ActiveFeiSuoBuffInstanceId = instanceId;
|
||
state.ActiveFeiSuoBuffUnitId = state.ActiveSpiritUnitId;
|
||
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff,
|
||
WorldCodec.UnitAddBuff(state.ActiveSpiritUnitId, WebTraversal.FeiSuoBuff, instanceId));
|
||
}
|
||
|
||
async Task OnEnterFeiSuoCrouch(RpcContext ctx)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
if (!IsActiveProtagonist(state))
|
||
{
|
||
ctx.Session.Log.Info($"[WEB] feisuo-enter ignored template={state.ActiveSpiritTemplateId} protagonist=false");
|
||
return;
|
||
}
|
||
// ⛑⛑ 进蹲伏态的同一帧客户端会自己发 AskRemoveClientBuff(52853760) 摘掉飞索 buff。
|
||
// 旧逻辑在这里 EnsureFeiSuoActionBuff 把它加回去,再加上 OnClientBuffRemove 里
|
||
// 120ms 后的 persistent-gate-reassert,客户端立刻 AskLeaveFeiSuoCrouch
|
||
// (实测 19:41:32.258 进 / .262 摘 / 同秒退出)⇒ 蹲伏撑不过 0.4s
|
||
// ⇒ StealthManager.CrouchAssassinationUpdate 跑不满一轮
|
||
// ⇒ ReportCanAssassinateEnemies 恒 0 ⇒ 暗杀 HUD 死在源头。
|
||
state.FeiSuoCrouchActive = true;
|
||
|
||
// ★ 每次进蹲伏都重新播种(硬规则 192):AssassinationSeededAgents 记的是「本次蹲伏
|
||
// 播过谁」,不清的话第二次蹲伏就再也不播了 —— 而 canAssassinate 会被上报路径撤销,
|
||
// 于是第二次及以后永远没有候选。
|
||
state.AssassinationSeededAgents.Clear();
|
||
state.AssassinationSeedAtTicks = 0;
|
||
|
||
await PublishInitialCapabilityBuffs(ctx);
|
||
ctx.Session.Log.Info($"[WEB] feisuo-enter crouch=true transient-suppressed={WebTraversal.FeiSuoBuff} seed-reset={WebTraversal.PushAssassinationSeed}");
|
||
}
|
||
|
||
async Task OnFeiSuoSuccess(RpcContext ctx, int feiSuoId)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
if (!IsActiveProtagonist(state))
|
||
{
|
||
ctx.Session.Log.Info($"[WEB] feisuo-success ignored template={state.ActiveSpiritTemplateId} protagonist=false");
|
||
return;
|
||
}
|
||
await EnsureFeiSuoActionBuff(ctx);
|
||
ctx.Session.Log.Info($"[WEB] feisuo-success id={feiSuoId} transient={WebTraversal.FeiSuoBuff}");
|
||
}
|
||
|
||
async Task OnLeaveFeiSuoCrouch(RpcContext ctx)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
// ⛑ 必须在任何提前 return 之前清:蹲伏一结束,飞索 buff 的 reassert 就要恢复。
|
||
state.FeiSuoCrouchActive = false;
|
||
|
||
// ★ 离开蹲伏 ⇒ 播种记录一起清,下次蹲伏重新播(硬规则 192)。
|
||
// ⛑ 撤销也必须在提前 return 之前:原来它挂在函数末尾,一旦
|
||
// ActiveFeiSuoBuffInstanceId == 0 就直接 return,敌人身上的 canAssassinate
|
||
// 永远撤不掉(残留暗杀交互 + HUD 提示)。
|
||
state.AssassinationSeededAgents.Clear();
|
||
state.AssassinationSeedAtTicks = 0;
|
||
await RevokeAllAssassinationGrants(ctx, "feisuo-leave");
|
||
|
||
if (state.ActiveFeiSuoBuffInstanceId == 0)
|
||
return;
|
||
|
||
var instanceId = state.ActiveFeiSuoBuffInstanceId;
|
||
var unitId = state.ActiveFeiSuoBuffUnitId != 0
|
||
? state.ActiveFeiSuoBuffUnitId
|
||
: state.ActiveSpiritUnitId;
|
||
state.ActiveFeiSuoBuffInstanceId = 0;
|
||
state.ActiveFeiSuoBuffUnitId = 0;
|
||
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
|
||
WorldCodec.UnitRemoveBuff(unitId, instanceId));
|
||
ctx.Session.Log.Info($"[WEB] feisuo-leave transient-removed instance={instanceId}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 蹲伏暗杀裁决:客户端上报「这几个在我背后」→ 服务端回 <c>SyncEnemyCanAssassinate</c> 授权。
|
||
///
|
||
/// ★ 为什么必须有这一段:客户端 <c>StealthManager</c> 只负责算候选(每 0.4s 一次),
|
||
/// 真正的授权在服务端这一条回包上。本服 2026-09-26 之前<b>既没有 handler 也从不下发</b>
|
||
/// (全仓只有 MethodId 常量)⇒ <c>canAssassinate</c> 恒 false ⇒
|
||
/// <c>EnemyInteractCore.TrySetFeisuoCrouchAssassinInteract</c> 从不置 true ⇒
|
||
/// <b>HUD 不出暗杀提示、ControlPower 按钮不切成暗杀态</b>。
|
||
///
|
||
/// ⛑ 只在集合变化时发包:候选名单蹲着不动时是完全一样的,不做差分会每 0.4s 推一批。
|
||
/// ⛑ 两态都发:撤销(canAssassinate=false)同样重要,否则目标走远了提示还在。
|
||
/// </summary>
|
||
async Task OnReportCanAssassinateEnemies(RpcContext ctx, List<ulong> enemyIds)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
if (!WebTraversal.AnswerCrouchAssassination)
|
||
return;
|
||
if (!IsActiveProtagonist(state))
|
||
return;
|
||
|
||
var granted = state.AssassinationGrantedAgents;
|
||
var incoming = new HashSet<ulong>();
|
||
foreach (var id in enemyIds)
|
||
{
|
||
if (id != 0)
|
||
incoming.Add(id);
|
||
}
|
||
|
||
// ① 撤销:上次授权过、这次不在候选里
|
||
var revoked = 0;
|
||
foreach (var gone in granted.Where(x => !incoming.Contains(x)).ToArray())
|
||
{
|
||
granted.Remove(gone);
|
||
revoked++;
|
||
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncEnemyCanAssassinate,
|
||
new SceneMethods.SyncEnemyCanAssassinate4229938 { agentId = gone, canAssassinate = false });
|
||
}
|
||
|
||
// ② 授权:这次在候选里、上次没给过
|
||
var grantedNew = 0;
|
||
foreach (var id in incoming)
|
||
{
|
||
if (!granted.Add(id))
|
||
continue;
|
||
grantedNew++;
|
||
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncEnemyCanAssassinate,
|
||
new SceneMethods.SyncEnemyCanAssassinate4229938 { agentId = id, canAssassinate = true });
|
||
}
|
||
|
||
if (revoked > 0 || grantedNew > 0)
|
||
ctx.Session.Log.Info($"[ASSASS] grant=+{grantedNew} revoke=-{revoked} total={granted.Count} reported={enemyIds.Count}");
|
||
}
|
||
|
||
/// <summary>清空全部暗杀授权(离开蹲伏态 / 退场景时调用),逐个发 false。</summary>
|
||
async Task RevokeAllAssassinationGrants(RpcContext ctx, string phase)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
var granted = state.AssassinationGrantedAgents;
|
||
if (granted.Count == 0)
|
||
return;
|
||
|
||
var all = granted.ToArray();
|
||
granted.Clear();
|
||
foreach (var id in all)
|
||
await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncEnemyCanAssassinate,
|
||
new SceneMethods.SyncEnemyCanAssassinate4229938 { agentId = id, canAssassinate = false });
|
||
ctx.Session.Log.Info($"[ASSASS] revoke-all phase={phase} count={all.Length}");
|
||
}
|
||
|
||
async Task OnClientBuffAdd(RpcContext ctx, ulong unitId, uint buffId)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
|
||
// ★ 白靖「斯安威斯坦」(子弹时间)—— 这是它的<b>原生触发通道</b>:
|
||
// 客户端职业技能按钮按下后 AskAddClientBuff(unitId, 52959496) 到服务端,
|
||
// 服务端才补上 SyncSandevistanStart + 5 秒 buff + 赛车手时缓次数结算。
|
||
// ⛑ 必须早于下面所有通用增 buff 分支(包括 IsWebBuff 的拒绝),
|
||
// 否则这一帧会被当成普通 buff 直接回了 Service 侧而过不了我们对时限的控制。
|
||
// ⛔ 2026-09-27 用户拍板:不把它挂到当前流派 ActiveSkill(E)上 —— 那是职业技能,不是战技。
|
||
if (buffId == CharacterCapabilities.BaiJingTimeScaleActiveBuff
|
||
&& state.ActiveSpiritTemplateId == CharacterCapabilities.BaiJingSpiritId)
|
||
{
|
||
await TryStartBaiJingTime4229938(ctx.Session, unitId, "native-profession-button");
|
||
return;
|
||
}
|
||
|
||
if (WebTraversal.IsWebBuff(buffId) && !IsActiveProtagonist(state, unitId))
|
||
{
|
||
ctx.Session.Log.Info($"[WEB] client-buff-add denied unit={unitId} template={state.ActiveSpiritTemplateId} buff={buffId} protagonist=false");
|
||
return;
|
||
}
|
||
// SwingBuff/WallRushBuff are action states, not unlocks. The 4229938 client requests them
|
||
// when an action starts and removes them when it ends. Track one exact instance per unit/buff
|
||
// so repeated client requests refresh the state instead of stacking permanent super-armor.
|
||
if (WebTraversal.IsClientLifecycleBuff(buffId))
|
||
{
|
||
var key = (unitId, buffId);
|
||
if (state.ActiveClientWebBuffInstances.Remove(key, out var previousInstanceId))
|
||
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff, WorldCodec.UnitRemoveBuff(unitId, previousInstanceId));
|
||
|
||
var lifecycleInstanceId = state.NextBuffInstanceId++;
|
||
state.ActiveClientWebBuffInstances[key] = lifecycleInstanceId;
|
||
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff,
|
||
WorldCodec.UnitAddBuff(unitId, buffId, lifecycleInstanceId));
|
||
ctx.Session.Log.Info($"[WEB] lifecycle-add unit={unitId} buff={buffId} instance={lifecycleInstanceId} refreshed={previousInstanceId != 0}");
|
||
return;
|
||
}
|
||
|
||
var instanceId = state.NextBuffInstanceId++;
|
||
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff, WorldCodec.UnitAddBuff(unitId, buffId, instanceId));
|
||
if (buffId == WebTraversal.FeiSuoBuff)
|
||
{
|
||
state.ActiveFeiSuoBuffInstanceId = instanceId;
|
||
state.ActiveFeiSuoBuffUnitId = unitId;
|
||
}
|
||
|
||
if (buffId == WebTraversal.FeiSuoBuff || WebTraversal.IsProtected(buffId))
|
||
ctx.Session.Log.Info($"[WEB] client-buff-add unit={unitId} buff={buffId} instance={instanceId}");
|
||
}
|
||
|
||
async Task OnClientBuffRemove(RpcContext ctx, ulong unitId, uint buffId)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
|
||
// ★ 白靖「斯安威斯坦」的收场:客户端技能结束 / buff 到点后摘除时上报。
|
||
// 只有操控单位本人发出的这一次才收尾,别人的包不动当前状态。
|
||
// ⛑ 一定要走到 TryStart 那一侧成对处理 —— 少了这条 End,玩家会永久卡在慢世界里。
|
||
if (buffId == CharacterCapabilities.BaiJingTimeScaleActiveBuff
|
||
&& state.ActiveSpiritTemplateId == CharacterCapabilities.BaiJingSpiritId)
|
||
{
|
||
if (unitId == state.ActiveSpiritUnitId)
|
||
await StopBaiJingTime4229938(ctx.Session, "client-buff-remove");
|
||
return;
|
||
}
|
||
|
||
if (WebTraversal.IsWebBuff(buffId) && !IsActiveProtagonist(state, unitId))
|
||
return;
|
||
if (WebTraversal.IsClientLifecycleBuff(buffId))
|
||
{
|
||
var key = (unitId, buffId);
|
||
if (state.ActiveClientWebBuffInstances.Remove(key, out var lifecycleInstanceId))
|
||
{
|
||
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
|
||
WorldCodec.UnitRemoveBuff(unitId, lifecycleInstanceId));
|
||
ctx.Session.Log.Info($"[WEB] lifecycle-remove unit={unitId} buff={buffId} instance={lifecycleInstanceId}");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (buffId == WebTraversal.FeiSuoBuff)
|
||
{
|
||
if (state.ActiveFeiSuoBuffInstanceId != 0)
|
||
{
|
||
var instanceId = state.ActiveFeiSuoBuffInstanceId;
|
||
var transientUnitId = state.ActiveFeiSuoBuffUnitId != 0
|
||
? state.ActiveFeiSuoBuffUnitId
|
||
: unitId;
|
||
state.ActiveFeiSuoBuffInstanceId = 0;
|
||
state.ActiveFeiSuoBuffUnitId = 0;
|
||
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
|
||
WorldCodec.UnitRemoveBuff(transientUnitId, instanceId));
|
||
ctx.Session.Log.Info($"[WEB] client-buff-remove transient={buffId} instance={instanceId}");
|
||
}
|
||
|
||
// Client 4229938 reports grapple teardown by buff *id*, not by instance.
|
||
// Locally that teardown can consume the configured resident grapple gate as well as the short-lived
|
||
// action copy. This is especially visible on the female protagonist: the first grapple
|
||
// succeeds, then no second client-buff-add is emitted. Re-arm one untracked resident gate
|
||
// after teardown for either protagonist. It is deliberately NOT stored as
|
||
// ActiveFeiSuoBuffInstanceId; that slot is reserved for the next action copy only.
|
||
await Task.Delay(WebTraversal.GrappleRearmDelayMs);
|
||
// ⛑⛑ 蹲伏期间不加回:客户端是为了进蹲伏才摘的(Enter 与 Remove 相隔 4ms)。
|
||
// 强行加回 ⇒ 客户端立刻退出蹲伏 ⇒ 暗杀检测永远跑不完一轮。
|
||
if (state.FeiSuoCrouchActive)
|
||
{
|
||
ctx.Session.Log.Info($"[WEB] persistent-gate-reassert skipped unit={unitId} reason=feisuo-crouch-active");
|
||
return;
|
||
}
|
||
if (IsActiveProtagonist(state, unitId))
|
||
{
|
||
var residentInstanceId = state.NextBuffInstanceId++;
|
||
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff,
|
||
WorldCodec.UnitAddBuff(unitId, WebTraversal.FeiSuoBuff, residentInstanceId));
|
||
ctx.Session.Log.Info($"[WEB] persistent-gate-reassert unit={unitId} buff={WebTraversal.FeiSuoBuff} instance={residentInstanceId}");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!WebTraversal.IsProtected(buffId))
|
||
return;
|
||
|
||
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff, WorldCodec.UnitAddBuff(unitId, buffId, state.NextBuffInstanceId++));
|
||
ctx.Session.Log.Info($"[WEB] protected-buff-reassert unit={unitId} buff={buffId}");
|
||
}
|
||
async Task ClearClientWebLifecycleBuffs(RpcContext ctx, ulong unitId, string phase)
|
||
{
|
||
var state = GetWorldState(ctx);
|
||
var active = state.ActiveClientWebBuffInstances
|
||
.Where(x => x.Key.UnitId == unitId)
|
||
.ToArray();
|
||
foreach (var item in active)
|
||
{
|
||
state.ActiveClientWebBuffInstances.Remove(item.Key);
|
||
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
|
||
WorldCodec.UnitRemoveBuff(unitId, item.Value));
|
||
}
|
||
if (active.Length > 0)
|
||
ctx.Session.Log.Info($"[WEB] lifecycle-clear phase={phase} unit={unitId} count={active.Length}");
|
||
}
|
||
|
||
}
|