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