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

523 lines
31 KiB
Go
Raw Permalink Normal View History

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"`
}