package routers import ( "context" "sync" ) var _ = sync.Mutex{} var _ = context.Background // // 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 // Drmk.App.exe and restart the server to turn the whole path off. // func GameRouter_LocalEnemyCombat_GetLocalEnemyRuntimeState(ctx context.Context, session *GameSession) any { // Router implementation for GameRouter_LocalEnemyCombat_GetLocalEnemyRuntimeState var zero any; return zero } func GameRouter_LocalEnemyCombat_LocalEnemyCombatEnabled(ctx context.Context, ) bool { // Router implementation for GameRouter_LocalEnemyCombat_LocalEnemyCombatEnabled return true } // ------------------------------------------------------------------ // 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. func GameRouter_LocalEnemyCombat_RegisterSpawnedEnemy(ctx context.Context, session *GameSession, uid uint64, enemyId uint32, position Vec3, playerUnit uint64, facing float32) any { // Router implementation for GameRouter_LocalEnemyCombat_RegisterSpawnedEnemy var zero any; return zero } // // 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. // func GameRouter_LocalEnemyCombat_EngageSpawnedEnemyAsync(ctx context.Context, session *GameSession, uid uint64, playerUnit uint64) any { // Router implementation for GameRouter_LocalEnemyCombat_EngageSpawnedEnemyAsync var zero any; return zero } // Re-sends the engagement batch 1.5s after spawn: the first batch can be // consumed before the client finishes initialising the unit. func GameRouter_LocalEnemyCombat_ReEngageSpawnedEnemyAsync(ctx context.Context, session *GameSession, uid uint64) any { // Router implementation for GameRouter_LocalEnemyCombat_ReEngageSpawnedEnemyAsync var zero any; return zero } // ------------------------------------------------------------------ // 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. // func GameRouter_LocalEnemyCombat_TrackLocalEnemyHitAsync(ctx context.Context, rpcCtx *RpcContext, hit any) any { // Router implementation for GameRouter_LocalEnemyCombat_TrackLocalEnemyHitAsync var zero any; return zero } // ------------------------------------------------------------------ // 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 空间),所以它能过。 // 开关 EnemyCombatSettings.AttackerIdFallbackToSpiritUnit(默认 true)。 func GameRouter_LocalEnemyCombat_ResolveLocalHitAttackerId(ctx context.Context, rpcCtx *RpcContext, hit any) uint64 { // Router implementation for GameRouter_LocalEnemyCombat_ResolveLocalHitAttackerId return 0 } func GameRouter_LocalEnemyCombat_SendLocalEnemyAttackedAsync(ctx context.Context, rpcCtx *RpcContext, uid uint64, hit any, damage float32, serverSpawned bool, poiseDrainOverride any) any { // Router implementation for GameRouter_LocalEnemyCombat_SendLocalEnemyAttackedAsync var zero any; return zero } // 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` 不再硬编码,改由 // ResolveLocalHurtPresentation 查 HurtEffectCatalog.json。 func GameRouter_LocalEnemyCombat_SendLocalEnemyHurtEffectAsync(ctx context.Context, rpcCtx *RpcContext, uid uint64, hit any) any { // Router implementation for GameRouter_LocalEnemyCombat_SendLocalEnemyHurtEffectAsync var zero any; return zero } // 2026-09-26 Wave 1:LocalEnemyBulletHurtPresentation(55001201,7K 枪械整行替换的 // 目标行,表里 Impact=1 / LeftOrRight=0 / SoundId=0)随「整行替换」语义一起移除 —— // 现在的枪械分支是只升不降(保留作者行),不再引用它。行值留在这条注释里备查。 // // 按 hit.HurtEffectId 解析出这次命中该发的表现参数。 // 移植自捐赠端 7k-v0.5 GameRouter.Enemy.cs:590 ResolveHurtPresentation。 // 关键差异(有意为之):捐赠端无条件走枪械重定向,我们把它放在 // EnemyCombatSettings.HurtEffectBulletOverride 后面 —— 见那个开关的注释。 // 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,保证不劣化。 // func GameRouter_LocalEnemyCombat_ResolveLocalHurtPresentation(ctx context.Context, hurtEffectId uint32) any { // Router implementation for GameRouter_LocalEnemyCombat_ResolveLocalHurtPresentation var zero any; return zero } // // 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. // func GameRouter_LocalEnemyCombat_SendLocalEnemyHpBaselineAsync(ctx context.Context, rpcCtx *RpcContext, uid uint64, maxHp float32) any { // Router implementation for GameRouter_LocalEnemyCombat_SendLocalEnemyHpBaselineAsync var zero any; return zero } // // 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. // func GameRouter_LocalEnemyCombat_SendLocalEnemyHpAsync(ctx context.Context, rpcCtx *RpcContext, uid uint64, hp float32) any { // Router implementation for GameRouter_LocalEnemyCombat_SendLocalEnemyHpAsync var zero any; return zero } // 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). // func GameRouter_LocalEnemyCombat_SendLocalEnemyStaggerAsync(ctx context.Context, rpcCtx *RpcContext, uid uint64) any { // Router implementation for GameRouter_LocalEnemyCombat_SendLocalEnemyStaggerAsync var zero any; return zero } func GameRouter_LocalEnemyCombat_ReassertLocalEnemyDeathAsync(ctx context.Context, session *GameSession, uid uint64) any { // Router implementation for GameRouter_LocalEnemyCombat_ReassertLocalEnemyDeathAsync var zero any; return zero } // 把倒地动作 88300 推给一个已经在走死亡流程的单位(不带节流)。 // 节流由调用方按自己的节奏做:AskAgentEnterStiff 上报走 // ReassertLocalEnemyDeathAsync,周期扫描由 SweepLocalEnemyDeathClearsAsync 自己做。 func GameRouter_LocalEnemyCombat_SendLocalEnemyDownPoseAsync(ctx context.Context, session *GameSession, uid uint64, why string) any { // Router implementation for GameRouter_LocalEnemyCombat_SendLocalEnemyDownPoseAsync var zero any; return zero } // 虚弱窗口内把硬直姿势顶回去:SyncEnemyPlayAction(67149)。 // 2026-09-23 17:1x 实测:破防后客户端只是站着(SyncEnemyDisarmState / SyncEnemyPoiseRate 在这个 // build 里都是空实现),5 s 后照常挥击,玩家读作"打晕没有动画"。一次性动作撑不满 5 s 的窗口, // 所以由 MaintainLocalEnemyGroggyAsync 按 LocalEnemyGroggyReassertMs 周期重推,直到窗口关闭 // (ExitLocalEnemyGroggyAsync 会清掉节流记录)。 func GameRouter_LocalEnemyCombat_SendLocalEnemyGroggyPoseAsync(ctx context.Context, session *GameSession, uid uint64) any { // Router implementation for GameRouter_LocalEnemyCombat_SendLocalEnemyGroggyPoseAsync var zero any; return zero } // 虚弱窗口内客户端又上报一次硬直时的即时补推(节流 LocalEnemyStiffNudgeMs)。 // 为什么需要它:MaintainLocalEnemyGroggyAsync 只在 AI tick 上按 LocalEnemyGroggyReassertMs // 周期补推,而"倒地被顶掉"是客户端先知道的 —— 它受击命中后立刻上报 AskAgentEnterStiff。 // 等周期到点再补,中间那一段敌人就是站着的(玩家读作"打晕没动画 / 只有挨打反应")。 // 这里收到上报就补一次;与周期补推共用 LastGroggyReassert 节流表,两者不会叠成每帧一次。 func GameRouter_LocalEnemyCombat_ReassertLocalEnemyGroggyAsync(ctx context.Context, session *GameSession, uid uint64) any { // Router implementation for GameRouter_LocalEnemyCombat_ReassertLocalEnemyGroggyAsync var zero any; return zero } // // 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. // func GameRouter_LocalEnemyCombat_LocalEnemyDeathSequenceAsync(ctx context.Context, rpcCtx *RpcContext, uid uint64, deadlySkillId uint32, deathPos Vec3) any { // Router implementation for GameRouter_LocalEnemyCombat_LocalEnemyDeathSequenceAsync var zero any; return zero } // ------------------------------------------------------------------ // 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. func GameRouter_LocalEnemyCombat_TryReadUnitId(ctx context.Context, msg any) uint64 { // Router implementation for GameRouter_LocalEnemyCombat_TryReadUnitId 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. // func GameRouter_LocalEnemyCombat_RawBody(ctx context.Context, msg any) string { // Router implementation for GameRouter_LocalEnemyCombat_RawBody return "" } // // 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. func GameRouter_LocalEnemyCombat_LocalEnemyHurtEffectReport(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_LocalEnemyCombat_LocalEnemyHurtEffectReport var zero any; return zero } // // 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. // func GameRouter_LocalEnemyCombat_LocalEnemyStartFall(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_LocalEnemyCombat_LocalEnemyStartFall var zero any; return zero } // // The client finished the fall. Drop the loot and remove the unit, then forget it. // Both packet kinds, empty OK on invoke. // func GameRouter_LocalEnemyCombat_LocalEnemyEndFall(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_LocalEnemyCombat_LocalEnemyEndFall var zero any; return zero } // Fall handshake acknowledgement point — logged, and always acknowledged. // Both packet kinds, empty OK on invoke. func GameRouter_LocalEnemyCombat_LocalEnemyFallGround(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_LocalEnemyCombat_LocalEnemyFallGround var zero any; return zero } // // 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. // func GameRouter_LocalEnemyCombat_LocalEnemyUseClientSkill(ctx context.Context, conn any, msg any) any { // Router implementation for GameRouter_LocalEnemyCombat_LocalEnemyUseClientSkill var zero any; return zero } type LocalEnemyRecord struct { Uid uint64 `json:"uid"` MaxHp float32 `json:"maxHp"` Hp float32 `json:"hp"` Hits int32 `json:"hits"` FirstSeenTicks int64 `json:"firstSeenTicks"` LastStiffTicks int64 `json:"lastStiffTicks"` Position Vec3 `json:"position"` LastSkillId uint32 `json:"lastSkillId"` LastHurtEffectId uint32 `json:"lastHurtEffectId"` Dead bool `json:"dead"` DeathStarted bool `json:"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. ServerSpawned bool `json:"serverSpawned"` EnemyId uint32 `json:"enemyId"` PlayerUnit uint64 `json:"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. Poise float32 `json:"poise"` // 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. GroggyUntilTicks int64 `json:"groggyUntilTicks"` // True while the client has told us (AskAgentEnterStiff) the unit is in 硬直. // Cleared by AskAgentExitStiff / ReportUnitStandUp — see GameRouter.EnemyStiff.cs. StiffActive bool `json:"stiffActive"` // How many times this enemy has been broken. Logging only. GroggyCount int32 `json:"groggyCount"` // ---- 2026-10-02 逐字对齐捐赠端 EnemyRecord 的收押/攻击 AI 字段 ---- // 主线原本没有这些(旧 PolicePrison.cs 靠改账本坐标 + AttackAiEnabled=false 顶替)。 // 现在捐赠端 GameRouter.PolicePrison.cs 逐字落地 ⇒ 把这些状态补上,**纯新增、不改既有语义**。 IsPoliceDetained bool `json:"isPoliceDetained"` AttackAiFollowing bool `json:"attackAiFollowing"` AttackAiFollowTarget uint64 `json:"attackAiFollowTarget"` // AttackAiTarget / AttackAiCastSkill / AttackAiCastUntil 主线本来就有 ⇒ 不重复加。 AttackAiCastTarget uint64 `json:"attackAiCastTarget"` HackBusyUntilTicks int64 `json:"hackBusyUntilTicks"` SceneGeneration int32 `json:"sceneGeneration"` // ---- Alertness / enemy-HUD state (GameRouter.EnemyWarning.cs) ---- // // 已经注册过警戒状态机。false = 客户端那边还是 EnemyDetectState.None, // HUD(血条 + 挂它上面的警戒条)不会绑 —— 这就是"血条默认不显示"。 // WarningPrimed bool `json:"warningPrimed"` // 最近一次下发的 detectionState(0 None / 1 Idle / 2 Suspect / 3 Alert / 4 Fight)。 WarningState byte `json:"warningState"` // // 注册后剩余的重试次数。模型/occur 重建会把早期那份 HUD 快照丢掉, // 所以刚注册的两拍里即使状态没变也要重发(捐赠端 Warmup=2)。 // WarningWarmupRemaining int32 `json:"warningWarmupRemaining"` // 下一次允许重估/重发的 TickCount64。状态不变就在这里直接跳过,避免刷包。 WarningRefreshAt int64 `json:"warningRefreshAt"` // ---- 血条显示时机(GameRouter.EnemyHpBar.cs)---- // // 服务端给这个单位挂了 UnitStateConfig.HideHp(10266),血条此刻是收起的。 // 第一次命中时由 RevealLocalEnemyHpBarAsync 撤掉状态并置回 false。 // HpBarHidden bool `json:"hpBarHidden"` // ---- Attack-AI state (GameRouter.EnemyAiActions.cs) ---- AttackAiEnabled bool `json:"attackAiEnabled"` AttackAiTarget uint64 `json:"attackAiTarget"` AttackAiActionAt int64 `json:"attackAiActionAt"` AttackAiCastSkill uint32 `json:"attackAiCastSkill"` AttackAiCastUntil int64 `json:"attackAiCastUntil"` AttackAiCastInstance int32 `json:"attackAiCastInstance"` AttackAiCastCount int32 `json:"attackAiCastCount"` AttackAiMoveId byte `json:"attackAiMoveId"` AttackAiMoveCount int32 `json:"attackAiMoveCount"` LastHurtFeedbackTicks int64 `json:"lastHurtFeedbackTicks"` // Spawn facing, kept so the turn command can compute a delta angle. Facing float32 `json:"facing"` // // 出生点。踱步的保险绳基准:估算位置离它超过 EnemyAiPatrolLeashMeters 就强制换向, // 免得估算误差累积把敌人一路推到玩家脸上。 // HomePosition Vec3 `json:"homePosition"` // 踱步方向:true = 下一步朝玩家(WalkFront),false = 下一步背对玩家退(WalkBack)。 PatrolForward bool `json:"patrolForward"` // engagement 状态上次翻转的 TickCount64,用于 EnemyAiEngageHoldMs 防抖。 AttackAiEngageChangedAt int64 `json:"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 GameRouter.EnemyAiMoveStepMeters per dispatch. // Only used to decide "close enough to swing yet". AiPosition Vec3 `json:"aiPosition"` } type LocalEnemyRuntimeState struct { SyncRoot any `json:"syncRoot"` Enemies map[uint64]LocalEnemyRecord `json:"enemies"` // 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. PendingDeathClear map[uint64]int64 `json:"pendingDeathClear"` // TickCount64 of the last time the down pose was re-pushed onto a corpse. // 尸体被玩家连招再次打进硬直时用来节流(见 ReassertLocalEnemyDeathAsync)。 // 尸体拆掉时随 PendingDeathClear 一起清理。 LastDeathReassert map[uint64]int64 `json:"lastDeathReassert"` // 尸体最早允许被拆掉的 TickCount64(= 死亡序列开始 + LocalEnemyMinCorpseMs)。 // 2026-09-23 19:2x:客户端会在死亡动画刚起手时就上报 ReportEnemyPrepareFinish / // ReportAgentDiePerfEnd,而这两个 handler 原来是收到即拆(SyncClearSpoonEnemies), // 于是同一套死亡序列会随机表现成两种结果 —— 上报来得早就"直接消失",来得晚才"正常倒地"。 // 这正是"死亡动作不一定能加载出来"的来源。加了这个下限之后,客户端的报告只能把清理 // 提前到下限,不能提前到动画播完之前。 DeathEarliest map[uint64]int64 `json:"deathEarliest"` // TickCount64 of the last time the 硬直 pose was re-pushed onto a 虚弱 enemy. // 虚弱窗口有 5 s,而客户端的 SyncEnemyPlayAction 是一次性动作、播完就站起来, // 所以窗口内必须周期性重推,敌人看起来才是"被打晕了"而不是"站着发呆" // (见 ReassertLocalEnemyGroggyAsync)。虚弱结束时清理。 LastGroggyReassert map[uint64]int64 `json:"lastGroggyReassert"` // 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. AttackAiWorkerStarted bool `json:"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. // PendingUnlocks []int64 `json:"pendingUnlocks"` // Auto-release on/off. Toggled from the admin panel; on by default. AutoUnlockEnabled bool `json:"autoUnlockEnabled"` // ---- 2026-10-02 逐字对齐捐赠端 EnemyRuntimeState 补的三个成员 ---- // 捐赠端 GameRouter.Enemy.cs:116 的 `GetEnemyRuntimeState` 返回 `EnemyRuntimeState`; // 主线这套被 `LocalEnemyRuntimeState` 取代,字段命名也不同。这里**只加字段、不改语义**, // 让捐赠端 GameRouter.PolicePrison.cs / LixiPolice.cs 能逐字编译。访问器别名在 // GameRouter.PoliceDonorGlue.cs。 HackDispatchGate *sync.Mutex `json:"hackDispatchGate"` AttackAiWorkerGeneration int32 `json:"attackAiWorkerGeneration"` AttackAiWorkerId int64 `json:"attackAiWorkerId"` }