This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/internal/gameserver/routers/game_router_local_enemy_combat.go
T

524 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 之前判一条:
// <code>
// 0x148246CC test r12,r12 / call get_IsMe() // unit
// 0x148246E0 test r15,r15 / call get_IsMe() // attacker
// 0x148246F7 test rbx,rbx / call get_IsMe() // realAttacker
// 0x1482470E je 0x14824744 // 三者全非 IsMe -> 只打 Warn(HurtFloatText=371) 后返回
// 0x1482472C jmp ShowHurtMsg // 有一方 IsMe -> 画飘字
// </code>
// 而 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"`
}