Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.LocalEnemyCombat.cs

1808 lines
104 KiB
C#
Raw Permalink 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;
using Ananta.Server.RpcTypes.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Combat authority for CLIENT-SPAWNED enemies.
///
/// The enemy this covers is the one produced by the in-game local channel
/// (GmUtils.CreateClientAgent / LuaUtils.CreateClientAgentByCfg). That unit belongs to
/// the client: it lives in the client's LocalUnitMgr, it carries its own HP / death
/// model / animation layer, and this server has no record of it at all. Nothing drives
/// that model either — the client's combat sim never applies damage to a local unit, so
/// the enemy absorbs every shot without flinching and never dies. Logs confirm the
/// player's hits DO arrive here (AskMultipleSkillHit2 with the local unit's uid in
/// HitTarget, firearms included), which means the server is the only missing link.
///
/// This file closes that link without touching how enemies are created and without
/// creating an entity server-side:
///
/// 1. LAZY LEDGER. The first time a hit names a local unit, its uid is recorded. No
/// spawn packet is sent, the client's unit is left exactly as it is; the server
/// only starts remembering "this uid has an HP pool".
/// 2. FEEDBACK. Every accepted hit broadcasts the hurt effect, a one-shot stagger and the
/// authoritative HP ratio. SyncUnitHurtEffect (68349329) is the important one: the
/// client fastpatch defines a Lua impl for it that forwards the payload into
/// gCS.BattleManager.LuaTryDoHurtAction, i.e. the client's OWN hurt pipeline, keyed by
/// pid. That is what makes the enemy actually flinch - and it is the right lever,
/// because the animation state machine owns the Animator and rewrites the pose from its
/// own State every frame, so any one-off PlayAction we sent would read as a twitch that
/// instantly recovers. SyncUnitHp is resolved through gCS.SceneDataMgr.GetUnit(pid), the
/// same global lookup that also serves LocalUnitMgr, which is why server packets can
/// reach a client-owned unit at all.
/// 3. DEATH. At zero HP the unit runs the down pose -> death beat -> loot drop -> despawn
/// chain. The step that matters on screen is the last one, SyncRemoveNpc, and it depends
/// on the same fastpatch: the retail build declares that method LuaOnly and ships its
/// reader, but never defines an impl, so the dispatcher's name lookup found nothing and
/// the packet was dropped on arrival without a log line. The patch's impl removes the
/// unit through BaseUnitUtils.DestroyAgentUnit left non-forced, which routes into
/// LocalUnit.OnDead and lets it play the death beat and schedule its own delayed destroy
/// instead of deleting the unit outright.
///
/// The stagger packets (67149 / 88300) ARE the reaction path. SyncEnemyPlayAction
/// (68039914) is declared CSharpOnly and RPCDeserializeAuto ships neither a midToReader
/// nor a midToName entry for it, so the Lua dispatcher used to return false on the
/// missing reader before it ever looked up a handler and the packet reached only the
/// native C# impl - which resolves units through registries a LocalUnit is not in.
/// The client fastpatch now injects those two map entries at runtime (reader =
/// u64 uid, u32 actionId, f32 maxTime, i.e. this file's EnemyPlayActionData4229938)
/// plus a Lua impl, so these packets land and drive the pose. Keep sending them.
///
/// Two deliberate exclusions keep the two spawn paths from colliding:
/// * units this server spawned (admin panel / GM channel) own uid >= ServerSpawnUidFloor and
/// are handled by GameAdminBridge — a lazy ledger entry there would double up;
/// * the player's own unit ids are never treated as enemies. Note the player entity is
/// 0x200000000001 (client-allocated), NOT Profile.InitialUnitId — before the ordinal
/// offset was introduced it sat above the floor and was settled as an enemy.
///
/// Feature switch: create an empty file named "local-enemy-combat.disabled" next to
/// Ananta.App.exe and restart the server to turn the whole path off.
/// </summary>
internal sealed partial class GameRouter
{
private const string LocalEnemyRuntimeKey = "client4229938-local-enemies";
/// <summary>
/// Boundary between the two uid spaces. Server-spawned agents are synthesised as
/// ServerSpawnUidBase + ServerSpawnUidOrdinalOffset + ordinal
/// (GameAdminBridge.SpawnAgentAsync), while every uid the client assigns to its own
/// local units sits below that, so the range split is how a hit target is attributed
/// to one path or the other.
///
/// Measured in packets-latest.log: the player's own entity is 0x200000000001, i.e. the
/// client's local units and our agents used to share the very first indices. Ordinal 1
/// then became the player, and this ledger settled the player's HP down to zero, which
/// parked the HUD on the knocked-out bar ("昏迷 100%"). The offset now reserves the low
/// indices for the client. Keep it in sync with GameAdminBridge — a drift here silently
/// re-attributes every spawned agent to the wrong path.
///
/// The player unit (100000000000) is below the floor too and is filtered separately.
/// </summary>
private const ulong ServerSpawnUidFloor = GameAdminBridge.ServerSpawnUidFloor;
// Local enemies carry their own HP model client-side, but nothing drives it. These
// are the server-side numbers that replace it: 1000 HP and 100 per accepted hit, so
// the standard test enemy dies on the 10th hit — the same tuning the LY-3.3 reference
// server settled on, which keeps hit counts comparable across both.
// internal: GameAdminBridge.SpawnAgentAsync seeds its own spawn batch from the same
// number so both spawn paths agree on what "full HP" means.
//
// ⛑⛑ 2026-09-27 改为配置驱动(`gameplay.combat.enemies.maxHp`,默认仍是 1000):
// 这是 GM/服务端 spawn 与「地图敌人首次受击登记」两条路<b>共用</b>的唯一血量来源,
// 以前调一次要改常量 + 重编译,现在改 json 即可(热重载生效)。
// 用属性而不是 const,保证每次读取都拿最新值;`Math.Max(1f, …)` 兜底配 0/负数。
internal static float LocalEnemyMaxHp
=> Math.Max(1f, PrivateServerConfigStore.Current.Gameplay.Combat.Enemies.MaxHp);
private const float LocalEnemyHitDamage = 100f;
// ---- Poise / groggy (stance) tuning ----
// Stance is deliberately a separate pool from HP: the client renders it as its own bar
// (SyncEnemyPoiseRate, verified visible in-game 2026-09-23) and draining it is what puts the
// enemy into the "虚弱" break where it stops swinging. Numbers taken from the enemy tuning
// block of the kimika reference server (maxPoise 100, poiseDamagePerHit 34, groggySeconds 5):
// 34 means three clean hits break the stance, so a break lands roughly once per fight
// instead of once per hit or never.
internal const float LocalEnemyMaxPoise = 100f;
/// <summary>每次命中扣多少架势 —— <b>不再写死</b>,改 private-server.json 的
/// <c>gameplay.combat.enemies.poiseDrainPerHit</c> 即可(100 = 每下破防 / 34 = 三下破防 /
/// 0 = 只走服务端模型)。服务端账本和发给客户端的 <c>SyncUnitAttacked.PoiseBasicValue</c>
/// 用的是同一个数,所以两边永远同步破防。
///
/// 原来是常量 34f(kimika 捐赠端值)。2026-09-23 夜按用户要求改回"每一下都有明显反应"的
/// 手感,默认值调到 100(= 一击伤害,也就是 11:3x 那个状态的值)。那时候它会导致敌人卡在
/// 破防里出不来("打不死"),但那是因为当时<b>没有退出通道</b>;现在 ReportUnitStandUp
/// 回执 + SyncEnemyDisarmState(false) + 5 s 超时兜底都在了,可以安全地开回来。</summary>
private static float LocalEnemyPoiseDamagePerHit
=> PrivateServerConfigStore.Current.Gameplay.Combat.Enemies.PoiseDrainPerHit;
/// <summary>敌人受击手感的三个旋钮(见 <see cref="EnemyCombatSettings"/>)。</summary>
private static EnemyCombatSettings EnemyHit
=> PrivateServerConfigStore.Current.Gameplay.Combat.Enemies;
private const long LocalEnemyGroggyMs = 5000;
/// <summary>虚弱窗口内重新顶一次硬直动作的间隔(ms)。
///
/// 2026-09-23 17:1x 实测:破防后敌人只是站着不动,5 s 后照常挥击 —— 玩家读作"没打晕"。
/// 原因:SyncEnemyPlayAction 是一次性动作,67149 播完(不到 1 s)就回站立,
/// 而客户端的虚弱 UI 在这个 build 里是死的(SyncEnemyDisarmState 的 C# 体是空的、
/// SyncEnemyPoiseRate 的 Lua handler 守卫写反)。所以服务端必须自己撑住这个姿势:
/// 窗口内周期性重推,敌人看起来才是"晕着"而不是"站着发呆"。900 ms 略短于一次
/// 900 → 3600 → 1200 → <b>3000</b>(2026-09-23 19:2x):1200 是"被顶掉后尽快补回来"定的,
/// 实测代价是 5 s 窗口里倒地了三次 —— 88300 是 RemainTime 4 s 的重倒地动作,每次补推都是
/// 一次动作重启(弹起再趴下),玩家读作"连续倒地三次"。3000 让 5 s 窗口最多出现两次倒地:
/// 破防那一次,加一次"真被顶掉之后"的补。
/// 前提仍然是:客户端本地的受击反应走 native(SyncUnitHurtEffect 在这个 build 上没有 Lua
/// impl,fastpatch 明确 left to native),服务端碰不到,只能事后补。</summary>
private const long LocalEnemyGroggyReassertMs = 3000;
/// <summary>是否推 SyncEnemyPlayAction(67149) 一次性硬直动作。<b>默认开</b>。
///
/// ⛑⛑ 2026-09-23 15:2x 实测翻案:这一条曾被关掉(理由写反了,见下),结果是「远程打敌人
/// 完全没有受击反应」——因为远程命中的 hurtEffect 恒为 55004611,客户端靠它演不出硬直,
/// 67149 是唯一还能让远程命中"看得见"的动作。近战当时看着有硬直,只是因为它 3 下就撞上
/// 我们自己的 poise 破防(groggy → SyncEnemyDisarmState),跟 67149 无关。
///
/// 当初关掉的理由及为何不成立:捐赠端(kimika)命中路径确实不发这个包,但 kimika 能不发是
/// 因为它 spawn 时把 BattleAiS / 6 个技能槽 / Detect / DetectStatus / FightEdict 一次推齐,
/// 客户端的 hurt 管线本来就是活的;我们缺那一整套,客户端的受击反应根本没起来,只能靠
/// 67149 显式驱动。
///
/// 「播完没有退出包 ⇒ 卡住」这条也已经翻案:真凶是 SyncUnitAttacked.PoiseBasicValue 填了
/// damage(100),而客户端 poise 池正好也是 100 ⇒ 每一下都直接破防倒地,且当时没有任何
/// 「退出虚弱」的路径。现在 PoiseBasicValue=0(下见 SendLocalEnemyAttackedAsync)+ 破防有
/// EnterLocalEnemyGroggyAsync/ExitLocalEnemyGroggyAsync 完整进出 + 5 个 C2S 上报 handler,
/// 卡住的根因已经不在 67149 身上。若复测又出现「打晕后不动」,优先把它改回 false 再验证。
/// ⛑ 用 static readonly 而不是 const:const bool 会让编译器把另一分支判为不可达(CS0162)。</summary>
private static readonly bool LocalEnemyPushStaggerAction = true;
// 死亡时是否由<b>服务端</b>推 SyncEnemyPlayAction(88300) 倒地动作 —— 2026-09-23 17:4x 起为「否」,
// 常量连同那段代码一起移除,理由留在这里,免得下次又把它加回来:
//
// 客户端 Player.log 证明 fastpatch 的 SyncRemoveNpc hook 每一次都成功播了 88300
// (17:43:51 / 17:44:27 / 17:44:49 / 17:45:07 四条 "death PlayAction(88300) ok=true"),
// 3 s 后 DestroyAgentUnit 也成功。服务端再补一个 88300 只会造成动作重启 —— hook 自己的注释
// 写着 "the restart is what makes the corpse snap upright before it drops"。
// 想做对照实验:在 LocalEnemyDeathSequenceAsync 的 removeNpc 之后补一段 SyncEnemyPlayAction
// (LocalEnemyDownActionId)。
// One-shot animation ids verified against this build: 67149 = stagger, 88300 = down.
// 67150/67148 (the other entries in HurtStiffConfig) are NO-OPs on this client, so a
// rotation through them would silently drop two of every three reactions.
private const uint LocalEnemyStaggerActionId = 67149;
private const uint LocalEnemyDownActionId = 88300;
/// <summary>击晕(破防)姿势用的动作 = <b>88300 倒地</b>,不是 67149。
///
/// ⛑⛑ 2026-09-23 18:0x:原来击晕复用 67149(受击硬直),结果玩家看不出任何区别 ——
/// 67149 是一次性短硬直,破防后敌人只是「僵一下 / 呆站着」,而玩家要的是<b>倒地</b>
/// ("昏迷/击晕"在这个游戏里就是被打趴)。88300 来自 HurtStiffFirearmDieConfig,
/// RemainTime = 4 s,正好覆盖 5 s 虚弱窗口的大半,是唯一已知的倒地动作。
/// 若实测发现敌人看起来像"死了",把它改回 LocalEnemyStaggerActionId 即可,
/// 破防账本(Poise / GroggyUntilTicks / 停手闸门)与这个动作 id 完全解耦。</summary>
private const uint LocalEnemyGroggyActionId = LocalEnemyDownActionId;
/// <summary>击晕(虚弱)期间要不要推倒地动作 88300。<b>2026-09-23 19:4x 起默认 false</b>。
///
/// 三条实测把这条路堵死了:
/// * 不推 ⇒ 敌人呆站着(客户端的 SyncEnemyDisarmState / SyncEnemyPoiseRate 在这个 build
/// 里都是空实现,虚弱 UI 起不来)。
/// * 推 88300 ⇒ 它是 RemainTime 4 s 的重倒地动作,玩家在虚弱窗口里照打,本地受击把它顶掉,
/// 服务端补推 = 动作重启 ⇒ 5 s 窗口里"连续倒地三次"。
/// * 推 67149(短硬直)⇒ 和受击动作一模一样,玩家读作"没打晕、只是僵一下"。
/// 用户 2026-09-23 已明确<b>接受"击晕无法倒地、只会呆呆站着"</b>,所以关掉,等找到真正的
/// 短眩晕动作 id 再打开。账本(Poise / GroggyUntilTicks / 停手闸门 / DisarmState)与该开关
/// 完全解耦,关掉它只影响"看不看得见姿势"。</summary>
private static readonly bool LocalEnemyGroggyPoseEnabled = false;
/// <summary>UX.Game.EnemyDetectState.None — pushed when a unit dies so the corpse leaves the
/// Fight behaviour instead of standing in the combat tree with an empty bar.</summary>
private const byte LocalEnemyDetectStateNone = 0;
/// <summary>UX.Game.EnemyDetectState.Fight — the slot that maps onto Behavior_Battle.
/// Carried by SyncEnemyDetectStatus(68126766); see SyncEnemyDetectStatusData4229938.
/// A spawned enemy sits at None(0) until this is pushed, and the enemy HUD keys the HP
/// bar off that state, so the bar cannot bind without it.</summary>
private const byte LocalEnemyDetectStateFight = 4;
/// <summary>Per-enemy stagger throttle. Slower than a melee swing and faster than a
/// firearm's cycle, so a fast melee combo cannot restart the flinch before it finishes
/// while firearms are unaffected.</summary>
private const int LocalEnemyStiffCooldownMs = 700;
/// <summary>Delay before the stagger so the hurt VFX (which resets the client's action
/// layer) has already been applied.</summary>
private const int LocalEnemyStiffDelayMs = 120;
/// <summary>Delay before the down pose so the client's action layer finishes the last
/// hit reaction first — a down action sent mid-stagger-restart is dropped, which is
/// exactly the "no death animation" symptom.</summary>
private const int LocalEnemyDeathActionDelayMs = 250;
/// <summary>
/// <c>SyncAddUnitState</c>(68026128) 的 <c>state</c>:把单位打进 <c>UnitStateConfig.DeadS</c>。
///
/// ⛑⛑ 是 <b>2</b>,不是 3。<c>UnitStateAtlasConfig.Death = 3</c> 只是个图集 atlas,
/// 发 3 单位不会真的进死亡行为模块(捐赠端 7k-v0.5 源码原注释)。
/// </summary>
private const uint LocalEnemyDeadUnitState = 2;
/// <summary>Gap between the down pose and the death beat (SyncEnemyPrepareDie).
///
/// The two beats must NOT land in the same frame: SyncEnemyPrepareDie drives the HP bar
/// collapse and the corpse setup, so pushing it together with the down action makes the
/// bar snap to 0 with no fall and tears the corpse down mid-animation (the remaining half
/// of the "no death animation" symptom — the other half was the 1500ms despawn).
/// 650ms is the value an independent 4229938 branch arrived at by the same reasoning.</summary>
private const int LocalEnemyDeathBeatDelayMs = 650;
/// <summary>Destroy distance handed to the client with the death beat.
///
/// Was 0, which the client reads as "destroy as soon as it is off-camera" — combined with
/// a 900-unit authored remain distance in the retail DeathSetting, this is what made
/// corpses vanish the instant the player turned away.</summary>
private const float LocalEnemyDelayDestroyDistance = 900f;
/// <summary>Publish a SyncEnemyPoiseRate frame with every accepted hit.
///
/// Started as a one-restart diagnostic on 68696896 (its C# handler IS an empty shell on
/// this build, which is why the dump shows no IDMAP entry). It is no longer a probe: the
/// client does react — a stance bar appears and a drained pool stops the enemy swinging,
/// confirmed in-game 2026-09-23, so the Lua side is the real consumer. Set to false to
/// stop shipping it; the HP / lethality pipeline does not depend on it.
///
/// ⛑ 2026-09-26 <b>Wave 4:关掉</b>(补偿 UI 清理)。W1 poiseDrainPerHit=0 之后服务端
/// 账本不再扣架势 ⇒ 这根条永远满格,是纯噪音;捐赠端 7k-v0.5 从头到尾不发这个包。
/// 想 A/B 就翻回 true。⛑ 用 static readonly 而不是 const:const false 会让编译器把
/// 另一分支判为不可达(CS0162)。</summary>
internal static readonly bool PushPoiseProbe = false;
/// <summary>死亡表现播完之后,SyncRemoveNpc 距 SyncEnemyPrepareDie 的间隔。
///
/// 2026-09-23 实测:客户端的死亡动作是<b>由 SyncRemoveNpc 的 Lua hook 播放的</b>(fastpatch
/// <c>LuaFiles#LX6#Manager#ClientGameSwitch.lua</c>),不是由服务端的 SyncEnemyPlayAction:
/// hook 里先 <c>PlayAction(unit, 88300, group)</c>,再 3 s 后 DestroyAgentUnit。它播之前还会
/// <c>__DRMK_SetT(__DRMK_Dead, unit, pid, true)</c> 关掉 hurt 路径 —— 这正是近战需要的:
/// 玩家连招不停时,后续命中的本地反应会顶掉倒地动作。
/// 在此之前的死亡序列<b>只对 client-spawned 单位发 SyncRemoveNpc</b>(<c>if (!serverSpawned)</c>),
/// GM 生成的敌人走的是 serverSpawned 分支,一次都没发过 ⇒ 那个 hook 从来没跑过。
/// 1700 → 0(2026-09-23 17:1x):此延迟是相对 SyncEnemyPrepareDie 的,实测 1.7 s 的空档里
/// 玩家近战连招照打,本地 hurt 反应把倒地动作顶掉 —— 尸体一直站着播硬直,最后被拆掉时
/// 玩家看到"突然消失"。0 = 死亡序列一开始就发,hurt 闸门在倒地之前就关上。
/// 想做对照实验就把这个值调回 1700(症状应当复现)。</summary>
private const int LocalEnemyRemoveNpcDelayMs = 0;
/// <summary>Time the down pose is allowed to play before the loot drop and despawn.
///
/// Was 1500ms, which is shorter than the authored death: HurtStiffFirearmDieConfig
/// record 1 gives DieAction 88300 a RemainTime of 4 seconds. Despawning at 1.5s removed
/// the corpse while the animation was still running, which reads as "no death animation"
/// even when the pose did start. Measured in packets-latest.log: SyncEnemyPlayAction
/// 88300 at 21:57:07.295, SyncLogicAgentLeave at 21:57:08.818 — 1.5s, exactly.
///
/// 5000 → 3500(2026-09-23 17:1x):现在 SyncRemoveNpc 是死亡序列的第一包,hook 的销毁
/// 定时器(3 s)从这里起算,尸体不需要服务端留到 5 s;3.5 s 兜底拆足够让 3 s 走完。</summary>
private const int LocalEnemyDropDelayMs = 3500;
/// <summary>
/// ★ T1-17(2026-09-25):死亡序列推 <c>SyncDetachBattleUnit</c>(68396133) 前,在
/// <c>SyncEnemyPrepareDie</c> + <see cref="LocalEnemyDropDelayMs"/> 之后**再**等多久。
/// donor 实测:客户端死亡特效约 3 s 起、5.3 s 完 ⇒ 3500 + 2500 = 6000 ms 正好落在他们用的
/// 「尸体保留 6000 ms」上。**提前 detach 会把死亡特效打断**(他们注释原话)。
/// ⛔ **2026-09-25 19:3x 回滚为 0**:发这个包之后玩家实测「死亡动画没了、只会直接消失」
/// (19:34–19:35 五个 uid 全部走完 detach,紧跟着 logicAgentLeave + clearSpoonEnemies)。
/// 客户端 `Player.log` 对 68396133 零报错、零日志 ⇒ 副作用发生在 C# 侧、无从观测,
/// 而收益("死亡特效")**donor 自己也没做出来** ⇒ 收益不抵风险,**不再发**。
/// 想复现就把它改回 2500 重编译(症状应当复现)。
/// ⛑ `static readonly` **不是** `const`:常量会在编译期折叠,写 0 会让下面的 `if` 分支
/// 变成「无法访问的代码」(CS0162 警告)。改成 readonly 之后开关语义不变(改 0 即不再发),
/// 但编译干净。
/// </summary>
private static readonly int LocalEnemyDetachBattleUnitDelayMs = 0;
/// <summary>尸体被客户端再次打进硬直时,重推倒地动作的最小间隔。
/// 1500 → 3000 → <b>1200</b>(2026-09-23 18:4x):3000 是照着 hook 里「2.5 s 内播过就不再重启」
/// 那条守卫定的,本意是退化为纯兜底;但那个守卫同时也挡住了所有补播 —— 玩家近战连招把倒地
/// 顶掉之后,整整 3 s 内没有任何一方再把 88300 顶回去,尸体就一直站着演受击,最后是"直接
/// 消失"。现在把间隔压回 1.2 s(19:4x),并且同步把 hook 的守卫从 2.5 s 降到 0.8 s(见
/// fastpatch 的 <c>SyncRemoveNpc</c> 实现),双方就不再互相抵消。
/// ⛑ 19:2x 曾把这里改成 2.0 s 以配合"周期扫描也补推",那个周期扫描 19:4x 已撤掉
/// (它是"远程放两次倒地"的另一半),于是这份节流表重新只服务 AskAgentEnterStiff 上报 ——
/// 近战连招下补得越快,倒地越不容易被吃掉。
/// ⛑ 代价仍是重推 = 动作重启(hook 注释:the restart is what makes the corpse snap upright
/// before it drops),但"弹起再趴下"远好过"站着不动然后消失"。</summary>
private const int LocalEnemyDeathReassertCooldownMs = 1200;
/// <summary>死亡后这段时间内<b>不接受</b>任何补推(ms)。见 ReassertLocalEnemyDeathAsync 里
/// 的实测:致死那一击自带的残留 AskAgentEnterStiff 会把 hook 刚播的 88300 重启成"倒两次"。
/// 必须 &lt; hook 的销毁定时器(3 s),而 &gt; 残留上报的延迟(实测 &lt;1 s)。</summary>
private const int LocalEnemyDeathQuietMs = 900;
/// <summary>收到客户端 AskAgentEnterStiff 上报后,补推击晕姿势的最小间隔(ms)。
/// 比 LocalEnemyGroggyReassertMs 短:上报是"已经被顶掉了"的即时证据,补得越快越看不出
/// 断档;但不能快到每一下近战都重推(那又变成动作重启,实测 700 ms 就是"连续倒地三次")。
/// 700 → 2000(2026-09-23 19:2x)。</summary>
private const int LocalEnemyStiffNudgeMs = 2000;
/// <summary>Soft cap so a long session cannot grow the ledger without bound.</summary>
private const int LocalEnemyLedgerCap = 256;
internal sealed class LocalEnemyRecord
{
public ulong Uid;
public float MaxHp = LocalEnemyMaxHp;
public float Hp = LocalEnemyMaxHp;
public int Hits;
public long FirstSeenTicks;
public long LastStiffTicks;
public Vec3 Position;
public uint LastSkillId;
public uint LastHurtEffectId;
public bool Dead;
public bool DeathStarted;
/// <summary>True when this record was created by SpawnAgentAsync rather than
/// discovered from a hit report. Server-spawned agents are real battle units the
/// client's native code can see, so they are engaged and despawned through the
/// agent channel; client-spawned local units need the local-unit removal path
/// instead. Template id and locked player unit are kept for logging and re-engage.</summary>
public bool ServerSpawned;
public uint EnemyId;
public ulong PlayerUnit;
// ---- Poise / groggy (stance) state ----
/// <summary>Current stance pool. Drains per hit, not per damage point: a heavy hit and a
/// light one cost the same, which is what makes the bar readable and predictable.</summary>
public float Poise = LocalEnemyMaxPoise;
/// <summary>TickCount64 until which the enemy is staggered and stops swinging.
/// 0 = not groggy. The pool refills when the window closes — see
/// ExitLocalEnemyGroggyAsync (GameRouter.EnemyStiff.cs), which is also the place that
/// pushes SyncEnemyDisarmState(false) + a full stance bar back to the client.</summary>
public long GroggyUntilTicks;
/// <summary>True while the client has told us (AskAgentEnterStiff) the unit is in 硬直.
/// Cleared by AskAgentExitStiff / ReportUnitStandUp — see GameRouter.EnemyStiff.cs.</summary>
public bool StiffActive;
/// <summary>How many times this enemy has been broken. Logging only.</summary>
public int GroggyCount;
// ---- 2026-10-02 逐字对齐捐赠端 EnemyRecord 的收押/攻击 AI 字段 ----
// 主线原本没有这些(旧 PolicePrison.cs 靠改账本坐标 + AttackAiEnabled=false 顶替)。
// 现在捐赠端 GameRouter.PolicePrison.cs 逐字落地 ⇒ 把这些状态补上,**纯新增、不改既有语义**。
public bool IsPoliceDetained;
public bool AttackAiFollowing;
public ulong AttackAiFollowTarget;
// AttackAiTarget / AttackAiCastSkill / AttackAiCastUntil 主线本来就有 ⇒ 不重复加。
public ulong AttackAiCastTarget;
public long HackBusyUntilTicks;
public int SceneGeneration;
// ---- Alertness / enemy-HUD state (GameRouter.EnemyWarning.cs) ----
/// <summary>
/// 已经注册过警戒状态机。false = 客户端那边还是 <c>EnemyDetectState.None</c>,
/// HUD(血条 + 挂它上面的警戒条)不会绑 —— 这就是"血条默认不显示"。
/// </summary>
public bool WarningPrimed;
/// <summary>最近一次下发的 detectionState(0 None / 1 Idle / 2 Suspect / 3 Alert / 4 Fight)。</summary>
public byte WarningState;
/// <summary>
/// 注册后剩余的重试次数。模型/occur 重建会把早期那份 HUD 快照丢掉,
/// 所以刚注册的两拍里即使状态没变也要重发(捐赠端 Warmup=2)。
/// </summary>
public int WarningWarmupRemaining;
/// <summary>下一次允许重估/重发的 TickCount64。状态不变就在这里直接跳过,避免刷包。</summary>
public long WarningRefreshAt;
// ---- 血条显示时机(GameRouter.EnemyHpBar.cs)----
/// <summary>
/// 服务端给这个单位挂了 <c>UnitStateConfig.HideHp</c>(10266),血条此刻是<b>收起</b>的。
/// 第一次命中时由 <c>RevealLocalEnemyHpBarAsync</c> 撤掉状态并置回 false。
/// </summary>
public bool HpBarHidden;
// ---- Attack-AI state (GameRouter.EnemyAiActions.cs) ----
public bool AttackAiEnabled;
public ulong AttackAiTarget;
public long AttackAiActionAt;
public uint AttackAiCastSkill;
public long AttackAiCastUntil;
public int AttackAiCastInstance;
public int AttackAiCastCount;
public byte AttackAiMoveId;
public int AttackAiMoveCount;
public long LastHurtFeedbackTicks;
/// <summary>Spawn facing, kept so the turn command can compute a delta angle.</summary>
public float Facing;
/// <summary>
/// 出生点。踱步的保险绳基准:估算位置离它超过 EnemyAiPatrolLeashMeters 就强制换向,
/// 免得估算误差累积把敌人一路推到玩家脸上。
/// </summary>
public Vec3 HomePosition;
/// <summary>踱步方向:true = 下一步朝玩家(WalkFront),false = 下一步背对玩家退(WalkBack)。</summary>
public bool PatrolForward = true;
/// <summary>engagement 状态上次翻转的 TickCount64,用于 EnemyAiEngageHoldMs 防抖。</summary>
public long AttackAiEngageChangedAt;
/// <summary>Estimated, NOT authoritative. The client owns the unit's real position
/// after a move command and reports it back without coordinates, so this is walked
/// towards the player by <see cref="GameRouter.EnemyAiMoveStepMeters"/> per dispatch.
/// Only used to decide "close enough to swing yet".</summary>
public Vec3 AiPosition;
}
internal sealed class LocalEnemyRuntimeState
{
public object SyncRoot { get; } = new();
public Dictionary<ulong, LocalEnemyRecord> Enemies { get; } = new();
/// <summary>TickCount64 deadlines for corpses that still need to be torn down.
///
/// SyncEnemyPrepareDie only makes the client PLAY the death; the unit itself is removed by
/// SyncClearSpoonEnemies. Entries are added by the death sequence and removed either by the
/// client's own report (ReportEnemyPrepareFinish / ReportAgentDiePerfEnd) or by the grace
/// timeout — see GameRouter.EnemyStiff.cs. Before this existed the corpse was never removed
/// at all, which is the "killed it and it came back, now unkillable" report.</summary>
public Dictionary<ulong, long> PendingDeathClear { get; } = new();
/// <summary>TickCount64 of the last time the down pose was re-pushed onto a corpse.
/// 尸体被玩家连招再次打进硬直时用来节流(见 ReassertLocalEnemyDeathAsync)。
/// 尸体拆掉时随 PendingDeathClear 一起清理。</summary>
public Dictionary<ulong, long> LastDeathReassert { get; } = new();
/// <summary>尸体<b>最早</b>允许被拆掉的 TickCount64(= 死亡序列开始 + LocalEnemyMinCorpseMs)。
///
/// 2026-09-23 19:2x:客户端会在死亡动画刚起手时就上报 ReportEnemyPrepareFinish /
/// ReportAgentDiePerfEnd,而这两个 handler 原来是<b>收到即拆</b>(SyncClearSpoonEnemies),
/// 于是同一套死亡序列会随机表现成两种结果 —— 上报来得早就"直接消失",来得晚才"正常倒地"。
/// 这正是"死亡动作不一定能加载出来"的来源。加了这个下限之后,客户端的报告只能把清理
/// <b>提前到下限</b>,不能提前到动画播完之前。</summary>
public Dictionary<ulong, long> DeathEarliest { get; } = new();
/// <summary>TickCount64 of the last time the 硬直 pose was re-pushed onto a 虚弱 enemy.
/// 虚弱窗口有 5 s,而客户端的 SyncEnemyPlayAction 是一次性动作、播完就站起来,
/// 所以窗口内必须周期性重推,敌人看起来才是"被打晕了"而不是"站着发呆"
/// (见 ReassertLocalEnemyGroggyAsync)。虚弱结束时清理。</summary>
public Dictionary<ulong, long> LastGroggyReassert { get; } = new();
/// <summary>Guards against starting a second attack-AI worker for the same session.
/// One worker drives every spawned enemy; it exits once nothing is left to drive.</summary>
public bool AttackAiWorkerStarted;
/// <summary>
/// TickCount64 deadlines at which the player's control states get released, queued by
/// the attack AI when it dispatches a cast. See EnemyAiUnlockDelayMsEarly/Late.
/// </summary>
public List<long> PendingUnlocks { get; } = new();
/// <summary>Auto-release on/off. Toggled from the admin panel; on by default.</summary>
public bool AutoUnlockEnabled = true;
// ---- 2026-10-02 逐字对齐捐赠端 EnemyRuntimeState 补的三个成员 ----
// 捐赠端 GameRouter.Enemy.cs:116 的 `GetEnemyRuntimeState` 返回 `EnemyRuntimeState`;
// 主线这套被 `LocalEnemyRuntimeState` 取代,字段命名也不同。这里**只加字段、不改语义**,
// 让捐赠端 GameRouter.PolicePrison.cs / LixiPolice.cs 能逐字编译。访问器别名在
// GameRouter.PoliceDonorGlue.cs。
public readonly SemaphoreSlim HackDispatchGate = new(1, 1);
public int AttackAiWorkerGeneration;
public long AttackAiWorkerId;
}
internal static LocalEnemyRuntimeState GetLocalEnemyRuntimeState(TcpSession session)
{
if (session.Items.TryGetValue(LocalEnemyRuntimeKey, out var raw) && raw is LocalEnemyRuntimeState existing)
return existing;
var created = new LocalEnemyRuntimeState();
session.Items[LocalEnemyRuntimeKey] = created;
return created;
}
// Same opt-out idiom as the infinite-ammo switch: a sentinel file beside the exe,
// re-checked at most every 2s so it can be flipped without a rebuild.
private static long _localEnemyCheckTicks;
private static bool _localEnemyCached = true;
internal static bool LocalEnemyCombatEnabled()
{
var now = Environment.TickCount64;
if (now - _localEnemyCheckTicks < 2000)
return _localEnemyCached;
_localEnemyCheckTicks = now;
try
{
_localEnemyCached = !System.IO.File.Exists(
System.IO.Path.Combine(System.AppContext.BaseDirectory, "local-enemy-combat.disabled"));
}
catch
{
_localEnemyCached = true;
}
return _localEnemyCached;
}
// ------------------------------------------------------------------
// Registration of SERVER-SPAWNED agents (GameAdminBridge.SpawnAgentAsync).
//
// A server-spawned agent is a real battle unit: the client creates it from
// SyncRaidBattleUnitAgent and its native code can see it, so SyncEnemyPlayAction
// (CSharpOnly) actually reaches an animation layer — unlike a client-spawned local
// unit, where those same packets are dropped because the uid is not in the native
// registry. That single difference is why this path is the reliable one for
// stagger + death, and why SpawnAgentAsync registers its agents here instead of
// relying on the lazy hit-ledger below.
// ------------------------------------------------------------------
/// <summary>Puts a freshly spawned agent into the HP ledger so its hits are settled.
/// Called by GameAdminBridge.SpawnAgentAsync right after the spawn batch.</summary>
internal static void RegisterSpawnedEnemy(
TcpSession session, ulong uid, uint enemyId, Vec3 position, ulong playerUnit, float facing)
{
var runtime = GetLocalEnemyRuntimeState(session);
bool start;
lock (runtime.SyncRoot)
{
runtime.Enemies[uid] = new LocalEnemyRecord
{
Uid = uid,
EnemyId = enemyId,
Position = position,
AiPosition = position,
HomePosition = position,
Facing = facing,
PlayerUnit = playerUnit,
ServerSpawned = true,
AttackAiEnabled = true,
AttackAiActionAt = Environment.TickCount64 + 1500,
FirstSeenTicks = Environment.TickCount64,
};
start = !runtime.AttackAiWorkerStarted;
if (start) runtime.AttackAiWorkerStarted = true;
}
session.Log.Info($"[ENEMY] tracked server-spawned agent uid={uid} template={enemyId} hp={LocalEnemyMaxHp:F0} playerUnit={playerUnit} facing={facing:F1}");
if (start) _ = RunEnemyAttackAiAsync(session);
// 警戒状态机:先注册成 Idle(1),警戒条**不显示**,等玩家走近由周期刷新推进到 Fight(4)。
// 这里是 void 方法,只能 fire-and-forget;Prime 内部自己 try/catch。
_ = PrimeLocalEnemyWarningAsync(session, uid);
// ⛑⛑ 血条是另一条路(2026-09-26 02:1x):警戒状态机动不了它,血条只看
// UnitStateConfig.HideHp。spawn 时挂上 ⇒ 收起,第一次命中由 Reveal 撤掉。
// 见 GameRouter.EnemyHpBar.cs 类注释里的客户端判据。
_ = HideLocalEnemyHpBarAsync(session, uid);
}
/// <summary>
/// Hostile activation: lock the player, give the fight edict, keep the weapon and
/// raise the detect flag. All four are needed — the behaviour tree only leaves idle
/// once it has a target AND the fight edict AND a detection.
///
/// Deliberately NOT re-sent on every hit. LY-3.3 proved that re-sending these four
/// packets per hit resets the client's enemy action layer, which cut every stagger
/// short: firearms survived it (slow enough to recover between shots) while melee
/// degenerated into "first swing animates, the rest twitch". Engagement is owned by
/// spawn plus the one delayed re-send below.
/// </summary>
internal static async Task EngageSpawnedEnemyAsync(TcpSession session, ulong uid, ulong playerUnit)
{
if (playerUnit == 0)
return;
try
{
await session.NotifyAsync(MethodId.SyncUnitLockTarget,
UxSerializer.Serialize(new SceneMethods.SyncUnitLockTarget { entityId = uid, targetId = playerUnit }),
CancellationToken.None);
await session.NotifyAsync(MethodId.SyncEnemyFightEdict,
UxSerializer.Serialize(new SceneMethods.SyncEnemyFightEdict { uid = uid, isGive = true }),
CancellationToken.None);
await session.NotifyAsync(MethodId.SyncEnemyDisarmState,
UxSerializer.Serialize(new SceneMethods.SyncEnemyDisarmState { id = uid, isDisarmed = false }),
CancellationToken.None);
// Combat behaviour-tree switch. LockTarget + FightEdict alone are not enough to
// make an enemy act: the native side flips BaseUnit.set_IsBattleAiS here, and that
// is what moves the unit off its idle/ambient tree onto CombatBehaviorTree. The
// donor build sends it as the third of its four engagement packets; this port used
// to omit it, which is the most likely reason spawned enemies never attacked.
await session.NotifyAsync(MethodId.SyncBattleAiS,
UxSerializer.Serialize(new SceneMethods.SyncBattleAiS { agentId = uid, value = true }),
CancellationToken.None);
await session.NotifyAsync(MethodId.SyncEnemyDetect,
UxSerializer.Serialize(new GameMethods.EnemyDetectData4229938 { Id = uid, Stealth = 0 }),
CancellationToken.None);
// Alertness state — the sixth engagement packet and the one that was missing.
// Everything above arms the behaviour tree, but the unit stays at
// EnemyDetectState.None until this lands; EnemyHUDCtrl keys the STEALTH ALERTNESS
// METER on unitDataSet.detectionState (EnemyHUDCtrl.lua:85 -> OnShowDetectionEffect),
// so a spawned enemy fought, took damage and died with no alertness meter anywhere.
// Fight(4) is the slot that maps onto Behavior_Battle.
// ⛑⛑ 2026-09-26:警戒状态机接管之后,这里<b>不再</b>发 Fight(4),改由
// GameRouter.EnemyWarning.cs 从 Idle(1) 起步,玩家走近才推进到 Fight(4)。
// ⛑⛑⛑ 订正(02:1x):detectionState 只管<b>警戒条</b>,<b>管不了血条</b> ——
// 血条看的是 cfg.ShowHPBar + UnitStateConfig.HideHp,见 GameRouter.EnemyHpBar.cs。
// 上面五件套(LockTarget / FightEdict / DisarmState / BattleAiS / SyncEnemyDetect)
// 一个都不动:它们管的是行为树(敌人会不会打人),跟 HUD 无关。
// 关掉 gameplay.combat.enemies.warningStateMachine 即回到这里发 Fight 的旧行为。
if (!EnemyHit.WarningStateMachine)
{
await session.NotifyAsync(MethodId.SyncEnemyDetectStatus,
UxSerializer.Serialize(new GameMethods.SyncEnemyDetectStatusData4229938
{
Id = uid,
StealthValue = 0f,
State = LocalEnemyDetectStateFight,
}), CancellationToken.None);
}
session.Log.Info($"[ENEMY] engage uid={uid} -> playerUnit={playerUnit} (lock+edict+battleAiS+detect" +
(EnemyHit.WarningStateMachine ? ", detectStatus=deferred to warning FSM)" : "+detectStatus=Fight)"));
}
catch (Exception ex)
{
session.Log.Info($"[ENEMY] engage skip uid={uid} ({ex.Message})");
}
}
/// <summary>Re-sends the engagement batch 1.5s after spawn: the first batch can be
/// consumed before the client finishes initialising the unit.</summary>
internal static async Task ReEngageSpawnedEnemyAsync(TcpSession session, ulong uid)
{
try
{
await Task.Delay(1500);
var runtime = GetLocalEnemyRuntimeState(session);
ulong playerUnit;
lock (runtime.SyncRoot)
{
if (!runtime.Enemies.TryGetValue(uid, out var record) || record is null)
return; // already defeated / removed
playerUnit = record.PlayerUnit;
}
if (playerUnit == 0)
return;
await EngageSpawnedEnemyAsync(session, uid, playerUnit);
session.Log.Info($"[ENEMY] re-engage uid={uid} -> playerUnit={playerUnit}");
}
catch (Exception ex)
{
session.Log.Info($"[ENEMY] re-engage skip uid={uid} ({ex.Message})");
}
}
// ------------------------------------------------------------------
// Hit intake.
// ------------------------------------------------------------------
/// <summary>
/// Damages a client-spawned enemy named by a hit report and drives its reaction.
///
/// Called from OnSkillHit BEFORE the weapon-sandbox validation so a local enemy still
/// takes damage when the skill is not in the sandbox's allow-list — the same ordering
/// rationale the LY-3.3 reference server used, and the reason its enemies stayed
/// defeatable while its weapon rules changed.
/// </summary>
internal async Task TrackLocalEnemyHitAsync(RpcContext ctx, SceneMethods.SkillHitData hit)
{
if (!LocalEnemyCombatEnabled())
return;
var uid = hit.HitTarget;
if (uid == 0)
return;
// Two uid spaces share this ledger (see ServerSpawnUidFloor). A server-spawned
// agent is settled only when SpawnAgentAsync registered it — an unregistered uid
// above the floor belongs to some other system. A client-spawned local unit is
// discovered lazily here on its first hit, as before.
var serverSpawned = uid >= ServerSpawnUidFloor;
var state = GetWorldState(ctx);
if (uid == state.ActiveSpiritUnitId || uid == state.WorldEntryControlUnit || uid == Profile.PlayerPid)
return;
var runtime = GetLocalEnemyRuntimeState(ctx.Session);
LocalEnemyRecord record;
bool isNew;
bool dead;
bool shouldStartDeath;
bool brokeStance = false;
float ratio;
lock (runtime.SyncRoot)
{
isNew = !runtime.Enemies.TryGetValue(uid, out var existing);
if (isNew)
{
if (serverSpawned)
return; // spawned by nobody we know — not an agent of ours
if (runtime.Enemies.Count >= LocalEnemyLedgerCap)
return;
existing = new LocalEnemyRecord
{
Uid = uid,
FirstSeenTicks = Environment.TickCount64,
Position = new Vec3(hit.ClientHitPosition.X, hit.ClientHitPosition.Y, hit.ClientHitPosition.Z),
};
runtime.Enemies.Add(uid, existing);
}
record = existing!;
dead = record.Dead;
shouldStartDeath = false;
if (!dead)
{
record.Hits++;
record.Hp = Math.Max(0f, record.Hp - LocalEnemyHitDamage);
record.Position = new Vec3(hit.ClientHitPosition.X, hit.ClientHitPosition.Y, hit.ClientHitPosition.Z);
record.LastSkillId = hit.SkillId;
record.LastHurtEffectId = hit.HurtEffectId;
// Suppresses the attack AI briefly (GameRouter.EnemyAiActions.cs): being shot
// must not make the enemy swing faster.
record.LastHurtFeedbackTicks = Environment.TickCount64;
// Stance drains per hit, independent of HP. Reaching zero BREAKS the enemy: it
// stops swinging for LocalEnemyGroggyMs (checked in RefreshEnemyActionAsync) and
// the pool refills, so the bar is a repeating rhythm rather than a one-way trip.
// A stance break does no damage — HP and stance are separate axes on purpose.
if (record.Poise > 0f)
{
record.Poise = Math.Max(0f, record.Poise - LocalEnemyPoiseDamagePerHit);
if (record.Poise <= 0f)
{
record.GroggyUntilTicks = Environment.TickCount64 + LocalEnemyGroggyMs;
record.GroggyCount++;
brokeStance = true;
// Poise STAYS at 0 for the whole window — it used to be reset to full right
// here, which made the stance bar snap back to 1.0 in the same frame it
// broke and left the client with no zero frame to enter 虚弱 on. The refill
// belongs to the exit path (ExitLocalEnemyGroggyAsync), which is also what
// pushes SyncEnemyDisarmState(false).
}
}
if (record.Hp <= 0f)
{
record.Dead = true;
dead = true;
}
}
if (dead && !record.DeathStarted)
{
record.DeathStarted = true;
shouldStartDeath = true;
}
ratio = Math.Clamp(record.Hp / record.MaxHp, 0f, 1f);
}
if (isNew)
{
ctx.Session.Log.Info($"[LENEMY] tracked new local unit uid={uid} hp={LocalEnemyMaxHp:F0} (first hit by skill={hit.SkillId} at {record.Position.X:F2},{record.Position.Y:F2},{record.Position.Z:F2})");
// A client-spawned unit is created without any server-side HP table, so the bar
// has to be established here, on the first hit — there is no spawn batch of ours
// to hang it on (this is the difference from the donor build, which spawns its
// enemies server-side and can send the baseline up front).
await SendLocalEnemyHpBaselineAsync(ctx, uid, LocalEnemyMaxHp);
}
// DEATH LATCH. Measured in console-latest.log: after the killing blow the client
// keeps reporting hits for another ~5s (40+ packets at ~10/s in the 22:26 run) and
// every one of them re-enters the hurt pipeline, which restarts whatever pose the
// death sequence just started. Only the lethal hit itself is allowed through.
if (dead && !shouldStartDeath)
return;
// SyncUnitHurtEffect is fed for EVERY tracked unit, server-spawned ones included.
// It used to be withheld from server-spawned agents on the theory that native
// already reacts to them; that theory was wrong. 68349329 is LuaAndCSharp and is
// the packet the client's own hurt pipeline is driven from on this build, and it is
// what the LY-3.3 reference server echoes back on every hit (see its
// AskPlayHurtEffect handler). Withholding it is the most likely reason both the
// flinch and the death pose were missing. The fastpatch no longer overrides it, so
// nothing stacks a second reaction.
// 受击三件套全部可在 private-server.json 里单独关(gameplay.combat.enemies.*):
// poiseDrainPerHit / pushStaggerAction / pushHurtEffect。
bool sendHurtEffect = EnemyHit.PushHurtEffect;
var tag = record.ServerSpawned ? "[ENEMY]" : "[LENEMY]";
// SyncUnitAttacked FIRST, on every hit including the lethal one. This is the packet
// the client's native combat code actually consumes; HP, stagger and the death beat
// are all downstream of it. Without it the client knows the unit's bar moved but
// never puts the unit into its hurt state, so nothing reacts and nothing dies on
// screen — exactly the "shot it ten times, it just vanished" report.
// ⛑⛑ 23:27 行为:致死击同样带满额架势伤害(不做 poiseDrainOverride)。
// 00:2x 那两轮为了让倒地播出来,给致死击传了 0 并在死亡前清虚弱,结果死亡动画
// 确实出来了,但玩家继续攻击时会把它顶掉(用户原话「死亡动画又会被我的普通攻击打断」)。
// ⇒ 23:27 的组合(每击都打满架势,包括致死击)实测是受击 + 死亡都正常的那一版。
// 血条出场:第一次命中撤掉 HideHp(GameRouter.EnemyHpBar.cs)。
// 之后 HpBarHidden 已置 false,这里是 no-op,不会每一下都刷包。
await RevealLocalEnemyHpBarAsync(ctx.Session, uid);
await SendLocalEnemyAttackedAsync(ctx, uid, hit, LocalEnemyHitDamage, record.ServerSpawned);
// 破防出场:SyncEnemyDisarmState(true) + 架势条 0。放在伤害包之后,顺序与捐赠端一致
// (先掉血/掉架势,再进虚弱)。客户端的起身报告 / 超时会关掉这个窗口
// (GameRouter.EnemyStiff.cs)——在接回执之前,这个窗口永远关不掉,敌人就"卡住"了。
// 致死那一击不再开虚弱窗口:单位马上要走死亡序列,开窗口只会让周期重推的 67149
// 和倒地动作 88300 抢同一个动作槽(死亡序列开头也有一次保险清理)。
if (brokeStance && !dead)
await EnterLocalEnemyGroggyAsync(ctx.Session, uid, "poise-break");
if (dead)
{
if (!shouldStartDeath)
return;
ctx.Session.Log.Info($"{tag} killed uid={uid} hits={record.Hits} deadlySkill={hit.SkillId} -> down/die/drop/despawn sequence");
if (sendHurtEffect)
await SendLocalEnemyHurtEffectAsync(ctx, uid, hit);
await SendLocalEnemyHpAsync(ctx, uid, 0f);
_ = LocalEnemyDeathSequenceAsync(ctx, uid, hit.SkillId, record.Position);
return;
}
ctx.Session.Log.Info($"{tag} hit uid={uid} hits={record.Hits} dmg={LocalEnemyHitDamage:F0} hp={record.Hp:F0}/{record.MaxHp:F0} ({ratio:P0}) " +
$"stance={record.Poise:F0}/{LocalEnemyMaxPoise:F0}{(brokeStance ? " **BREAK**" : "")} skill={hit.SkillId} hurtEffect={hit.HurtEffectId}");
// Stagger first: it is the reaction the player notices, and the one the client's
// BattleManager.HasHitState guard can drop. The VFX+HP follow immediately.
// NOTE (2026-09-23): off by default, see LocalEnemyPushStaggerAction — the client already
// plays its own hit reaction from SyncUnitAttacked + SyncUnitHurtEffect, and this one-shot
// action has no exit packet, which is what parked enemies inside their stagger pose.
if (LocalEnemyPushStaggerAction && EnemyHit.PushStaggerAction)
_ = SendLocalEnemyStaggerAsync(ctx, uid);
if (sendHurtEffect)
await SendLocalEnemyHurtEffectAsync(ctx, uid, hit);
await SendLocalEnemyHpAsync(ctx, uid, record.Hp);
}
// ------------------------------------------------------------------
// Outgoing reactions.
// ------------------------------------------------------------------
/// <summary>Authoritative hit notification: SyncUnitAttacked(68299892).
///
/// This is the one hit packet the client's NATIVE combat code handles (CSharpOnly, no Lua
/// reader), which is why it only ever does anything for a unit the native side can see —
/// i.e. a server-spawned agent. It is still sent for client-spawned local units because a
/// dropped packet costs nothing and the shape is now known to be correct.
///
/// ⛑⛑ PoiseBasicValue 填的是 <b>EnemyCombatSettings.PoiseDrainPerHit</b>,和服务端自己的
/// 架势账本是同一个数,两边同步破防。
/// 历史:这里填过 damage(100),客户端架势池就是 100 ⇒ 每一下都打空 ⇒ 每一下都进虚弱/倒地,
/// 而当时<b>没有退出通道</b>(没有 ReportUnitStandUp 回执、没有 SyncEnemyDisarmState(false)、
/// 没有超时兜底),敌人就永久卡在破防里 —— 那正是 2026-09-23 上午"受打击反馈正常但打不死"
/// 的完整解释。14:4x 把它改成 0(donor 值)解决了卡住,但也把反馈改没了;现在退出通道齐了,
/// 这个值可以按手感自由调:100 = 每下破防(反馈最强)/ 34 = 三下破防 / 0 = 不破防。
/// </summary>
/// 2026-09-24 00:2x 曾加过「致死那一击 PoiseBasicValue 传 0」(poiseDrainOverride),
/// 01:2x 已按用户要求撤掉:它让倒地动画播出来了,但玩家的后续攻击会把倒地顶掉。
/// 23:27 实测正常的一版就是**不加这个特例**。`poiseDrainOverride` 参数保留但现在没有调用点传值,
/// 将来若再要单独控制致死击的架势,改调用点即可。</summary>
/// <summary>Resolve the attacker <b>unit id</b> for a hit.
///
/// ⛑⛑⛑ 2026-09-26(伤害数字攻坚·离线取证结论):<b>回退值不能用 <c>Profile.PlayerPid</c></b>。
/// 客户端画飘字的唯一入口是 <c>LX6.Utils.GuiUtils.HurtFloat</c>(RVA 0x14824610,
/// 全 .text 只有一处调用点:SyncUnitAttacked 消费函数 0x1275F320 内的 0x1276261B),
/// 它在调用 <c>ShowHurtMsg</c> 之前判一条:
/// <code>
/// 0x148246CC test r12,r12 / call get_IsMe() // unit
/// 0x148246E0 test r15,r15 / call get_IsMe() // attacker
/// 0x148246F7 test rbx,rbx / call get_IsMe() // realAttacker
/// 0x1482470E je 0x14824744 // 三者全非 IsMe -> 只打 Warn(HurtFloatText=371) 后返回
/// 0x1482472C jmp ShowHurtMsg // 有一方 IsMe -> 画飘字
/// </code>
/// 而 <c>attacker</c> 是 <c>SyncUnitAttacked.attackerId</c> 经
/// <c>GetUnit → GetNpcByPid → Unit_GetByPid</c> 三段解析出来的(0x1275F48D/4AF/882),
/// 三段全落空 ⇒ attacker = null ⇒ 上面那条 IsMe 判据拿不到 true ⇒ <b>不画飘字</b>。
/// <c>Profile.PlayerPid</c> 是 json <c>player.pid</c>(我们 = 100,捐赠端 = 666),
/// 那是<b>账号 pid,不在 unit id 空间里</b>,三段必然全落空。
/// 捐赠端 7k-v0.5 <c>GameRouter.Enemy.cs:400-402</c> 回退的是
/// <c>world?.ActiveSpiritUnitId ?? Profile.InitialUnitId</c>(unit id 空间),所以它能过。
/// 开关 <see cref="EnemyCombatSettings.AttackerIdFallbackToSpiritUnit"/>(默认 true)。</summary>
private static ulong ResolveLocalHitAttackerId(RpcContext ctx, SceneMethods.SkillHitData hit)
{
if (hit.ReleaserId != 0) return hit.ReleaserId;
if (!EnemyHit.AttackerIdFallbackToSpiritUnit) return Profile.PlayerPid;
var st = GetStateIfExists(ctx.Session);
return st is not null && st.ActiveSpiritUnitId != 0 ? st.ActiveSpiritUnitId : Profile.InitialUnitId;
}
private static async Task SendLocalEnemyAttackedAsync(RpcContext ctx, ulong uid, SceneMethods.SkillHitData hit,
float damage, bool serverSpawned,
float? poiseDrainOverride = null)
{
// 和服务端账本扣的架势是同一个数 —— 见 LocalEnemyPoiseDamagePerHit。
float poiseDrain = poiseDrainOverride ?? LocalEnemyPoiseDamagePerHit;
try
{
// 2026-09-26(伤害数字攻坚):对齐 7K GameRouter.Enemy.cs:425-431 —— 模型/occur
// 重建会覆盖 spawn 快照里的属性,每次伤害/HUD 事件前重断言 MaxHp 分母(不重置 HP)。
// 原生飘字(C# 消费 SyncUnitAttacked)需要这个分母;这是与 7K 逐字段 diff 后
// 伤害链上最后一处未对齐的差异。开关 ReassertMaxHpBeforeHit(json 开)。
if (EnemyHit.ReassertMaxHpBeforeHit)
{
await ctx.NotifyAsync(MethodId.SyncUnitAttrs, new SceneMethods.SyncUnitAttrs
{
unitId = uid,
attrs = new Dictionary<uint, float> { [1] = LocalEnemyMaxHp },
});
}
await ctx.NotifyAsync(MethodId.SyncUnitAttacked, new GameMethods.SyncUnitAttackedData4229938
{
Id = uid,
AttackerId = ResolveLocalHitAttackerId(ctx, hit),
HpChange = new GameMethods.DamageData4229938
{
SourceAmount = damage,
FromType = GameMethods.EntityType4229938.Player,
// 攻击者模板 id。捐赠端 7k-v0.5(GameRouter.Enemy.cs:441)填的是真实模板,
// 我们一直是 0。见 EnemyCombatSettings.FillDamageSourceIdentity 的说明:
// 这是对齐,不是已验证的修复。
SourceTemplateId = EnemyHit.FillDamageSourceIdentity
? GetStateIfExists(ctx.Session)?.ActiveSpiritTemplateId ?? Profile.InitialSpiritTemplateId
: 0u,
SkillId = hit.SkillId,
CreationId = 0,
TriggerIndex = unchecked((int)hit.TriggerIndex),
BuffId = 0,
SpecialEffects = GameMethods.DamageEffect4229938.Normal,
ClientHitPosition = new SceneMethods.UxVector3(
hit.ClientHitPosition.X, hit.ClientHitPosition.Y, hit.ClientHitPosition.Z),
ElementType = 0,
// 同上:捐赠端(:446)填 hit.Id,我们填 0。
HitIndex = EnemyHit.FillDamageSourceIdentity ? hit.Id : 0,
// ⛑⛑ 2026-09-24 01:2x 修正:回退到 23:27 = **这里填 poiseDrain(默认 100)**。
// 上一轮(01:1x)我把这里改成 0f、并把 json 的 poiseDrainPerHit 调到 34,
// 那其实退过了头 —— 34/0 是 23:23 之前的版本,不是 23:27。
// 23:27 实测「受击反馈 + 死亡动画都正常」的那一版 = poiseDrainPerHit 100
// + 每击(含致死击)都发满额架势。
//
// ⛑⛑ 2026-09-26 Wave 1(原生管线复活):默认改发**客户端上报的 hit.FirmHurt**
// (捐赠端 7k-v0.5 GameRouter.Enemy.cs:447 的取值来源),开关
// EnemyCombatSettings.RelayFirmHurtAsPoise(默认 true)。实测先行值:
// console-20260926-105902.log 里 10 次命中 firm=0.00 ⇒ 中继 ≈ 发 0 =
// 客户端架势池不动、原生破防不再被每击强制触发(「忽然定住」的锁死源消失)。
// 开关关掉 = 回到上面 23:27 的旧行为(发 poiseDrain)。
PoiseBasicValue = EnemyHit.RelayFirmHurtAsPoise ? hit.FirmHurt : poiseDrain,
HpDecreased = damage,
Amount = damage,
ShieldDecreased = 0f,
// 2026-09-26:对齐 7K(Enemy.cs:450-451)—— 盾字段透传客户端上报值,
// 不再写死 -1/0。
ShieldIndex = hit.ShieldDefendIndex,
ShieldId = hit.ShieldId,
IsHeadShoot = false,
},
});
// ⛑ 诊断字段(2026-09-26 加):firm = 客户端上报的 FirmHurt,即捐赠端
// PoiseBasicValue 的取值来源。**已有实测值**:console-20260926-105902.log
// 10 次命中全部 firm=0.00(stiff=0 也是恒 0)—— 所以 RelayFirmHurtAsPoise
// 开启时 PoiseBasicValue ≈ 0。这行日志继续留着:若 Wave 2 的 spawn 握手让
// 客户端开始报非 0,这里能第一时间看到。
// 2026-09-26(伤害数字攻坚):rel / atk 必须一起打 —— rel=0 就说明走的是回退分支,
// 而回退值落在哪个 id 空间(unit id vs 账号 pid)正是飘字 IsMe 判据的关键。
ctx.Session.Log.Info($"[LENEMY] unitAttacked uid={uid} dmg={damage:F0} skill={hit.SkillId} " +
$"rel={hit.ReleaserId} atk={ResolveLocalHitAttackerId(ctx, hit)} " +
$"serverSpawned={serverSpawned} " +
$"hitIdx={hit.Id} srcTpl={(EnemyHit.FillDamageSourceIdentity ? GetStateIfExists(ctx.Session)?.ActiveSpiritTemplateId ?? Profile.InitialSpiritTemplateId : 0u)} " +
$"firm={hit.FirmHurt:F2} stiff={hit.StiffId} hurt={hit.HurtEffectId} " +
$"pos=({hit.ClientHitPosition.X:F1},{hit.ClientHitPosition.Y:F1},{hit.ClientHitPosition.Z:F1})");
if (PushPoiseProbe)
{
// Stance pool, published alongside every hit. Verified in-game 2026-09-23: the
// client renders this as the enemy's stance bar even though its C# side is an
// empty shell on this build, so the Lua impl is the real consumer — the dump
// having no IDMAP entry is NOT evidence that the client ignores it.
// Ratio is the stance pool, not HP: draining it is what breaks the enemy, and it
// refills after every break so the bar is a rhythm rather than a one-way trip.
var stanceState = GetLocalEnemyRuntimeState(ctx.Session);
float stance = 1f;
bool groggy = false;
lock (stanceState.SyncRoot)
{
if (stanceState.Enemies.TryGetValue(uid, out var probe) && probe is not null)
{
stance = Math.Clamp(probe.Poise / LocalEnemyMaxPoise, 0f, 1f);
groggy = Environment.TickCount64 < probe.GroggyUntilTicks;
}
}
await ctx.NotifyAsync(MethodId.SyncEnemyPoiseRate, new GameMethods.SyncEnemyPoiseRateData4229938
{
Id = uid,
Ratio = stance,
});
ctx.Session.Log.Info($"[ENEMY-POISE] uid={uid} stance={stance:F2} groggy={groggy}");
}
}
catch (Exception ex)
{
ctx.Session.Log.Warn($"[LENEMY] unit-attacked send failed uid={uid} serverSpawned={serverSpawned}: {ex.Message}");
}
}
/// <summary>Hurt VFX on the target, and - since the client fastpatch defines a Lua impl
/// for SyncUnitHurtEffect - the trigger for the actual hit reaction: the fastpatch feeds
/// these fields straight into gCS.BattleManager.LuaTryDoHurtAction, so the client runs its
/// own hurt pipeline on the locally spawned unit instead of the packet falling through to
/// a native handler that cannot see it. `hurtId` is the hit's own HurtEffectId so the
/// client plays the effect the weapon authored; 1 = normal impact.
///
/// HurtEffectFacePos carries the real impact point, not the surface normal: the client
/// reports ClientHitPosNormalDir as (0,0,0) on every shot (decoded byte-for-byte from
/// packets-latest.log), so the normal would have placed the reaction at the world origin.
///
/// 2026-09-26:`HurtImpact` / `HurtLeftOrRightHit` 不再硬编码,改由
/// <see cref="ResolveLocalHurtPresentation"/> 查 <c>HurtEffectCatalog.json</c>。</summary>
private static async Task SendLocalEnemyHurtEffectAsync(RpcContext ctx, ulong uid, SceneMethods.SkillHitData hit)
{
try
{
var presentation = ResolveLocalHurtPresentation(hit.HurtEffectId);
await ctx.NotifyAsync(MethodId.SyncUnitHurtEffect, new GameMethods.SyncUnitHurtEffectData4229938
{
HurterId = uid,
AttackerId = ResolveLocalHitAttackerId(ctx, hit),
// 7K 发的是 presentation.Id。查表未命中时 Resolve 回传原始 hurtEffectId,
// 与旧行为逐字节一致。
HurtId = presentation.Id,
SkillId = hit.SkillId,
HurtEffectFacePos = new SceneMethods.UxVector3(
hit.ClientHitPosition.X, hit.ClientHitPosition.Y, hit.ClientHitPosition.Z),
HurtImpact = presentation.Impact,
HurtLeftOrRightHit = presentation.LeftOrRight,
});
ctx.Session.Log.Info($"[LENEMY] hurtEffect uid={uid} hurt={hit.HurtEffectId} " +
$"-> id={presentation.Id} impact={presentation.Impact} " +
$"side={presentation.LeftOrRight} sound={presentation.SoundId} " +
$"catalog={(EnemyHit.HurtEffectCatalogLookup ? "on" : "off")}");
}
catch (Exception ex)
{
ctx.Session.Log.Warn($"[LENEMY] hurt-effect send failed uid={uid}: {ex.Message}");
}
}
/// <summary>查表关掉时退回的旧常量值(2026-09-26 之前这里写死 1)。</summary>
private const uint LocalEnemyHurtImpactLegacy = 1;
// 2026-09-26 Wave 1:LocalEnemyBulletHurtPresentation(55001201,7K 枪械整行替换的
// 目标行,表里 Impact=1 / LeftOrRight=0 / SoundId=0)随「整行替换」语义一起移除 ——
// 现在的枪械分支是只升不降(保留作者行),不再引用它。行值留在这条注释里备查。
/// <summary>
/// 按 <c>hit.HurtEffectId</c> 解析出这次命中该发的表现参数。
/// 移植自捐赠端 7k-v0.5 <c>GameRouter.Enemy.cs:590 ResolveHurtPresentation</c>。
///
/// 关键差异(<b>有意为之</b>):捐赠端无条件走枪械重定向,我们把它放在
/// <see cref="EnemyCombatSettings.HurtEffectBulletOverride"/> 后面 —— 见那个开关的注释。
/// 2026-09-26 Wave 1 起该分支从「整行替换成 55001201」改成<b>只升不降</b>:
/// 保留作者行,只把 Impact 抬到 ≥ 2。7K 的替换会把 3 行 Impact=4 的作者配置
/// (55004539 / 55004615 / 55004741,还带 SoundId=70400806)打成 Impact=2 / 无声。
///
/// ⛑ 下限夹到 1 是必须的:1841 行里有 306 行 Impact = 0,照发就是"完全没反应",
/// 比原来的硬编码 1 还差。查表未命中同样兜到 1,保证不劣化。
/// </summary>
private static HurtEffectEntry4229938 ResolveLocalHurtPresentation(uint hurtEffectId)
{
if (!EnemyHit.HurtEffectCatalogLookup)
return new HurtEffectEntry4229938(hurtEffectId, LocalEnemyHurtImpactLegacy, 0u, 0u);
// 捐赠端:build 4229938 里 PhysicalAttackType 4/5 的精确 FireArmID 预设。
// 不重定向火箭/爆炸、近战、黑客或未知 ID。
if (EnemyHit.HurtEffectBulletOverride &&
hurtEffectId is 55004538 or 55004539 or >= 55004611 and <= 55004618
or 55004736 or 55004740 or 55004741)
{
// 只升不降(2026-09-26 Wave 1):作者行原样保留,Impact 抬到第一可见档。
// 7K 是整行换成 55001201 —— 见 HurtEffectBulletOverride 的注释。
var authoredGun = HurtEffectCatalog4229938.Find(hurtEffectId);
return new HurtEffectEntry4229938(
hurtEffectId,
Math.Max(2u, authoredGun?.Impact ?? 2u),
authoredGun?.LeftOrRight ?? 0u,
authoredGun?.SoundId ?? 0u);
}
var authored = HurtEffectCatalog4229938.Find(hurtEffectId);
return new HurtEffectEntry4229938(
hurtEffectId,
Math.Max(1u, authored?.Impact ?? 1u),
authored?.LeftOrRight ?? 0u,
authored?.SoundId ?? 0u);
}
/// <summary>
/// HP bar baseline, sent once the first time a unit enters the ledger.
///
/// Two packets and the order is not optional:
/// 1. SyncUnitAttrs with attrs[1] = MaxHp (AttributeNameConfig Id=1 is Max HP). Without
/// this the unit has no MaxHp at all and there is nothing for a bar to be a
/// fraction of.
/// 2. SyncUnitHp with the ABSOLUTE hp, not a ratio. Sending 1.0 for "full" is what made
/// the bar missing: the client reads the value against MaxHp, so 1.0 against a
/// 1000 MaxHp is a sliver, and against a MaxHp of 0 it is nothing at all.
///
/// Unit of measure deliberately matches the ledger (LocalEnemyMaxHp,2026-09-27 起由
/// `gameplay.combat.enemies.maxHp` 驱动,默认 1000 / 现配 1500): the donor
/// build settled on exactly the same numbers, so hit counts stay comparable.
/// </summary>
private static async Task SendLocalEnemyHpBaselineAsync(RpcContext ctx, ulong uid, float maxHp)
{
try
{
await ctx.NotifyAsync(MethodId.SyncUnitAttrs, new SceneMethods.SyncUnitAttrs
{
unitId = uid,
attrs = new Dictionary<uint, float> { [1] = maxHp },
});
await ctx.NotifyAsync(MethodId.SyncUnitHp, new SceneMethods.SyncUnitHp { unitId = uid, hp = maxHp });
ctx.Session.Log.Info($"[LENEMY] hp baseline uid={uid} maxHp={maxHp:F0}");
}
catch (Exception ex)
{
ctx.Session.Log.Warn($"[LENEMY] hp baseline send failed uid={uid}: {ex.Message}");
}
}
/// <summary>
/// Authoritative HP, in absolute units (0 .. MaxHp), NOT a ratio.
///
/// Deliberately the LAST packet of a hit: the client's Lua handler for this one touches
/// unit.ClientData.MaxHp, and a local unit's ClientData is the one field on this path
/// that could be nil. Sending it last means a client-side error there cannot cost the
/// player the visible flinch or the hurt VFX.
/// </summary>
private static async Task SendLocalEnemyHpAsync(RpcContext ctx, ulong uid, float hp)
{
try
{
await ctx.NotifyAsync(MethodId.SyncUnitHp, new SceneMethods.SyncUnitHp { unitId = uid, hp = hp });
}
catch (Exception ex)
{
ctx.Session.Log.Warn($"[LENEMY] hp send failed uid={uid}: {ex.Message}");
}
}
/// <summary>Throttled one-shot stagger. Delayed so the hurt VFX has landed first, and
/// rate-limited per enemy so a fast melee combo cannot cut the flinch short.
///
/// Caveat, established from the client's own dispatch tables: this action packet is
/// handled by the native C# impl of SyncEnemyPlayAction, not by Lua. 68039914 has no
/// midToReader and no midToName entry, and RPCDeserializeBase returns early on a
/// missing reader, so the Lua impl (and any fastpatch on top of it) is never reached.
/// It is now also consumed by Lua: the fastpatch injects the missing midToReader /
/// midToName entries for 68039914 and installs an impl that resolves the unit's
/// action group through ActionManager and plays the action. Expect the client's
/// hit-state guard to swallow none of them (it is deliberately not reproduced).
/// </summary>
private static async Task SendLocalEnemyStaggerAsync(RpcContext ctx, ulong uid)
{
try
{
await Task.Delay(LocalEnemyStiffDelayMs);
var runtime = GetLocalEnemyRuntimeState(ctx.Session);
bool send;
lock (runtime.SyncRoot)
{
if (!runtime.Enemies.TryGetValue(uid, out var record) || record is null)
return;
// 击晕窗口内不推每击硬直:67149 会把 88300 倒地姿势顶掉,敌人看起来就是
// "一直播受击动画、没有击晕表现"。倒地期间本来也不该有受击反馈。
if (record.GroggyUntilTicks > 0)
return;
var now = Environment.TickCount64;
send = now - record.LastStiffTicks >= LocalEnemyStiffCooldownMs;
if (send)
record.LastStiffTicks = now;
}
if (!send)
return;
await ctx.NotifyAsync(MethodId.SyncEnemyPlayAction, new GameMethods.EnemyPlayActionData4229938
{
Uid = uid,
ActionId = LocalEnemyStaggerActionId,
MaxTime = 0f,
});
}
catch (Exception ex)
{
ctx.Session.Log.Warn($"[LENEMY] stagger send failed uid={uid}: {ex.Message}");
}
}
/// <summary>把倒地动作重新顶回尸体身上:SyncEnemyPlayAction(88300)。
///
/// 2026-09-23 实测(uid=35184372188842,近战连招致死):hp 归零后 250ms 服务端推了 88300,
/// 客户端也确实倒下了;但玩家近战连招没停,死后 3.6 s 客户端又上报了一次
/// <c>AskAgentEnterStiff</c>(16:11:59.670)—— 本地的命中反应把倒地动作顶掉,尸体站着等到
/// 4.2 s 后被 SyncClearSpoonEnemies 拆掉,玩家看到的就是"近战杀死敌人只会消失"。
/// 对照组 uid=35184372188839(远程)打完就停手,倒地完整播完。
///
/// 服务端拦不住客户端本地的命中反应,能做的只有把倒地动作顶回去:每次客户端在尸体上
/// 再进一次硬直,就重推一次 88300。节流 1.5 s(LocalEnemyDeathReassertCooldownMs)。</summary>
/// ⛑⛑ 2026-09-23 18:4x 重新打开(上一轮判它为"死通道"是<b>误判</b>,理由见下)。
/// 误判的来路:当时以 Player.log 里 "death pose already playing via playAction, skipping
/// restart" 一次都没出现为据,反推"服务端推的 88300 从来没落到客户端"。那条日志只在
/// 服务端<b>先</b>推过 88300(会写 __DRMK_DeathAct)时才可能打,而那一轮我们已经把
/// LocalEnemyPushDownAction 删掉了 —— 服务端压根没发,当然永远不打。是"没发"不是"没收"。
/// 反证在 Player.log 18:23:12:<c>injected 68039914 reader on …LTConfigWrap ok=true</c>
/// —— fastpatch 已经把 SyncEnemyPlayAction 缺的 midToReader / midToName 补齐,通道是通的。
///
/// 现在它同时也是对抗"受击顶掉死亡动画"的<b>唯一</b>服务端手段:客户端本地的命中反应走
/// native(SyncUnitHurtEffect 在这个 build 上没有 Lua impl,fastpatch 明确把它 left to
/// native),服务端既拦不住也关不掉,只能在事后把倒地动作顶回去。</summary>
private static readonly bool LocalEnemyDeathReassertEnabled = true;
internal static async Task ReassertLocalEnemyDeathAsync(TcpSession session, ulong uid)
{
if (!LocalEnemyDeathReassertEnabled)
return;
var runtime = GetLocalEnemyRuntimeState(session);
var now = Environment.TickCount64;
lock (runtime.SyncRoot)
{
if (!runtime.PendingDeathClear.ContainsKey(uid))
return;
// 死亡后 LocalEnemyDeathQuietMs 内一律不补推。
// 2026-09-23 19:4x 实测:远程杀死敌人时倒地动作<b>放了两次</b>。原因就是这一段 ——
// 致死那一击本身会让客户端在尸体上再报一次 AskAgentEnterStiff(日志里 killed 与
// "stiff reported on a corpse" 常常同一秒),而 fastpatch 的 SyncRemoveNpc hook
// 那一刻刚播过 88300。两份 88300 相隔不到 1 s = 一次动作重启 = 玩家看到倒两次。
// 真正的"被顶掉"只可能发生在玩家继续连招之后,所以前 900 ms 的残留上报直接无视。
if (runtime.DeathEarliest.TryGetValue(uid, out var earliest)
&& now - (earliest - LocalEnemyMinCorpseMs) < LocalEnemyDeathQuietMs)
{
session.Log.Info($"[ENEMY] down pose re-push skipped uid={uid} (corpse still inside the quiet window)");
return;
}
if (runtime.LastDeathReassert.TryGetValue(uid, out var last)
&& now - last < LocalEnemyDeathReassertCooldownMs)
return;
runtime.LastDeathReassert[uid] = now;
}
await SendLocalEnemyDownPoseAsync(session, uid, "client re-stiffed a corpse");
}
/// <summary>把倒地动作 88300 推给一个已经在走死亡流程的单位(<b>不带节流</b>)。
/// 节流由调用方按自己的节奏做:AskAgentEnterStiff 上报走
/// ReassertLocalEnemyDeathAsync,周期扫描由 SweepLocalEnemyDeathClearsAsync 自己做。</summary>
private static async Task SendLocalEnemyDownPoseAsync(TcpSession session, ulong uid, string why)
{
try
{
await session.NotifyAsync(MethodId.SyncEnemyPlayAction,
UxSerializer.Serialize(new GameMethods.EnemyPlayActionData4229938
{
Uid = uid,
ActionId = LocalEnemyDownActionId,
MaxTime = 0f,
}),
CancellationToken.None);
session.Log.Info($"[ENEMY] down pose re-push uid={uid} action={LocalEnemyDownActionId} ({why})");
}
catch (Exception ex)
{
session.Log.Warn($"[ENEMY] down pose re-push failed uid={uid}: {ex.Message}");
}
}
/// <summary>虚弱窗口内把硬直姿势顶回去:SyncEnemyPlayAction(67149)。
///
/// 2026-09-23 17:1x 实测:破防后客户端只是站着(SyncEnemyDisarmState / SyncEnemyPoiseRate 在这个
/// build 里都是空实现),5 s 后照常挥击,玩家读作"打晕没有动画"。一次性动作撑不满 5 s 的窗口,
/// 所以由 MaintainLocalEnemyGroggyAsync 按 LocalEnemyGroggyReassertMs 周期重推,直到窗口关闭
/// (ExitLocalEnemyGroggyAsync 会清掉节流记录)。</summary>
internal static async Task SendLocalEnemyGroggyPoseAsync(TcpSession session, ulong uid)
{
try
{
await session.NotifyAsync(MethodId.SyncEnemyPlayAction,
UxSerializer.Serialize(new GameMethods.EnemyPlayActionData4229938
{
Uid = uid,
ActionId = LocalEnemyGroggyActionId,
MaxTime = LocalEnemyGroggyMs / 1000f,
}),
CancellationToken.None);
session.Log.Info($"[ENEMY] groggy pose re-assert uid={uid} action={LocalEnemyGroggyActionId}");
}
catch (Exception ex)
{
session.Log.Warn($"[ENEMY] groggy pose push failed uid={uid}: {ex.Message}");
}
}
/// <summary>虚弱窗口内客户端又上报一次硬直时的<b>即时</b>补推(节流 LocalEnemyStiffNudgeMs)。
///
/// 为什么需要它:MaintainLocalEnemyGroggyAsync 只在 AI tick 上按 LocalEnemyGroggyReassertMs
/// 周期补推,而"倒地被顶掉"是客户端先知道的 —— 它受击命中后立刻上报 AskAgentEnterStiff。
/// 等周期到点再补,中间那一段敌人就是站着的(玩家读作"打晕没动画 / 只有挨打反应")。
/// 这里收到上报就补一次;与周期补推共用 LastGroggyReassert 节流表,两者不会叠成每帧一次。</summary>
internal static async Task ReassertLocalEnemyGroggyAsync(TcpSession session, ulong uid)
{
if (!LocalEnemyGroggyPoseEnabled)
return;
var runtime = GetLocalEnemyRuntimeState(session);
var now = Environment.TickCount64;
lock (runtime.SyncRoot)
{
if (!runtime.Enemies.TryGetValue(uid, out var record) || record is null)
return;
if (record.GroggyUntilTicks == 0 || now >= record.GroggyUntilTicks)
return;
if (runtime.LastGroggyReassert.TryGetValue(uid, out var last)
&& now - last < LocalEnemyStiffNudgeMs)
return;
runtime.LastGroggyReassert[uid] = now;
}
await SendLocalEnemyGroggyPoseAsync(session, uid);
}
/// <summary>
/// Death sequence for a local unit, in the order the client can actually consume:
/// down pose -> death beat (EnemyDieInfo) -> loot drop -> LogicAgentLeave -> SyncRemoveNpc
/// probe. Each step is separated so one that is dropped client-side does not take the
/// next one with it, and every send is individually guarded.
///
/// The last two steps are the removal half. SyncLogicAgentLeave routes through
/// LogicAgentMgr and does not know LocalUnitMgr entries, so it is a no-op for this kind of
/// unit and is kept only for the case where the target happens to be a real logic agent.
/// SyncRemoveNpc is the step that actually lands: the fastpatch installs the Lua impl the
/// retail build is missing, and that impl runs the destroy cascade. Both are still sent so
/// one test tells them apart in the log.
/// </summary>
private static async Task LocalEnemyDeathSequenceAsync(RpcContext ctx, ulong uid, uint deadlySkillId, Vec3 deathPos)
{
try
{
var runtime = GetLocalEnemyRuntimeState(ctx.Session);
bool serverSpawned;
lock (runtime.SyncRoot)
{
serverSpawned = runtime.Enemies.TryGetValue(uid, out var dying) && dying is not null && dying.ServerSpawned;
// 死在虚弱窗口里时必须立刻把窗口关掉,否则 MaintainLocalEnemyGroggyAsync 会继续
// 周期重推 67149,把这里马上要播的倒地动作顶掉(2026-09-23 17:3x,uid=…836:
// 17:30:08 致死、窗口到 17:30:12,结果只有"直接消失")。不推出场包——对一个
// 已经在走死亡流程的单位推 PoiseRate(1)/DisarmState(false) 没有意义。
if (dying is not null && dying.GroggyUntilTicks != 0)
{
dying.GroggyUntilTicks = 0;
ctx.Session.Log.Info($"[ENEMY] uid={uid} groggy window dropped — unit died inside it");
}
runtime.LastGroggyReassert.Remove(uid);
}
// ① 先让客户端把这个单位搬出战斗管线、进入死亡流程。
// ⛑⛑ 顺序是 2026-09-23 17:4x 用客户端 Player.log 定下来的:fastpatch 的 SyncRemoveNpc
// hook 每一次都成功播了 88300(17:43:51 / 17:44:27 / 17:44:49 / 17:45:07 四次
// "death PlayAction(88300) ok=true"),3 s 后 DestroyAgentUnit 也成功、单位确实 removed。
// 也就是说 hook 从来没坏过 —— 坏的是它挡不住客户端<b>原生的</b>受击反应:
// hook 只能关掉自己注入的那条 Lua hurt 路径(__DRMK_Dead 闸门),而玩家近战连招的
// 受击动作是 C# 原生管线演的("SyncUnitHurtEffect left to native" 那条日志)。
// 能让它停的只有"单位已经死了"这个状态,而建立这个状态的包是 SyncEnemyPrepareDie ——
// 之前它排在 SyncRemoveNpc 后面约 900 ms,那段时间里的连招照打,倒地一直被顶掉。
try
{
// RpcContext.NotifyAsync 只有两参重载(byte[] / 泛型),没有 CancellationToken 版。
await ctx.NotifyAsync(MethodId.SyncEnemyFightEdict,
UxSerializer.Serialize(new SceneMethods.SyncEnemyFightEdict { uid = uid, isGive = false }));
// 警戒状态机:发 None(0) 收起血条 + 清掉账本里的警戒字段。
// 等价于原来那句直接发 None,只是现在连账本一起清(GameRouter.EnemyWarning.cs)。
await ClearLocalEnemyWarningAsync(ctx.Session, uid);
// ②′ 2026-09-24 01:1x 回退:这段是 00:2x 加的(死亡前先解除虚弱再走死亡流程),
// 同一批改动一起撤掉 —— 用户对这批改动的评价是"死亡动画被我的攻击打断"。
// 23:27 的行为就是死亡序列不额外干预客户端的破防/虚弱状态,只靠
// DeathReassert + DeathQuietMs + MinCorpseMs 挡住死后继续推的受击包。
}
catch (Exception ex)
{
ctx.Session.Log.Warn($"[LENEMY] death disengage failed uid={uid}: {ex.Message}");
}
await ctx.NotifyAsync(MethodId.SyncEnemyPrepareDie, new GameMethods.SyncEnemyPrepareDieArgs4229938
{
Id = uid,
DieInfo = new GameMethods.EnemyDieInfo4229938
{
HasDieAnimation = true,
HasDieEffect = true,
DieEffectId = 0,
DelayDestroyDistance = LocalEnemyDelayDestroyDistance,
LastHitHurtEffect = 0,
DeadlySkillId = deadlySkillId,
Killer = Profile.PlayerPid,
HasPlayedDeathSkill = false,
WeaponDropDestructibleId = 0,
DieType = GameMethods.DieType4229938.Damage,
WeaponId = 0,
},
});
ctx.Session.Log.Info($"[LENEMY] uid={uid} prepareDie sent (DieType=Damage)");
// ③′ 死亡标记:把单位打进 UnitStateConfig.DeadS(2)。
// 捐赠端 7k-v0.5(GameRouter.Enemy.cs:496-506)在 prepareDie + SyncUnitHp(0) 之后必发这一包,
// 注释原文:"HP alone only changes the client data/UI and does not enter
// UnitDeathBehaviorModule."
// ⇒ 只把血打到 0 是不够的:客户端的单位还是"活的",它原生的受击管线照常跑,
// 玩家后续的每一次命中都能重新抢到动作槽,把刚播起来的倒地/死亡动作顶掉
// (用户原话「敌人死亡不会自动无视我们的攻击 / 死亡动画会被普通攻击打断」)。
// 服务端这边早有闸门(dead && !shouldStartDeath 直接 return,一个包都不发),
// 但那挡的是**我们**发的包,挡不住客户端自己演 —— 能让它停手的只有
// "这个单位已经是 DeadS"这个状态本身。
// 可关:gameplay.combat.enemies.pushDeadUnitState(false = 回到 23:27 既有行为)。
if (EnemyHit.PushDeadUnitState)
{
await ctx.NotifyAsync(MethodId.SyncAddUnitState, UxSerializer.Serialize(
new GameMethods.SyncAddUnitStateArgs4229938
{
Id = uid,
State = LocalEnemyDeadUnitState,
EffectFreezeState = 0u,
}));
ctx.Session.Log.Info($"[LENEMY] uid={uid} addUnitState sent (DeadS={LocalEnemyDeadUnitState})");
}
// Register the corpse for teardown. The client normally answers with
// ReportEnemyPrepareFinish / ReportAgentDiePerfEnd, which completes the clear
// immediately; the deadline is the fallback so a dropped report cannot leave an
// immortal corpse behind (GameRouter.EnemyStiff.cs).
lock (runtime.SyncRoot)
{
runtime.PendingDeathClear[uid] = Environment.TickCount64 + LocalEnemyDeathClearGraceMs;
// 尸体最短存活时间:客户端的死亡报告来得再早,也不许在这之前拆掉它。
runtime.DeathEarliest[uid] = Environment.TickCount64 + LocalEnemyMinCorpseMs;
}
// ② 死亡动作的播放点 + 尸体销毁定时器。hook 里 PlayAction(88300) 之后 3 s DestroyAgentUnit。
// 服务端不再自己推 88300(LocalEnemyPushDownAction = false):hook 的注释明写
// "the restart is what makes the corpse snap upright before it drops" —— 两个 88300
// 相隔几百毫秒就是一次动作重启,倒地被自己打断。
await Task.Delay(LocalEnemyRemoveNpcDelayMs);
await ctx.NotifyAsync(MethodId.SyncRemoveNpc, new GameMethods.SyncRemoveNpcData4229938
{
NpcId = unchecked((int)uid),
TaskId = 0,
EnemyId = uid,
});
ctx.Session.Log.Info($"[ENEMY] uid={uid} removeNpc sent (death-beat trigger, serverSpawned={serverSpawned})");
// SyncRemoveNpc 已经在序列开头发过(见那里关于"命中闸门"的说明),这里不再发。
// 剩余等待按 DropDelay 从头算:hook 的销毁定时器是 3 s,3.5 s 兜底拆尸体刚好在其后。
await Task.Delay(LocalEnemyDropDelayMs);
await ctx.NotifyAsync(MethodId.SyncEnemyEndItemDrop, new GameMethods.EnemyEndItemDropData4229938
{
EnemyInstanceId = uid,
BindItemsIndex = -1,
ItemRotation = new SceneMethods.UxVector3(),
Position = new SceneMethods.UxVector3(deathPos.X, deathPos.Y, deathPos.Z),
});
// ★ T1-17:死亡后卸载战斗模块。必须等死亡特效播完(3 s 起 / 5.3 s 完)才能发,
// 所以排在 prepareDie + DropDelay(3500) 之后再等 LocalEnemyDetachBattleUnitDelayMs。
// ⛑ 只有这条「服务端驱动的完整死亡序列」发;客户端自己上报的那条路径
//(AskEnemyStartFall,本文件另一处)**故意不发** —— 那里在回执之后还要占住 handler,
// 多等几秒会把该 session 的后续包排队,收益不抵风险。
if (LocalEnemyDetachBattleUnitDelayMs > 0)
{
await Task.Delay(LocalEnemyDetachBattleUnitDelayMs);
await ctx.NotifyAsync(MethodId.SyncDetachBattleUnit, new SceneMethods.SyncDetachBattleUnit { id = uid });
ctx.Session.Log.Info($"[LENEMY] uid={uid} detachBattleUnit sent");
}
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(uid));
ctx.Session.Log.Info($"[LENEMY] uid={uid} endItemDrop+logicAgentLeave sent");
// (SyncRemoveNpc 已经统一在上面发过,这里不再按 serverSpawned 分流。)
// THE ACTUAL REMOVAL. SyncEnemyPrepareDie only asked the client to play the death;
// SyncClearSpoonEnemies is what tears the unit down. Without it the corpse keeps
// standing with an empty bar and every later hit on it is ignored (its ledger entry was
// dropped here), which is exactly the "it came back and I cannot kill it" report.
// Also drops the ledger entry — idempotent, see CompleteLocalEnemyDeathClearAsync.
await CompleteLocalEnemyDeathClearAsync(ctx.Session, uid, "death-sequence");
ctx.Session.Log.Info($"[ENEMY] uid={uid} death sequence complete (clearSpoonEnemies{(serverSpawned ? "" : "+removeNpc")})");
}
catch (Exception ex)
{
ctx.Session.Log.Warn($"[LENEMY] death sequence failed uid={uid}: {ex.Message}");
}
}
// ------------------------------------------------------------------
// Client-reported combat C2S. These are accepted for observability: nothing here
// changes server state except the fall handshake, and each one logs what the client
// itself believes happened. All arguments are read defensively.
// ------------------------------------------------------------------
/// <summary>Defensive read of the single-u64 unit reference used by the fall handshake.
/// These three methods carry one ulong argument (verified in-game on this build), so the
/// read is a bare u64 rather than a guessed struct; a failure yields 0 and the caller
/// simply does not answer.</summary>
private static ulong TryReadUnitId(UxRpcMessage msg)
{
try
{
return msg.GetArgs<GameMethods.EnemyIdData4229938>().EnemyId;
}
catch
{
return 0;
}
}
/// <summary>
/// Raw body of a report whose wire shape is not verifiable on this build. Used instead of
/// parsing: guessing a struct here would either throw or silently mis-read, whereas the
/// hex dump in the log is itself the evidence needed to define the contract later.
/// </summary>
private static string RawBody(UxRpcMessage msg)
{
var body = msg.Body;
if (body.Length == 0)
return "<empty>";
var shown = Math.Min(body.Length, 64);
return $"{body.Length}b {Convert.ToHexString(body, 0, shown)}" + (body.Length > shown ? "..." : "");
}
/// <summary>
/// Hurt-effect feedback. Recorded raw and NOT re-broadcast: the server already sends the
/// hurt effect from the authoritative hit, so echoing this one would double the VFX on
/// every melee swing. Whether the client emits this for a local unit at all is exactly
/// what the log line answers.
///
/// Registered for BOTH packet kinds and answered with an empty OK on invoke. The older
/// ANANAS server returned SendEmptySuccessReturn for every one of these combat reports,
/// which means the client may send them as invokes expecting a reply; a notify-only
/// registration would leave that callback outstanding.</summary>
[Handler(MethodId.AskPlayHurtEffect)]
private async Task LocalEnemyHurtEffectReport(Connection conn, UxRpcMessage msg)
{
// LY-3.3 parity: the client reports the hurt effect it expects to see on the
// target and the server echoes it back as SyncUnitHurtEffect. It was a log-only
// stub here, which dropped the one round trip the reference build uses to drive
// the visible hit reaction (GameRouter.Enemy.cs:835 in the LY-3.3 source).
try
{
var args = msg.GetArgs<SceneMethods.SkillHitData>();
if (args.HitTarget != 0)
{
conn.Log.Info($"[LENEMY] AskPlayHurtEffect target={args.HitTarget} hurtEffect={args.HurtEffectId} skill={args.SkillId}");
// 通缉系统挂钩(2026-09-23):玩家打中一个单位 ⇒ 记一次 npc-hit。
// attackerId 传 0(= 按玩家算):这条上报里的 ReleaserId 可能是 pid 而不是
// ActiveSpiritUnitId,拿它去比对会把所有命中都过滤掉。玩家自己挨打的情况由
// RegisterWantedCrimeIncidentAsync 自己按 targetId == playerId 排除。
await RegisterWantedCrimeIncidentAsync(
conn.Session, "npc-hit", args.HitTarget, 0, msg.Context.CancellationToken);
// 2026-09-26:与 SendLocalEnemyHurtEffectAsync 同源,Impact / LeftOrRight 查表。
var reportPresentation = ResolveLocalHurtPresentation(args.HurtEffectId);
await conn.Session.NotifyAsync(MethodId.SyncUnitHurtEffect,
UxSerializer.Serialize(new GameMethods.SyncUnitHurtEffectData4229938
{
HurterId = args.HitTarget,
AttackerId = args.ReleaserId != 0 ? args.ReleaserId : Profile.PlayerPid,
HurtId = reportPresentation.Id,
SkillId = args.SkillId,
HurtEffectFacePos = new SceneMethods.UxVector3(
args.ClientHitPosNormalDir.X,
args.ClientHitPosNormalDir.Y,
args.ClientHitPosNormalDir.Z),
HurtImpact = reportPresentation.Impact,
HurtLeftOrRightHit = reportPresentation.LeftOrRight,
}), CancellationToken.None);
}
}
catch (Exception ex)
{
conn.Log.Info($"[LENEMY] AskPlayHurtEffect raw (parse failed: {ex.Message}) {RawBody(msg)}");
}
if (msg.IsInvoke)
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>
/// The client's own combat sim announces the killing blow on a unit it owns. When that
/// unit is one we track, the down pose and death beat are played immediately: the client
/// hides the unit if the server stalls here, so answering late is as bad as not answering.
///
/// Registered for BOTH packet kinds: the older ANANAS server answered this handshake with
/// an empty OK, so an invoke form has to be replied to even when the unit is untracked.
/// </summary>
[Handler(MethodId.AskEnemyStartFall)]
private async Task LocalEnemyStartFall(Connection conn, UxRpcMessage msg)
{
var uid = TryReadUnitId(msg);
var isInvoke = msg.IsInvoke;
if (uid == 0)
{
if (isInvoke)
await conn.ReturnEmptyOkAsync(msg);
return;
}
var runtime = GetLocalEnemyRuntimeState(conn.Session);
bool tracked;
bool own;
lock (runtime.SyncRoot)
{
tracked = runtime.Enemies.TryGetValue(uid, out var record) && record is not null;
own = false;
if (tracked)
{
own = !record!.DeathStarted;
record.Dead = true;
record.DeathStarted = true;
}
}
if (!tracked)
{
conn.Log.Info($"[LENEMY] AskEnemyStartFall uid={uid} (not tracked, skipped)");
if (isInvoke)
await conn.ReturnEmptyOkAsync(msg);
return;
}
conn.Log.Info($"[LENEMY] AskEnemyStartFall uid={uid} — client death reported, {(own ? "owning" : "re-answering")} the sequence");
// 通缉系统挂钩(2026-09-23):被我们跟踪的单位死了 ⇒ 记一次 npc-kill。
// attackerId 传 0 —— 这条上报里没有攻击者,RegisterWantedCrimeIncidentAsync 会按玩家算。
await RegisterWantedCrimeIncidentAsync(
conn.Session, "npc-kill", uid, 0, msg.Context.CancellationToken);
await msg.Context.NotifyAsync(MethodId.SyncEnemyPlayAction, new GameMethods.EnemyPlayActionData4229938
{
Uid = uid,
ActionId = LocalEnemyDownActionId,
MaxTime = 0f,
});
await Task.Delay(LocalEnemyDeathBeatDelayMs);
await msg.Context.NotifyAsync(MethodId.SyncEnemyPrepareDie, new GameMethods.SyncEnemyPrepareDieArgs4229938
{
Id = uid,
DieInfo = new GameMethods.EnemyDieInfo4229938
{
HasDieAnimation = true,
HasDieEffect = true,
DieEffectId = 0,
DelayDestroyDistance = LocalEnemyDelayDestroyDistance,
LastHitHurtEffect = 0,
DeadlySkillId = 0,
Killer = Profile.PlayerPid,
HasPlayedDeathSkill = false,
WeaponDropDestructibleId = 0,
DieType = GameMethods.DieType4229938.Damage,
WeaponId = 0,
},
});
// 同样登记尸体待拆:这条路径是客户端自己报的死亡,没有走 LocalEnemyDeathSequenceAsync,
// 不登记就没人发 SyncClearSpoonEnemies("尸体还在"的同一条根因)。
lock (runtime.SyncRoot)
runtime.PendingDeathClear[uid] = Environment.TickCount64 + LocalEnemyDeathClearGraceMs;
if (isInvoke)
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>
/// The client finished the fall. Drop the loot and remove the unit, then forget it.
/// Both packet kinds, empty OK on invoke.
/// </summary>
[Handler(MethodId.AskEnemyEndFall)]
private async Task LocalEnemyEndFall(Connection conn, UxRpcMessage msg)
{
var uid = TryReadUnitId(msg);
var isInvoke = msg.IsInvoke;
if (uid == 0)
{
if (isInvoke)
await conn.ReturnEmptyOkAsync(msg);
return;
}
var runtime = GetLocalEnemyRuntimeState(conn.Session);
bool tracked;
bool serverSpawned = false;
var deathPos = new Vec3();
lock (runtime.SyncRoot)
{
tracked = runtime.Enemies.TryGetValue(uid, out var record) && record is not null;
if (tracked)
{
serverSpawned = record!.ServerSpawned;
deathPos = record.Position;
runtime.Enemies.Remove(uid);
}
}
if (!tracked)
{
if (isInvoke)
await conn.ReturnEmptyOkAsync(msg);
return;
}
var endTag = serverSpawned ? "[ENEMY]" : "[LENEMY]";
conn.Log.Info($"{endTag} AskEnemyEndFall uid={uid} — drop + leave{(serverSpawned ? "" : " + removeNpc")}");
await msg.Context.NotifyAsync(MethodId.SyncEnemyEndItemDrop, new GameMethods.EnemyEndItemDropData4229938
{
EnemyInstanceId = uid,
BindItemsIndex = -1,
ItemRotation = new SceneMethods.UxVector3(),
Position = new SceneMethods.UxVector3(deathPos.X, deathPos.Y, deathPos.Z),
});
await msg.Context.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(uid));
// SyncRemoveNpc is the local-unit destroy path (see the death sequence above). A
// server-spawned agent is a logic agent and is already gone via LogicAgentLeave.
if (!serverSpawned)
{
await msg.Context.NotifyAsync(MethodId.SyncRemoveNpc, new GameMethods.SyncRemoveNpcData4229938
{
NpcId = unchecked((int)uid),
TaskId = 0,
EnemyId = uid,
});
}
if (isInvoke)
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>Fall handshake acknowledgement point — logged, and always acknowledged.
/// Both packet kinds, empty OK on invoke.</summary>
[Handler(MethodId.AskEnemyFallGround)]
private async Task LocalEnemyFallGround(Connection conn, UxRpcMessage msg)
{
conn.Log.Info($"[LENEMY] AskEnemyFallGround uid={TryReadUnitId(msg)}");
if (msg.IsInvoke)
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>
/// The client reports a unit casting a skill. Direct evidence that a unit's client-side
/// AI is acting, so it is recorded raw (the wire shape is CSharpOnly and unverifiable on
/// this build) rather than parsed — a locally generated enemy that fights back shows up
/// in the console this way. Both packet kinds, empty OK on invoke.
/// </summary>
[Handler(MethodId.AskEnemyUseClientSkill)]
private async Task LocalEnemyUseClientSkill(Connection conn, UxRpcMessage msg)
{
conn.Log.Info($"[LENEMY] client-AI cast raw {RawBody(msg)}");
if (msg.IsInvoke)
await conn.ReturnEmptyOkAsync(msg);
}
}