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

308 lines
15 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.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}");
}
}