523 lines
31 KiB
Go
523 lines
31 KiB
Go
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"`
|
|||
|
|
}
|
|||
|
|
|