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;
/// Protagonist traversal/web buff handling. Buff ids come from private-server.json.
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}");
}
///
/// 蹲伏暗杀裁决:客户端上报「这几个在我背后」→ 服务端回 SyncEnemyCanAssassinate 授权。
///
/// ★ 为什么必须有这一段:客户端 StealthManager 只负责算候选(每 0.4s 一次),
/// 真正的授权在服务端这一条回包上。本服 2026-09-26 之前既没有 handler 也从不下发
/// (全仓只有 MethodId 常量)⇒ canAssassinate 恒 false ⇒
/// EnemyInteractCore.TrySetFeisuoCrouchAssassinInteract 从不置 true ⇒
/// HUD 不出暗杀提示、ControlPower 按钮不切成暗杀态。
///
/// ⛑ 只在集合变化时发包:候选名单蹲着不动时是完全一样的,不做差分会每 0.4s 推一批。
/// ⛑ 两态都发:撤销(canAssassinate=false)同样重要,否则目标走远了提示还在。
///
async Task OnReportCanAssassinateEnemies(RpcContext ctx, List enemyIds)
{
var state = GetWorldState(ctx);
if (!WebTraversal.AnswerCrouchAssassination)
return;
if (!IsActiveProtagonist(state))
return;
var granted = state.AssassinationGrantedAgents;
var incoming = new HashSet();
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}");
}
/// 清空全部暗杀授权(离开蹲伏态 / 退场景时调用),逐个发 false。
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);
// ★ 白靖「斯安威斯坦」(子弹时间)—— 这是它的原生触发通道:
// 客户端职业技能按钮按下后 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}");
}
}