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;
///
/// 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.
///
internal sealed partial class GameRouter
{
private const string LocalEnemyRuntimeKey = "client4229938-local-enemies";
///
/// 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.
///
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 与「地图敌人首次受击登记」两条路共用的唯一血量来源,
// 以前调一次要改常量 + 重编译,现在改 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;
/// 每次命中扣多少架势 —— 不再写死,改 private-server.json 的
/// gameplay.combat.enemies.poiseDrainPerHit 即可(100 = 每下破防 / 34 = 三下破防 /
/// 0 = 只走服务端模型)。服务端账本和发给客户端的 SyncUnitAttacked.PoiseBasicValue
/// 用的是同一个数,所以两边永远同步破防。
///
/// 原来是常量 34f(kimika 捐赠端值)。2026-09-23 夜按用户要求改回"每一下都有明显反应"的
/// 手感,默认值调到 100(= 一击伤害,也就是 11:3x 那个状态的值)。那时候它会导致敌人卡在
/// 破防里出不来("打不死"),但那是因为当时没有退出通道;现在 ReportUnitStandUp
/// 回执 + SyncEnemyDisarmState(false) + 5 s 超时兜底都在了,可以安全地开回来。
private static float LocalEnemyPoiseDamagePerHit
=> PrivateServerConfigStore.Current.Gameplay.Combat.Enemies.PoiseDrainPerHit;
/// 敌人受击手感的三个旋钮(见 )。
private static EnemyCombatSettings EnemyHit
=> PrivateServerConfigStore.Current.Gameplay.Combat.Enemies;
private const long LocalEnemyGroggyMs = 5000;
/// 虚弱窗口内重新顶一次硬直动作的间隔(ms)。
///
/// 2026-09-23 17:1x 实测:破防后敌人只是站着不动,5 s 后照常挥击 —— 玩家读作"没打晕"。
/// 原因:SyncEnemyPlayAction 是一次性动作,67149 播完(不到 1 s)就回站立,
/// 而客户端的虚弱 UI 在这个 build 里是死的(SyncEnemyDisarmState 的 C# 体是空的、
/// SyncEnemyPoiseRate 的 Lua handler 守卫写反)。所以服务端必须自己撑住这个姿势:
/// 窗口内周期性重推,敌人看起来才是"晕着"而不是"站着发呆"。900 ms 略短于一次
/// 900 → 3600 → 1200 → 3000(2026-09-23 19:2x):1200 是"被顶掉后尽快补回来"定的,
/// 实测代价是 5 s 窗口里倒地了三次 —— 88300 是 RemainTime 4 s 的重倒地动作,每次补推都是
/// 一次动作重启(弹起再趴下),玩家读作"连续倒地三次"。3000 让 5 s 窗口最多出现两次倒地:
/// 破防那一次,加一次"真被顶掉之后"的补。
/// 前提仍然是:客户端本地的受击反应走 native(SyncUnitHurtEffect 在这个 build 上没有 Lua
/// impl,fastpatch 明确 left to native),服务端碰不到,只能事后补。
private const long LocalEnemyGroggyReassertMs = 3000;
/// 是否推 SyncEnemyPlayAction(67149) 一次性硬直动作。默认开。
///
/// ⛑⛑ 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)。
private static readonly bool LocalEnemyPushStaggerAction = true;
// 死亡时是否由服务端推 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;
/// 击晕(破防)姿势用的动作 = 88300 倒地,不是 67149。
///
/// ⛑⛑ 2026-09-23 18:0x:原来击晕复用 67149(受击硬直),结果玩家看不出任何区别 ——
/// 67149 是一次性短硬直,破防后敌人只是「僵一下 / 呆站着」,而玩家要的是倒地
/// ("昏迷/击晕"在这个游戏里就是被打趴)。88300 来自 HurtStiffFirearmDieConfig,
/// RemainTime = 4 s,正好覆盖 5 s 虚弱窗口的大半,是唯一已知的倒地动作。
/// 若实测发现敌人看起来像"死了",把它改回 LocalEnemyStaggerActionId 即可,
/// 破防账本(Poise / GroggyUntilTicks / 停手闸门)与这个动作 id 完全解耦。
private const uint LocalEnemyGroggyActionId = LocalEnemyDownActionId;
/// 击晕(虚弱)期间要不要推倒地动作 88300。2026-09-23 19:4x 起默认 false。
///
/// 三条实测把这条路堵死了:
/// * 不推 ⇒ 敌人呆站着(客户端的 SyncEnemyDisarmState / SyncEnemyPoiseRate 在这个 build
/// 里都是空实现,虚弱 UI 起不来)。
/// * 推 88300 ⇒ 它是 RemainTime 4 s 的重倒地动作,玩家在虚弱窗口里照打,本地受击把它顶掉,
/// 服务端补推 = 动作重启 ⇒ 5 s 窗口里"连续倒地三次"。
/// * 推 67149(短硬直)⇒ 和受击动作一模一样,玩家读作"没打晕、只是僵一下"。
/// 用户 2026-09-23 已明确接受"击晕无法倒地、只会呆呆站着",所以关掉,等找到真正的
/// 短眩晕动作 id 再打开。账本(Poise / GroggyUntilTicks / 停手闸门 / DisarmState)与该开关
/// 完全解耦,关掉它只影响"看不看得见姿势"。
private static readonly bool LocalEnemyGroggyPoseEnabled = false;
/// 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.
private const byte LocalEnemyDetectStateNone = 0;
/// 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.
private const byte LocalEnemyDetectStateFight = 4;
/// 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.
private const int LocalEnemyStiffCooldownMs = 700;
/// Delay before the stagger so the hurt VFX (which resets the client's action
/// layer) has already been applied.
private const int LocalEnemyStiffDelayMs = 120;
/// 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.
private const int LocalEnemyDeathActionDelayMs = 250;
///
/// SyncAddUnitState(68026128) 的 state:把单位打进 UnitStateConfig.DeadS。
///
/// ⛑⛑ 是 2,不是 3。UnitStateAtlasConfig.Death = 3 只是个图集 atlas,
/// 发 3 单位不会真的进死亡行为模块(捐赠端 7k-v0.5 源码原注释)。
///
private const uint LocalEnemyDeadUnitState = 2;
/// 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.
private const int LocalEnemyDeathBeatDelayMs = 650;
/// 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.
private const float LocalEnemyDelayDestroyDistance = 900f;
/// 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 Wave 4:关掉(补偿 UI 清理)。W1 poiseDrainPerHit=0 之后服务端
/// 账本不再扣架势 ⇒ 这根条永远满格,是纯噪音;捐赠端 7k-v0.5 从头到尾不发这个包。
/// 想 A/B 就翻回 true。⛑ 用 static readonly 而不是 const:const false 会让编译器把
/// 另一分支判为不可达(CS0162)。
internal static readonly bool PushPoiseProbe = false;
/// 死亡表现播完之后,SyncRemoveNpc 距 SyncEnemyPrepareDie 的间隔。
///
/// 2026-09-23 实测:客户端的死亡动作是由 SyncRemoveNpc 的 Lua hook 播放的(fastpatch
/// LuaFiles#LX6#Manager#ClientGameSwitch.lua),不是由服务端的 SyncEnemyPlayAction:
/// hook 里先 PlayAction(unit, 88300, group),再 3 s 后 DestroyAgentUnit。它播之前还会
/// __DRMK_SetT(__DRMK_Dead, unit, pid, true) 关掉 hurt 路径 —— 这正是近战需要的:
/// 玩家连招不停时,后续命中的本地反应会顶掉倒地动作。
/// 在此之前的死亡序列只对 client-spawned 单位发 SyncRemoveNpc(if (!serverSpawned)),
/// GM 生成的敌人走的是 serverSpawned 分支,一次都没发过 ⇒ 那个 hook 从来没跑过。
/// 1700 → 0(2026-09-23 17:1x):此延迟是相对 SyncEnemyPrepareDie 的,实测 1.7 s 的空档里
/// 玩家近战连招照打,本地 hurt 反应把倒地动作顶掉 —— 尸体一直站着播硬直,最后被拆掉时
/// 玩家看到"突然消失"。0 = 死亡序列一开始就发,hurt 闸门在倒地之前就关上。
/// 想做对照实验就把这个值调回 1700(症状应当复现)。
private const int LocalEnemyRemoveNpcDelayMs = 0;
/// 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 走完。
private const int LocalEnemyDropDelayMs = 3500;
///
/// ★ T1-17(2026-09-25):死亡序列推 SyncDetachBattleUnit(68396133) 前,在
/// SyncEnemyPrepareDie + 之后**再**等多久。
/// 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 即不再发),
/// 但编译干净。
///
private static readonly int LocalEnemyDetachBattleUnitDelayMs = 0;
/// 尸体被客户端再次打进硬直时,重推倒地动作的最小间隔。
/// 1500 → 3000 → 1200(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 的 SyncRemoveNpc 实现),双方就不再互相抵消。
/// ⛑ 19:2x 曾把这里改成 2.0 s 以配合"周期扫描也补推",那个周期扫描 19:4x 已撤掉
/// (它是"远程放两次倒地"的另一半),于是这份节流表重新只服务 AskAgentEnterStiff 上报 ——
/// 近战连招下补得越快,倒地越不容易被吃掉。
/// ⛑ 代价仍是重推 = 动作重启(hook 注释:the restart is what makes the corpse snap upright
/// before it drops),但"弹起再趴下"远好过"站着不动然后消失"。
private const int LocalEnemyDeathReassertCooldownMs = 1200;
/// 死亡后这段时间内不接受任何补推(ms)。见 ReassertLocalEnemyDeathAsync 里
/// 的实测:致死那一击自带的残留 AskAgentEnterStiff 会把 hook 刚播的 88300 重启成"倒两次"。
/// 必须 < hook 的销毁定时器(3 s),而 > 残留上报的延迟(实测 <1 s)。
private const int LocalEnemyDeathQuietMs = 900;
/// 收到客户端 AskAgentEnterStiff 上报后,补推击晕姿势的最小间隔(ms)。
/// 比 LocalEnemyGroggyReassertMs 短:上报是"已经被顶掉了"的即时证据,补得越快越看不出
/// 断档;但不能快到每一下近战都重推(那又变成动作重启,实测 700 ms 就是"连续倒地三次")。
/// 700 → 2000(2026-09-23 19:2x)。
private const int LocalEnemyStiffNudgeMs = 2000;
/// Soft cap so a long session cannot grow the ledger without bound.
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;
/// 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.
public bool ServerSpawned;
public uint EnemyId;
public ulong PlayerUnit;
// ---- Poise / groggy (stance) state ----
/// 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.
public float Poise = LocalEnemyMaxPoise;
/// 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.
public long GroggyUntilTicks;
/// True while the client has told us (AskAgentEnterStiff) the unit is in 硬直.
/// Cleared by AskAgentExitStiff / ReportUnitStandUp — see GameRouter.EnemyStiff.cs.
public bool StiffActive;
/// How many times this enemy has been broken. Logging only.
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) ----
///
/// 已经注册过警戒状态机。false = 客户端那边还是 EnemyDetectState.None,
/// HUD(血条 + 挂它上面的警戒条)不会绑 —— 这就是"血条默认不显示"。
///
public bool WarningPrimed;
/// 最近一次下发的 detectionState(0 None / 1 Idle / 2 Suspect / 3 Alert / 4 Fight)。
public byte WarningState;
///
/// 注册后剩余的重试次数。模型/occur 重建会把早期那份 HUD 快照丢掉,
/// 所以刚注册的两拍里即使状态没变也要重发(捐赠端 Warmup=2)。
///
public int WarningWarmupRemaining;
/// 下一次允许重估/重发的 TickCount64。状态不变就在这里直接跳过,避免刷包。
public long WarningRefreshAt;
// ---- 血条显示时机(GameRouter.EnemyHpBar.cs)----
///
/// 服务端给这个单位挂了 UnitStateConfig.HideHp(10266),血条此刻是收起的。
/// 第一次命中时由 RevealLocalEnemyHpBarAsync 撤掉状态并置回 false。
///
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;
/// Spawn facing, kept so the turn command can compute a delta angle.
public float Facing;
///
/// 出生点。踱步的保险绳基准:估算位置离它超过 EnemyAiPatrolLeashMeters 就强制换向,
/// 免得估算误差累积把敌人一路推到玩家脸上。
///
public Vec3 HomePosition;
/// 踱步方向:true = 下一步朝玩家(WalkFront),false = 下一步背对玩家退(WalkBack)。
public bool PatrolForward = true;
/// engagement 状态上次翻转的 TickCount64,用于 EnemyAiEngageHoldMs 防抖。
public long AttackAiEngageChangedAt;
/// 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 per dispatch.
/// Only used to decide "close enough to swing yet".
public Vec3 AiPosition;
}
internal sealed class LocalEnemyRuntimeState
{
public object SyncRoot { get; } = new();
public Dictionary Enemies { get; } = new();
/// 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.
public Dictionary PendingDeathClear { get; } = new();
/// TickCount64 of the last time the down pose was re-pushed onto a corpse.
/// 尸体被玩家连招再次打进硬直时用来节流(见 ReassertLocalEnemyDeathAsync)。
/// 尸体拆掉时随 PendingDeathClear 一起清理。
public Dictionary LastDeathReassert { get; } = new();
/// 尸体最早允许被拆掉的 TickCount64(= 死亡序列开始 + LocalEnemyMinCorpseMs)。
///
/// 2026-09-23 19:2x:客户端会在死亡动画刚起手时就上报 ReportEnemyPrepareFinish /
/// ReportAgentDiePerfEnd,而这两个 handler 原来是收到即拆(SyncClearSpoonEnemies),
/// 于是同一套死亡序列会随机表现成两种结果 —— 上报来得早就"直接消失",来得晚才"正常倒地"。
/// 这正是"死亡动作不一定能加载出来"的来源。加了这个下限之后,客户端的报告只能把清理
/// 提前到下限,不能提前到动画播完之前。
public Dictionary DeathEarliest { get; } = new();
/// TickCount64 of the last time the 硬直 pose was re-pushed onto a 虚弱 enemy.
/// 虚弱窗口有 5 s,而客户端的 SyncEnemyPlayAction 是一次性动作、播完就站起来,
/// 所以窗口内必须周期性重推,敌人看起来才是"被打晕了"而不是"站着发呆"
/// (见 ReassertLocalEnemyGroggyAsync)。虚弱结束时清理。
public Dictionary LastGroggyReassert { get; } = new();
/// 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.
public bool AttackAiWorkerStarted;
///
/// TickCount64 deadlines at which the player's control states get released, queued by
/// the attack AI when it dispatches a cast. See EnemyAiUnlockDelayMsEarly/Late.
///
public List PendingUnlocks { get; } = new();
/// Auto-release on/off. Toggled from the admin panel; on by default.
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.
// ------------------------------------------------------------------
/// Puts a freshly spawned agent into the HP ledger so its hits are settled.
/// Called by GameAdminBridge.SpawnAgentAsync right after the spawn batch.
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);
}
///
/// 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.
///
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:警戒状态机接管之后,这里不再发 Fight(4),改由
// GameRouter.EnemyWarning.cs 从 Idle(1) 起步,玩家走近才推进到 Fight(4)。
// ⛑⛑⛑ 订正(02:1x):detectionState 只管警戒条,管不了血条 ——
// 血条看的是 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})");
}
}
/// Re-sends the engagement batch 1.5s after spawn: the first batch can be
/// consumed before the client finishes initialising the unit.
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.
// ------------------------------------------------------------------
///
/// 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.
///
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.
// ------------------------------------------------------------------
/// 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 填的是 EnemyCombatSettings.PoiseDrainPerHit,和服务端自己的
/// 架势账本是同一个数,两边同步破防。
/// 历史:这里填过 damage(100),客户端架势池就是 100 ⇒ 每一下都打空 ⇒ 每一下都进虚弱/倒地,
/// 而当时没有退出通道(没有 ReportUnitStandUp 回执、没有 SyncEnemyDisarmState(false)、
/// 没有超时兜底),敌人就永久卡在破防里 —— 那正是 2026-09-23 上午"受打击反馈正常但打不死"
/// 的完整解释。14:4x 把它改成 0(donor 值)解决了卡住,但也把反馈改没了;现在退出通道齐了,
/// 这个值可以按手感自由调:100 = 每下破防(反馈最强)/ 34 = 三下破防 / 0 = 不破防。
///
/// 2026-09-24 00:2x 曾加过「致死那一击 PoiseBasicValue 传 0」(poiseDrainOverride),
/// 01:2x 已按用户要求撤掉:它让倒地动画播出来了,但玩家的后续攻击会把倒地顶掉。
/// 23:27 实测正常的一版就是**不加这个特例**。`poiseDrainOverride` 参数保留但现在没有调用点传值,
/// 将来若再要单独控制致死击的架势,改调用点即可。
/// Resolve the attacker unit id for a hit.
///
/// ⛑⛑⛑ 2026-09-26(伤害数字攻坚·离线取证结论):回退值不能用 Profile.PlayerPid。
/// 客户端画飘字的唯一入口是 LX6.Utils.GuiUtils.HurtFloat(RVA 0x14824610,
/// 全 .text 只有一处调用点:SyncUnitAttacked 消费函数 0x1275F320 内的 0x1276261B),
/// 它在调用 ShowHurtMsg 之前判一条:
///
/// 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 -> 画飘字
///
/// 而 attacker 是 SyncUnitAttacked.attackerId 经
/// GetUnit → GetNpcByPid → Unit_GetByPid 三段解析出来的(0x1275F48D/4AF/882),
/// 三段全落空 ⇒ attacker = null ⇒ 上面那条 IsMe 判据拿不到 true ⇒ 不画飘字。
/// Profile.PlayerPid 是 json player.pid(我们 = 100,捐赠端 = 666),
/// 那是账号 pid,不在 unit id 空间里,三段必然全落空。
/// 捐赠端 7k-v0.5 GameRouter.Enemy.cs:400-402 回退的是
/// world?.ActiveSpiritUnitId ?? Profile.InitialUnitId(unit id 空间),所以它能过。
/// 开关 (默认 true)。
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 { [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}");
}
}
/// 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` 不再硬编码,改由
/// 查 HurtEffectCatalog.json。
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}");
}
}
/// 查表关掉时退回的旧常量值(2026-09-26 之前这里写死 1)。
private const uint LocalEnemyHurtImpactLegacy = 1;
// 2026-09-26 Wave 1:LocalEnemyBulletHurtPresentation(55001201,7K 枪械整行替换的
// 目标行,表里 Impact=1 / LeftOrRight=0 / SoundId=0)随「整行替换」语义一起移除 ——
// 现在的枪械分支是只升不降(保留作者行),不再引用它。行值留在这条注释里备查。
///
/// 按 hit.HurtEffectId 解析出这次命中该发的表现参数。
/// 移植自捐赠端 7k-v0.5 GameRouter.Enemy.cs:590 ResolveHurtPresentation。
///
/// 关键差异(有意为之):捐赠端无条件走枪械重定向,我们把它放在
/// 后面 —— 见那个开关的注释。
/// 2026-09-26 Wave 1 起该分支从「整行替换成 55001201」改成只升不降:
/// 保留作者行,只把 Impact 抬到 ≥ 2。7K 的替换会把 3 行 Impact=4 的作者配置
/// (55004539 / 55004615 / 55004741,还带 SoundId=70400806)打成 Impact=2 / 无声。
///
/// ⛑ 下限夹到 1 是必须的:1841 行里有 306 行 Impact = 0,照发就是"完全没反应",
/// 比原来的硬编码 1 还差。查表未命中同样兜到 1,保证不劣化。
///
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);
}
///
/// 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.
///
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 { [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}");
}
}
///
/// 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.
///
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}");
}
}
/// 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).
///
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}");
}
}
/// 把倒地动作重新顶回尸体身上:SyncEnemyPlayAction(88300)。
///
/// 2026-09-23 实测(uid=35184372188842,近战连招致死):hp 归零后 250ms 服务端推了 88300,
/// 客户端也确实倒下了;但玩家近战连招没停,死后 3.6 s 客户端又上报了一次
/// AskAgentEnterStiff(16:11:59.670)—— 本地的命中反应把倒地动作顶掉,尸体站着等到
/// 4.2 s 后被 SyncClearSpoonEnemies 拆掉,玩家看到的就是"近战杀死敌人只会消失"。
/// 对照组 uid=35184372188839(远程)打完就停手,倒地完整播完。
///
/// 服务端拦不住客户端本地的命中反应,能做的只有把倒地动作顶回去:每次客户端在尸体上
/// 再进一次硬直,就重推一次 88300。节流 1.5 s(LocalEnemyDeathReassertCooldownMs)。
/// ⛑⛑ 2026-09-23 18:4x 重新打开(上一轮判它为"死通道"是误判,理由见下)。
/// 误判的来路:当时以 Player.log 里 "death pose already playing via playAction, skipping
/// restart" 一次都没出现为据,反推"服务端推的 88300 从来没落到客户端"。那条日志只在
/// 服务端先推过 88300(会写 __DRMK_DeathAct)时才可能打,而那一轮我们已经把
/// LocalEnemyPushDownAction 删掉了 —— 服务端压根没发,当然永远不打。是"没发"不是"没收"。
/// 反证在 Player.log 18:23:12:injected 68039914 reader on …LTConfigWrap ok=true
/// —— fastpatch 已经把 SyncEnemyPlayAction 缺的 midToReader / midToName 补齐,通道是通的。
///
/// 现在它同时也是对抗"受击顶掉死亡动画"的唯一服务端手段:客户端本地的命中反应走
/// native(SyncUnitHurtEffect 在这个 build 上没有 Lua impl,fastpatch 明确把它 left to
/// native),服务端既拦不住也关不掉,只能在事后把倒地动作顶回去。
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 实测:远程杀死敌人时倒地动作放了两次。原因就是这一段 ——
// 致死那一击本身会让客户端在尸体上再报一次 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");
}
/// 把倒地动作 88300 推给一个已经在走死亡流程的单位(不带节流)。
/// 节流由调用方按自己的节奏做:AskAgentEnterStiff 上报走
/// ReassertLocalEnemyDeathAsync,周期扫描由 SweepLocalEnemyDeathClearsAsync 自己做。
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}");
}
}
/// 虚弱窗口内把硬直姿势顶回去:SyncEnemyPlayAction(67149)。
///
/// 2026-09-23 17:1x 实测:破防后客户端只是站着(SyncEnemyDisarmState / SyncEnemyPoiseRate 在这个
/// build 里都是空实现),5 s 后照常挥击,玩家读作"打晕没有动画"。一次性动作撑不满 5 s 的窗口,
/// 所以由 MaintainLocalEnemyGroggyAsync 按 LocalEnemyGroggyReassertMs 周期重推,直到窗口关闭
/// (ExitLocalEnemyGroggyAsync 会清掉节流记录)。
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}");
}
}
/// 虚弱窗口内客户端又上报一次硬直时的即时补推(节流 LocalEnemyStiffNudgeMs)。
///
/// 为什么需要它:MaintainLocalEnemyGroggyAsync 只在 AI tick 上按 LocalEnemyGroggyReassertMs
/// 周期补推,而"倒地被顶掉"是客户端先知道的 —— 它受击命中后立刻上报 AskAgentEnterStiff。
/// 等周期到点再补,中间那一段敌人就是站着的(玩家读作"打晕没动画 / 只有挨打反应")。
/// 这里收到上报就补一次;与周期补推共用 LastGroggyReassert 节流表,两者不会叠成每帧一次。
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);
}
///
/// 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.
///
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 从来没坏过 —— 坏的是它挡不住客户端原生的受击反应:
// 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.
// ------------------------------------------------------------------
/// 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.
private static ulong TryReadUnitId(UxRpcMessage msg)
{
try
{
return msg.GetArgs().EnemyId;
}
catch
{
return 0;
}
}
///
/// 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.
///
private static string RawBody(UxRpcMessage msg)
{
var body = msg.Body;
if (body.Length == 0)
return "";
var shown = Math.Min(body.Length, 64);
return $"{body.Length}b {Convert.ToHexString(body, 0, shown)}" + (body.Length > shown ? "..." : "");
}
///
/// 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.
[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();
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);
}
///
/// 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.
///
[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);
}
///
/// The client finished the fall. Drop the loot and remove the unit, then forget it.
/// Both packet kinds, empty OK on invoke.
///
[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);
}
/// Fall handshake acknowledgement point — logged, and always acknowledged.
/// Both packet kinds, empty OK on invoke.
[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);
}
///
/// 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.
///
[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);
}
}