1808 lines
104 KiB
C#
1808 lines
104 KiB
C#
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Rpc;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using Ananta.Server.RpcTypes.Client4229938;
|
||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// Combat authority for CLIENT-SPAWNED enemies.
|
||
///
|
||
/// The enemy this covers is the one produced by the in-game local channel
|
||
/// (GmUtils.CreateClientAgent / LuaUtils.CreateClientAgentByCfg). That unit belongs to
|
||
/// the client: it lives in the client's LocalUnitMgr, it carries its own HP / death
|
||
/// model / animation layer, and this server has no record of it at all. Nothing drives
|
||
/// that model either — the client's combat sim never applies damage to a local unit, so
|
||
/// the enemy absorbs every shot without flinching and never dies. Logs confirm the
|
||
/// player's hits DO arrive here (AskMultipleSkillHit2 with the local unit's uid in
|
||
/// HitTarget, firearms included), which means the server is the only missing link.
|
||
///
|
||
/// This file closes that link without touching how enemies are created and without
|
||
/// creating an entity server-side:
|
||
///
|
||
/// 1. LAZY LEDGER. The first time a hit names a local unit, its uid is recorded. No
|
||
/// spawn packet is sent, the client's unit is left exactly as it is; the server
|
||
/// only starts remembering "this uid has an HP pool".
|
||
/// 2. FEEDBACK. Every accepted hit broadcasts the hurt effect, a one-shot stagger and the
|
||
/// authoritative HP ratio. SyncUnitHurtEffect (68349329) is the important one: the
|
||
/// client fastpatch defines a Lua impl for it that forwards the payload into
|
||
/// gCS.BattleManager.LuaTryDoHurtAction, i.e. the client's OWN hurt pipeline, keyed by
|
||
/// pid. That is what makes the enemy actually flinch - and it is the right lever,
|
||
/// because the animation state machine owns the Animator and rewrites the pose from its
|
||
/// own State every frame, so any one-off PlayAction we sent would read as a twitch that
|
||
/// instantly recovers. SyncUnitHp is resolved through gCS.SceneDataMgr.GetUnit(pid), the
|
||
/// same global lookup that also serves LocalUnitMgr, which is why server packets can
|
||
/// reach a client-owned unit at all.
|
||
/// 3. DEATH. At zero HP the unit runs the down pose -> death beat -> loot drop -> despawn
|
||
/// chain. The step that matters on screen is the last one, SyncRemoveNpc, and it depends
|
||
/// on the same fastpatch: the retail build declares that method LuaOnly and ships its
|
||
/// reader, but never defines an impl, so the dispatcher's name lookup found nothing and
|
||
/// the packet was dropped on arrival without a log line. The patch's impl removes the
|
||
/// unit through BaseUnitUtils.DestroyAgentUnit left non-forced, which routes into
|
||
/// LocalUnit.OnDead and lets it play the death beat and schedule its own delayed destroy
|
||
/// instead of deleting the unit outright.
|
||
///
|
||
/// The stagger packets (67149 / 88300) ARE the reaction path. SyncEnemyPlayAction
|
||
/// (68039914) is declared CSharpOnly and RPCDeserializeAuto ships neither a midToReader
|
||
/// nor a midToName entry for it, so the Lua dispatcher used to return false on the
|
||
/// missing reader before it ever looked up a handler and the packet reached only the
|
||
/// native C# impl - which resolves units through registries a LocalUnit is not in.
|
||
/// The client fastpatch now injects those two map entries at runtime (reader =
|
||
/// u64 uid, u32 actionId, f32 maxTime, i.e. this file's EnemyPlayActionData4229938)
|
||
/// plus a Lua impl, so these packets land and drive the pose. Keep sending them.
|
||
///
|
||
/// Two deliberate exclusions keep the two spawn paths from colliding:
|
||
/// * units this server spawned (admin panel / GM channel) own uid >= ServerSpawnUidFloor and
|
||
/// are handled by GameAdminBridge — a lazy ledger entry there would double up;
|
||
/// * the player's own unit ids are never treated as enemies. Note the player entity is
|
||
/// 0x200000000001 (client-allocated), NOT Profile.InitialUnitId — before the ordinal
|
||
/// offset was introduced it sat above the floor and was settled as an enemy.
|
||
///
|
||
/// Feature switch: create an empty file named "local-enemy-combat.disabled" next to
|
||
/// Ananta.App.exe and restart the server to turn the whole path off.
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
private const string LocalEnemyRuntimeKey = "client4229938-local-enemies";
|
||
|
||
/// <summary>
|
||
/// Boundary between the two uid spaces. Server-spawned agents are synthesised as
|
||
/// ServerSpawnUidBase + ServerSpawnUidOrdinalOffset + ordinal
|
||
/// (GameAdminBridge.SpawnAgentAsync), while every uid the client assigns to its own
|
||
/// local units sits below that, so the range split is how a hit target is attributed
|
||
/// to one path or the other.
|
||
///
|
||
/// Measured in packets-latest.log: the player's own entity is 0x200000000001, i.e. the
|
||
/// client's local units and our agents used to share the very first indices. Ordinal 1
|
||
/// then became the player, and this ledger settled the player's HP down to zero, which
|
||
/// parked the HUD on the knocked-out bar ("昏迷 100%"). The offset now reserves the low
|
||
/// indices for the client. Keep it in sync with GameAdminBridge — a drift here silently
|
||
/// re-attributes every spawned agent to the wrong path.
|
||
///
|
||
/// The player unit (100000000000) is below the floor too and is filtered separately.
|
||
/// </summary>
|
||
private const ulong ServerSpawnUidFloor = GameAdminBridge.ServerSpawnUidFloor;
|
||
|
||
// Local enemies carry their own HP model client-side, but nothing drives it. These
|
||
// are the server-side numbers that replace it: 1000 HP and 100 per accepted hit, so
|
||
// the standard test enemy dies on the 10th hit — the same tuning the LY-3.3 reference
|
||
// server settled on, which keeps hit counts comparable across both.
|
||
// internal: GameAdminBridge.SpawnAgentAsync seeds its own spawn batch from the same
|
||
// number so both spawn paths agree on what "full HP" means.
|
||
//
|
||
// ⛑⛑ 2026-09-27 改为配置驱动(`gameplay.combat.enemies.maxHp`,默认仍是 1000):
|
||
// 这是 GM/服务端 spawn 与「地图敌人首次受击登记」两条路<b>共用</b>的唯一血量来源,
|
||
// 以前调一次要改常量 + 重编译,现在改 json 即可(热重载生效)。
|
||
// 用属性而不是 const,保证每次读取都拿最新值;`Math.Max(1f, …)` 兜底配 0/负数。
|
||
internal static float LocalEnemyMaxHp
|
||
=> Math.Max(1f, PrivateServerConfigStore.Current.Gameplay.Combat.Enemies.MaxHp);
|
||
private const float LocalEnemyHitDamage = 100f;
|
||
|
||
// ---- Poise / groggy (stance) tuning ----
|
||
// Stance is deliberately a separate pool from HP: the client renders it as its own bar
|
||
// (SyncEnemyPoiseRate, verified visible in-game 2026-09-23) and draining it is what puts the
|
||
// enemy into the "虚弱" break where it stops swinging. Numbers taken from the enemy tuning
|
||
// block of the kimika reference server (maxPoise 100, poiseDamagePerHit 34, groggySeconds 5):
|
||
// 34 means three clean hits break the stance, so a break lands roughly once per fight
|
||
// instead of once per hit or never.
|
||
internal const float LocalEnemyMaxPoise = 100f;
|
||
|
||
/// <summary>每次命中扣多少架势 —— <b>不再写死</b>,改 private-server.json 的
|
||
/// <c>gameplay.combat.enemies.poiseDrainPerHit</c> 即可(100 = 每下破防 / 34 = 三下破防 /
|
||
/// 0 = 只走服务端模型)。服务端账本和发给客户端的 <c>SyncUnitAttacked.PoiseBasicValue</c>
|
||
/// 用的是同一个数,所以两边永远同步破防。
|
||
///
|
||
/// 原来是常量 34f(kimika 捐赠端值)。2026-09-23 夜按用户要求改回"每一下都有明显反应"的
|
||
/// 手感,默认值调到 100(= 一击伤害,也就是 11:3x 那个状态的值)。那时候它会导致敌人卡在
|
||
/// 破防里出不来("打不死"),但那是因为当时<b>没有退出通道</b>;现在 ReportUnitStandUp
|
||
/// 回执 + SyncEnemyDisarmState(false) + 5 s 超时兜底都在了,可以安全地开回来。</summary>
|
||
private static float LocalEnemyPoiseDamagePerHit
|
||
=> PrivateServerConfigStore.Current.Gameplay.Combat.Enemies.PoiseDrainPerHit;
|
||
|
||
/// <summary>敌人受击手感的三个旋钮(见 <see cref="EnemyCombatSettings"/>)。</summary>
|
||
private static EnemyCombatSettings EnemyHit
|
||
=> PrivateServerConfigStore.Current.Gameplay.Combat.Enemies;
|
||
private const long LocalEnemyGroggyMs = 5000;
|
||
|
||
/// <summary>虚弱窗口内重新顶一次硬直动作的间隔(ms)。
|
||
///
|
||
/// 2026-09-23 17:1x 实测:破防后敌人只是站着不动,5 s 后照常挥击 —— 玩家读作"没打晕"。
|
||
/// 原因:SyncEnemyPlayAction 是一次性动作,67149 播完(不到 1 s)就回站立,
|
||
/// 而客户端的虚弱 UI 在这个 build 里是死的(SyncEnemyDisarmState 的 C# 体是空的、
|
||
/// SyncEnemyPoiseRate 的 Lua handler 守卫写反)。所以服务端必须自己撑住这个姿势:
|
||
/// 窗口内周期性重推,敌人看起来才是"晕着"而不是"站着发呆"。900 ms 略短于一次
|
||
/// 900 → 3600 → 1200 → <b>3000</b>(2026-09-23 19:2x):1200 是"被顶掉后尽快补回来"定的,
|
||
/// 实测代价是 5 s 窗口里倒地了三次 —— 88300 是 RemainTime 4 s 的重倒地动作,每次补推都是
|
||
/// 一次动作重启(弹起再趴下),玩家读作"连续倒地三次"。3000 让 5 s 窗口最多出现两次倒地:
|
||
/// 破防那一次,加一次"真被顶掉之后"的补。
|
||
/// 前提仍然是:客户端本地的受击反应走 native(SyncUnitHurtEffect 在这个 build 上没有 Lua
|
||
/// impl,fastpatch 明确 left to native),服务端碰不到,只能事后补。</summary>
|
||
private const long LocalEnemyGroggyReassertMs = 3000;
|
||
|
||
/// <summary>是否推 SyncEnemyPlayAction(67149) 一次性硬直动作。<b>默认开</b>。
|
||
///
|
||
/// ⛑⛑ 2026-09-23 15:2x 实测翻案:这一条曾被关掉(理由写反了,见下),结果是「远程打敌人
|
||
/// 完全没有受击反应」——因为远程命中的 hurtEffect 恒为 55004611,客户端靠它演不出硬直,
|
||
/// 67149 是唯一还能让远程命中"看得见"的动作。近战当时看着有硬直,只是因为它 3 下就撞上
|
||
/// 我们自己的 poise 破防(groggy → SyncEnemyDisarmState),跟 67149 无关。
|
||
///
|
||
/// 当初关掉的理由及为何不成立:捐赠端(kimika)命中路径确实不发这个包,但 kimika 能不发是
|
||
/// 因为它 spawn 时把 BattleAiS / 6 个技能槽 / Detect / DetectStatus / FightEdict 一次推齐,
|
||
/// 客户端的 hurt 管线本来就是活的;我们缺那一整套,客户端的受击反应根本没起来,只能靠
|
||
/// 67149 显式驱动。
|
||
///
|
||
/// 「播完没有退出包 ⇒ 卡住」这条也已经翻案:真凶是 SyncUnitAttacked.PoiseBasicValue 填了
|
||
/// damage(100),而客户端 poise 池正好也是 100 ⇒ 每一下都直接破防倒地,且当时没有任何
|
||
/// 「退出虚弱」的路径。现在 PoiseBasicValue=0(下见 SendLocalEnemyAttackedAsync)+ 破防有
|
||
/// EnterLocalEnemyGroggyAsync/ExitLocalEnemyGroggyAsync 完整进出 + 5 个 C2S 上报 handler,
|
||
/// 卡住的根因已经不在 67149 身上。若复测又出现「打晕后不动」,优先把它改回 false 再验证。
|
||
/// ⛑ 用 static readonly 而不是 const:const bool 会让编译器把另一分支判为不可达(CS0162)。</summary>
|
||
private static readonly bool LocalEnemyPushStaggerAction = true;
|
||
|
||
// 死亡时是否由<b>服务端</b>推 SyncEnemyPlayAction(88300) 倒地动作 —— 2026-09-23 17:4x 起为「否」,
|
||
// 常量连同那段代码一起移除,理由留在这里,免得下次又把它加回来:
|
||
//
|
||
// 客户端 Player.log 证明 fastpatch 的 SyncRemoveNpc hook 每一次都成功播了 88300
|
||
// (17:43:51 / 17:44:27 / 17:44:49 / 17:45:07 四条 "death PlayAction(88300) ok=true"),
|
||
// 3 s 后 DestroyAgentUnit 也成功。服务端再补一个 88300 只会造成动作重启 —— hook 自己的注释
|
||
// 写着 "the restart is what makes the corpse snap upright before it drops"。
|
||
// 想做对照实验:在 LocalEnemyDeathSequenceAsync 的 removeNpc 之后补一段 SyncEnemyPlayAction
|
||
// (LocalEnemyDownActionId)。
|
||
|
||
// One-shot animation ids verified against this build: 67149 = stagger, 88300 = down.
|
||
// 67150/67148 (the other entries in HurtStiffConfig) are NO-OPs on this client, so a
|
||
// rotation through them would silently drop two of every three reactions.
|
||
private const uint LocalEnemyStaggerActionId = 67149;
|
||
private const uint LocalEnemyDownActionId = 88300;
|
||
|
||
/// <summary>击晕(破防)姿势用的动作 = <b>88300 倒地</b>,不是 67149。
|
||
///
|
||
/// ⛑⛑ 2026-09-23 18:0x:原来击晕复用 67149(受击硬直),结果玩家看不出任何区别 ——
|
||
/// 67149 是一次性短硬直,破防后敌人只是「僵一下 / 呆站着」,而玩家要的是<b>倒地</b>
|
||
/// ("昏迷/击晕"在这个游戏里就是被打趴)。88300 来自 HurtStiffFirearmDieConfig,
|
||
/// RemainTime = 4 s,正好覆盖 5 s 虚弱窗口的大半,是唯一已知的倒地动作。
|
||
/// 若实测发现敌人看起来像"死了",把它改回 LocalEnemyStaggerActionId 即可,
|
||
/// 破防账本(Poise / GroggyUntilTicks / 停手闸门)与这个动作 id 完全解耦。</summary>
|
||
private const uint LocalEnemyGroggyActionId = LocalEnemyDownActionId;
|
||
|
||
/// <summary>击晕(虚弱)期间要不要推倒地动作 88300。<b>2026-09-23 19:4x 起默认 false</b>。
|
||
///
|
||
/// 三条实测把这条路堵死了:
|
||
/// * 不推 ⇒ 敌人呆站着(客户端的 SyncEnemyDisarmState / SyncEnemyPoiseRate 在这个 build
|
||
/// 里都是空实现,虚弱 UI 起不来)。
|
||
/// * 推 88300 ⇒ 它是 RemainTime 4 s 的重倒地动作,玩家在虚弱窗口里照打,本地受击把它顶掉,
|
||
/// 服务端补推 = 动作重启 ⇒ 5 s 窗口里"连续倒地三次"。
|
||
/// * 推 67149(短硬直)⇒ 和受击动作一模一样,玩家读作"没打晕、只是僵一下"。
|
||
/// 用户 2026-09-23 已明确<b>接受"击晕无法倒地、只会呆呆站着"</b>,所以关掉,等找到真正的
|
||
/// 短眩晕动作 id 再打开。账本(Poise / GroggyUntilTicks / 停手闸门 / DisarmState)与该开关
|
||
/// 完全解耦,关掉它只影响"看不看得见姿势"。</summary>
|
||
private static readonly bool LocalEnemyGroggyPoseEnabled = false;
|
||
|
||
/// <summary>UX.Game.EnemyDetectState.None — pushed when a unit dies so the corpse leaves the
|
||
/// Fight behaviour instead of standing in the combat tree with an empty bar.</summary>
|
||
private const byte LocalEnemyDetectStateNone = 0;
|
||
|
||
/// <summary>UX.Game.EnemyDetectState.Fight — the slot that maps onto Behavior_Battle.
|
||
/// Carried by SyncEnemyDetectStatus(68126766); see SyncEnemyDetectStatusData4229938.
|
||
/// A spawned enemy sits at None(0) until this is pushed, and the enemy HUD keys the HP
|
||
/// bar off that state, so the bar cannot bind without it.</summary>
|
||
private const byte LocalEnemyDetectStateFight = 4;
|
||
|
||
/// <summary>Per-enemy stagger throttle. Slower than a melee swing and faster than a
|
||
/// firearm's cycle, so a fast melee combo cannot restart the flinch before it finishes
|
||
/// while firearms are unaffected.</summary>
|
||
private const int LocalEnemyStiffCooldownMs = 700;
|
||
|
||
/// <summary>Delay before the stagger so the hurt VFX (which resets the client's action
|
||
/// layer) has already been applied.</summary>
|
||
private const int LocalEnemyStiffDelayMs = 120;
|
||
|
||
/// <summary>Delay before the down pose so the client's action layer finishes the last
|
||
/// hit reaction first — a down action sent mid-stagger-restart is dropped, which is
|
||
/// exactly the "no death animation" symptom.</summary>
|
||
private const int LocalEnemyDeathActionDelayMs = 250;
|
||
|
||
/// <summary>
|
||
/// <c>SyncAddUnitState</c>(68026128) 的 <c>state</c>:把单位打进 <c>UnitStateConfig.DeadS</c>。
|
||
///
|
||
/// ⛑⛑ 是 <b>2</b>,不是 3。<c>UnitStateAtlasConfig.Death = 3</c> 只是个图集 atlas,
|
||
/// 发 3 单位不会真的进死亡行为模块(捐赠端 7k-v0.5 源码原注释)。
|
||
/// </summary>
|
||
private const uint LocalEnemyDeadUnitState = 2;
|
||
|
||
/// <summary>Gap between the down pose and the death beat (SyncEnemyPrepareDie).
|
||
///
|
||
/// The two beats must NOT land in the same frame: SyncEnemyPrepareDie drives the HP bar
|
||
/// collapse and the corpse setup, so pushing it together with the down action makes the
|
||
/// bar snap to 0 with no fall and tears the corpse down mid-animation (the remaining half
|
||
/// of the "no death animation" symptom — the other half was the 1500ms despawn).
|
||
/// 650ms is the value an independent 4229938 branch arrived at by the same reasoning.</summary>
|
||
private const int LocalEnemyDeathBeatDelayMs = 650;
|
||
|
||
/// <summary>Destroy distance handed to the client with the death beat.
|
||
///
|
||
/// Was 0, which the client reads as "destroy as soon as it is off-camera" — combined with
|
||
/// a 900-unit authored remain distance in the retail DeathSetting, this is what made
|
||
/// corpses vanish the instant the player turned away.</summary>
|
||
private const float LocalEnemyDelayDestroyDistance = 900f;
|
||
|
||
/// <summary>Publish a SyncEnemyPoiseRate frame with every accepted hit.
|
||
///
|
||
/// Started as a one-restart diagnostic on 68696896 (its C# handler IS an empty shell on
|
||
/// this build, which is why the dump shows no IDMAP entry). It is no longer a probe: the
|
||
/// client does react — a stance bar appears and a drained pool stops the enemy swinging,
|
||
/// confirmed in-game 2026-09-23, so the Lua side is the real consumer. Set to false to
|
||
/// stop shipping it; the HP / lethality pipeline does not depend on it.
|
||
///
|
||
/// ⛑ 2026-09-26 <b>Wave 4:关掉</b>(补偿 UI 清理)。W1 poiseDrainPerHit=0 之后服务端
|
||
/// 账本不再扣架势 ⇒ 这根条永远满格,是纯噪音;捐赠端 7k-v0.5 从头到尾不发这个包。
|
||
/// 想 A/B 就翻回 true。⛑ 用 static readonly 而不是 const:const false 会让编译器把
|
||
/// 另一分支判为不可达(CS0162)。</summary>
|
||
internal static readonly bool PushPoiseProbe = false;
|
||
|
||
/// <summary>死亡表现播完之后,SyncRemoveNpc 距 SyncEnemyPrepareDie 的间隔。
|
||
///
|
||
/// 2026-09-23 实测:客户端的死亡动作是<b>由 SyncRemoveNpc 的 Lua hook 播放的</b>(fastpatch
|
||
/// <c>LuaFiles#LX6#Manager#ClientGameSwitch.lua</c>),不是由服务端的 SyncEnemyPlayAction:
|
||
/// hook 里先 <c>PlayAction(unit, 88300, group)</c>,再 3 s 后 DestroyAgentUnit。它播之前还会
|
||
/// <c>__DRMK_SetT(__DRMK_Dead, unit, pid, true)</c> 关掉 hurt 路径 —— 这正是近战需要的:
|
||
/// 玩家连招不停时,后续命中的本地反应会顶掉倒地动作。
|
||
/// 在此之前的死亡序列<b>只对 client-spawned 单位发 SyncRemoveNpc</b>(<c>if (!serverSpawned)</c>),
|
||
/// GM 生成的敌人走的是 serverSpawned 分支,一次都没发过 ⇒ 那个 hook 从来没跑过。
|
||
/// 1700 → 0(2026-09-23 17:1x):此延迟是相对 SyncEnemyPrepareDie 的,实测 1.7 s 的空档里
|
||
/// 玩家近战连招照打,本地 hurt 反应把倒地动作顶掉 —— 尸体一直站着播硬直,最后被拆掉时
|
||
/// 玩家看到"突然消失"。0 = 死亡序列一开始就发,hurt 闸门在倒地之前就关上。
|
||
/// 想做对照实验就把这个值调回 1700(症状应当复现)。</summary>
|
||
private const int LocalEnemyRemoveNpcDelayMs = 0;
|
||
|
||
/// <summary>Time the down pose is allowed to play before the loot drop and despawn.
|
||
///
|
||
/// Was 1500ms, which is shorter than the authored death: HurtStiffFirearmDieConfig
|
||
/// record 1 gives DieAction 88300 a RemainTime of 4 seconds. Despawning at 1.5s removed
|
||
/// the corpse while the animation was still running, which reads as "no death animation"
|
||
/// even when the pose did start. Measured in packets-latest.log: SyncEnemyPlayAction
|
||
/// 88300 at 21:57:07.295, SyncLogicAgentLeave at 21:57:08.818 — 1.5s, exactly.
|
||
///
|
||
/// 5000 → 3500(2026-09-23 17:1x):现在 SyncRemoveNpc 是死亡序列的第一包,hook 的销毁
|
||
/// 定时器(3 s)从这里起算,尸体不需要服务端留到 5 s;3.5 s 兜底拆足够让 3 s 走完。</summary>
|
||
private const int LocalEnemyDropDelayMs = 3500;
|
||
|
||
/// <summary>
|
||
/// ★ T1-17(2026-09-25):死亡序列推 <c>SyncDetachBattleUnit</c>(68396133) 前,在
|
||
/// <c>SyncEnemyPrepareDie</c> + <see cref="LocalEnemyDropDelayMs"/> 之后**再**等多久。
|
||
/// donor 实测:客户端死亡特效约 3 s 起、5.3 s 完 ⇒ 3500 + 2500 = 6000 ms 正好落在他们用的
|
||
/// 「尸体保留 6000 ms」上。**提前 detach 会把死亡特效打断**(他们注释原话)。
|
||
/// ⛔ **2026-09-25 19:3x 回滚为 0**:发这个包之后玩家实测「死亡动画没了、只会直接消失」
|
||
/// (19:34–19:35 五个 uid 全部走完 detach,紧跟着 logicAgentLeave + clearSpoonEnemies)。
|
||
/// 客户端 `Player.log` 对 68396133 零报错、零日志 ⇒ 副作用发生在 C# 侧、无从观测,
|
||
/// 而收益("死亡特效")**donor 自己也没做出来** ⇒ 收益不抵风险,**不再发**。
|
||
/// 想复现就把它改回 2500 重编译(症状应当复现)。
|
||
/// ⛑ `static readonly` **不是** `const`:常量会在编译期折叠,写 0 会让下面的 `if` 分支
|
||
/// 变成「无法访问的代码」(CS0162 警告)。改成 readonly 之后开关语义不变(改 0 即不再发),
|
||
/// 但编译干净。
|
||
/// </summary>
|
||
private static readonly int LocalEnemyDetachBattleUnitDelayMs = 0;
|
||
|
||
/// <summary>尸体被客户端再次打进硬直时,重推倒地动作的最小间隔。
|
||
/// 1500 → 3000 → <b>1200</b>(2026-09-23 18:4x):3000 是照着 hook 里「2.5 s 内播过就不再重启」
|
||
/// 那条守卫定的,本意是退化为纯兜底;但那个守卫同时也挡住了所有补播 —— 玩家近战连招把倒地
|
||
/// 顶掉之后,整整 3 s 内没有任何一方再把 88300 顶回去,尸体就一直站着演受击,最后是"直接
|
||
/// 消失"。现在把间隔压回 1.2 s(19:4x),并且同步把 hook 的守卫从 2.5 s 降到 0.8 s(见
|
||
/// fastpatch 的 <c>SyncRemoveNpc</c> 实现),双方就不再互相抵消。
|
||
/// ⛑ 19:2x 曾把这里改成 2.0 s 以配合"周期扫描也补推",那个周期扫描 19:4x 已撤掉
|
||
/// (它是"远程放两次倒地"的另一半),于是这份节流表重新只服务 AskAgentEnterStiff 上报 ——
|
||
/// 近战连招下补得越快,倒地越不容易被吃掉。
|
||
/// ⛑ 代价仍是重推 = 动作重启(hook 注释:the restart is what makes the corpse snap upright
|
||
/// before it drops),但"弹起再趴下"远好过"站着不动然后消失"。</summary>
|
||
private const int LocalEnemyDeathReassertCooldownMs = 1200;
|
||
|
||
/// <summary>死亡后这段时间内<b>不接受</b>任何补推(ms)。见 ReassertLocalEnemyDeathAsync 里
|
||
/// 的实测:致死那一击自带的残留 AskAgentEnterStiff 会把 hook 刚播的 88300 重启成"倒两次"。
|
||
/// 必须 < hook 的销毁定时器(3 s),而 > 残留上报的延迟(实测 <1 s)。</summary>
|
||
private const int LocalEnemyDeathQuietMs = 900;
|
||
|
||
/// <summary>收到客户端 AskAgentEnterStiff 上报后,补推击晕姿势的最小间隔(ms)。
|
||
/// 比 LocalEnemyGroggyReassertMs 短:上报是"已经被顶掉了"的即时证据,补得越快越看不出
|
||
/// 断档;但不能快到每一下近战都重推(那又变成动作重启,实测 700 ms 就是"连续倒地三次")。
|
||
/// 700 → 2000(2026-09-23 19:2x)。</summary>
|
||
private const int LocalEnemyStiffNudgeMs = 2000;
|
||
|
||
/// <summary>Soft cap so a long session cannot grow the ledger without bound.</summary>
|
||
private const int LocalEnemyLedgerCap = 256;
|
||
|
||
internal sealed class LocalEnemyRecord
|
||
{
|
||
public ulong Uid;
|
||
public float MaxHp = LocalEnemyMaxHp;
|
||
public float Hp = LocalEnemyMaxHp;
|
||
public int Hits;
|
||
public long FirstSeenTicks;
|
||
public long LastStiffTicks;
|
||
public Vec3 Position;
|
||
public uint LastSkillId;
|
||
public uint LastHurtEffectId;
|
||
public bool Dead;
|
||
public bool DeathStarted;
|
||
|
||
/// <summary>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.</summary>
|
||
public bool ServerSpawned;
|
||
public uint EnemyId;
|
||
public ulong PlayerUnit;
|
||
|
||
// ---- Poise / groggy (stance) state ----
|
||
/// <summary>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.</summary>
|
||
public float Poise = LocalEnemyMaxPoise;
|
||
|
||
/// <summary>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.</summary>
|
||
public long GroggyUntilTicks;
|
||
|
||
/// <summary>True while the client has told us (AskAgentEnterStiff) the unit is in 硬直.
|
||
/// Cleared by AskAgentExitStiff / ReportUnitStandUp — see GameRouter.EnemyStiff.cs.</summary>
|
||
public bool StiffActive;
|
||
|
||
/// <summary>How many times this enemy has been broken. Logging only.</summary>
|
||
public int GroggyCount;
|
||
|
||
// ---- 2026-10-02 逐字对齐捐赠端 EnemyRecord 的收押/攻击 AI 字段 ----
|
||
// 主线原本没有这些(旧 PolicePrison.cs 靠改账本坐标 + AttackAiEnabled=false 顶替)。
|
||
// 现在捐赠端 GameRouter.PolicePrison.cs 逐字落地 ⇒ 把这些状态补上,**纯新增、不改既有语义**。
|
||
public bool IsPoliceDetained;
|
||
public bool AttackAiFollowing;
|
||
public ulong AttackAiFollowTarget;
|
||
// AttackAiTarget / AttackAiCastSkill / AttackAiCastUntil 主线本来就有 ⇒ 不重复加。
|
||
public ulong AttackAiCastTarget;
|
||
public long HackBusyUntilTicks;
|
||
public int SceneGeneration;
|
||
|
||
// ---- Alertness / enemy-HUD state (GameRouter.EnemyWarning.cs) ----
|
||
/// <summary>
|
||
/// 已经注册过警戒状态机。false = 客户端那边还是 <c>EnemyDetectState.None</c>,
|
||
/// HUD(血条 + 挂它上面的警戒条)不会绑 —— 这就是"血条默认不显示"。
|
||
/// </summary>
|
||
public bool WarningPrimed;
|
||
|
||
/// <summary>最近一次下发的 detectionState(0 None / 1 Idle / 2 Suspect / 3 Alert / 4 Fight)。</summary>
|
||
public byte WarningState;
|
||
|
||
/// <summary>
|
||
/// 注册后剩余的重试次数。模型/occur 重建会把早期那份 HUD 快照丢掉,
|
||
/// 所以刚注册的两拍里即使状态没变也要重发(捐赠端 Warmup=2)。
|
||
/// </summary>
|
||
public int WarningWarmupRemaining;
|
||
|
||
/// <summary>下一次允许重估/重发的 TickCount64。状态不变就在这里直接跳过,避免刷包。</summary>
|
||
public long WarningRefreshAt;
|
||
|
||
// ---- 血条显示时机(GameRouter.EnemyHpBar.cs)----
|
||
/// <summary>
|
||
/// 服务端给这个单位挂了 <c>UnitStateConfig.HideHp</c>(10266),血条此刻是<b>收起</b>的。
|
||
/// 第一次命中时由 <c>RevealLocalEnemyHpBarAsync</c> 撤掉状态并置回 false。
|
||
/// </summary>
|
||
public bool HpBarHidden;
|
||
|
||
// ---- Attack-AI state (GameRouter.EnemyAiActions.cs) ----
|
||
public bool AttackAiEnabled;
|
||
public ulong AttackAiTarget;
|
||
public long AttackAiActionAt;
|
||
public uint AttackAiCastSkill;
|
||
public long AttackAiCastUntil;
|
||
public int AttackAiCastInstance;
|
||
public int AttackAiCastCount;
|
||
public byte AttackAiMoveId;
|
||
public int AttackAiMoveCount;
|
||
public long LastHurtFeedbackTicks;
|
||
|
||
/// <summary>Spawn facing, kept so the turn command can compute a delta angle.</summary>
|
||
public float Facing;
|
||
|
||
/// <summary>
|
||
/// 出生点。踱步的保险绳基准:估算位置离它超过 EnemyAiPatrolLeashMeters 就强制换向,
|
||
/// 免得估算误差累积把敌人一路推到玩家脸上。
|
||
/// </summary>
|
||
public Vec3 HomePosition;
|
||
|
||
/// <summary>踱步方向:true = 下一步朝玩家(WalkFront),false = 下一步背对玩家退(WalkBack)。</summary>
|
||
public bool PatrolForward = true;
|
||
|
||
/// <summary>engagement 状态上次翻转的 TickCount64,用于 EnemyAiEngageHoldMs 防抖。</summary>
|
||
public long AttackAiEngageChangedAt;
|
||
|
||
/// <summary>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 <see cref="GameRouter.EnemyAiMoveStepMeters"/> per dispatch.
|
||
/// Only used to decide "close enough to swing yet".</summary>
|
||
public Vec3 AiPosition;
|
||
}
|
||
|
||
internal sealed class LocalEnemyRuntimeState
|
||
{
|
||
public object SyncRoot { get; } = new();
|
||
public Dictionary<ulong, LocalEnemyRecord> Enemies { get; } = new();
|
||
|
||
/// <summary>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.</summary>
|
||
public Dictionary<ulong, long> PendingDeathClear { get; } = new();
|
||
|
||
/// <summary>TickCount64 of the last time the down pose was re-pushed onto a corpse.
|
||
/// 尸体被玩家连招再次打进硬直时用来节流(见 ReassertLocalEnemyDeathAsync)。
|
||
/// 尸体拆掉时随 PendingDeathClear 一起清理。</summary>
|
||
public Dictionary<ulong, long> LastDeathReassert { get; } = new();
|
||
|
||
/// <summary>尸体<b>最早</b>允许被拆掉的 TickCount64(= 死亡序列开始 + LocalEnemyMinCorpseMs)。
|
||
///
|
||
/// 2026-09-23 19:2x:客户端会在死亡动画刚起手时就上报 ReportEnemyPrepareFinish /
|
||
/// ReportAgentDiePerfEnd,而这两个 handler 原来是<b>收到即拆</b>(SyncClearSpoonEnemies),
|
||
/// 于是同一套死亡序列会随机表现成两种结果 —— 上报来得早就"直接消失",来得晚才"正常倒地"。
|
||
/// 这正是"死亡动作不一定能加载出来"的来源。加了这个下限之后,客户端的报告只能把清理
|
||
/// <b>提前到下限</b>,不能提前到动画播完之前。</summary>
|
||
public Dictionary<ulong, long> DeathEarliest { get; } = new();
|
||
|
||
/// <summary>TickCount64 of the last time the 硬直 pose was re-pushed onto a 虚弱 enemy.
|
||
/// 虚弱窗口有 5 s,而客户端的 SyncEnemyPlayAction 是一次性动作、播完就站起来,
|
||
/// 所以窗口内必须周期性重推,敌人看起来才是"被打晕了"而不是"站着发呆"
|
||
/// (见 ReassertLocalEnemyGroggyAsync)。虚弱结束时清理。</summary>
|
||
public Dictionary<ulong, long> LastGroggyReassert { get; } = new();
|
||
|
||
/// <summary>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.</summary>
|
||
public bool AttackAiWorkerStarted;
|
||
|
||
/// <summary>
|
||
/// TickCount64 deadlines at which the player's control states get released, queued by
|
||
/// the attack AI when it dispatches a cast. See EnemyAiUnlockDelayMsEarly/Late.
|
||
/// </summary>
|
||
public List<long> PendingUnlocks { get; } = new();
|
||
|
||
/// <summary>Auto-release on/off. Toggled from the admin panel; on by default.</summary>
|
||
public bool AutoUnlockEnabled = true;
|
||
|
||
// ---- 2026-10-02 逐字对齐捐赠端 EnemyRuntimeState 补的三个成员 ----
|
||
// 捐赠端 GameRouter.Enemy.cs:116 的 `GetEnemyRuntimeState` 返回 `EnemyRuntimeState`;
|
||
// 主线这套被 `LocalEnemyRuntimeState` 取代,字段命名也不同。这里**只加字段、不改语义**,
|
||
// 让捐赠端 GameRouter.PolicePrison.cs / LixiPolice.cs 能逐字编译。访问器别名在
|
||
// GameRouter.PoliceDonorGlue.cs。
|
||
public readonly SemaphoreSlim HackDispatchGate = new(1, 1);
|
||
public int AttackAiWorkerGeneration;
|
||
public long AttackAiWorkerId;
|
||
}
|
||
|
||
internal static LocalEnemyRuntimeState GetLocalEnemyRuntimeState(TcpSession session)
|
||
{
|
||
if (session.Items.TryGetValue(LocalEnemyRuntimeKey, out var raw) && raw is LocalEnemyRuntimeState existing)
|
||
return existing;
|
||
var created = new LocalEnemyRuntimeState();
|
||
session.Items[LocalEnemyRuntimeKey] = created;
|
||
return created;
|
||
}
|
||
|
||
// Same opt-out idiom as the infinite-ammo switch: a sentinel file beside the exe,
|
||
// re-checked at most every 2s so it can be flipped without a rebuild.
|
||
private static long _localEnemyCheckTicks;
|
||
private static bool _localEnemyCached = true;
|
||
|
||
internal static bool LocalEnemyCombatEnabled()
|
||
{
|
||
var now = Environment.TickCount64;
|
||
if (now - _localEnemyCheckTicks < 2000)
|
||
return _localEnemyCached;
|
||
_localEnemyCheckTicks = now;
|
||
try
|
||
{
|
||
_localEnemyCached = !System.IO.File.Exists(
|
||
System.IO.Path.Combine(System.AppContext.BaseDirectory, "local-enemy-combat.disabled"));
|
||
}
|
||
catch
|
||
{
|
||
_localEnemyCached = true;
|
||
}
|
||
return _localEnemyCached;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Registration of SERVER-SPAWNED agents (GameAdminBridge.SpawnAgentAsync).
|
||
//
|
||
// A server-spawned agent is a real battle unit: the client creates it from
|
||
// SyncRaidBattleUnitAgent and its native code can see it, so SyncEnemyPlayAction
|
||
// (CSharpOnly) actually reaches an animation layer — unlike a client-spawned local
|
||
// unit, where those same packets are dropped because the uid is not in the native
|
||
// registry. That single difference is why this path is the reliable one for
|
||
// stagger + death, and why SpawnAgentAsync registers its agents here instead of
|
||
// relying on the lazy hit-ledger below.
|
||
// ------------------------------------------------------------------
|
||
|
||
/// <summary>Puts a freshly spawned agent into the HP ledger so its hits are settled.
|
||
/// Called by GameAdminBridge.SpawnAgentAsync right after the spawn batch.</summary>
|
||
internal static void RegisterSpawnedEnemy(
|
||
TcpSession session, ulong uid, uint enemyId, Vec3 position, ulong playerUnit, float facing)
|
||
{
|
||
var runtime = GetLocalEnemyRuntimeState(session);
|
||
bool start;
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
runtime.Enemies[uid] = new LocalEnemyRecord
|
||
{
|
||
Uid = uid,
|
||
EnemyId = enemyId,
|
||
Position = position,
|
||
AiPosition = position,
|
||
HomePosition = position,
|
||
Facing = facing,
|
||
PlayerUnit = playerUnit,
|
||
ServerSpawned = true,
|
||
AttackAiEnabled = true,
|
||
AttackAiActionAt = Environment.TickCount64 + 1500,
|
||
FirstSeenTicks = Environment.TickCount64,
|
||
};
|
||
start = !runtime.AttackAiWorkerStarted;
|
||
if (start) runtime.AttackAiWorkerStarted = true;
|
||
}
|
||
session.Log.Info($"[ENEMY] tracked server-spawned agent uid={uid} template={enemyId} hp={LocalEnemyMaxHp:F0} playerUnit={playerUnit} facing={facing:F1}");
|
||
if (start) _ = RunEnemyAttackAiAsync(session);
|
||
// 警戒状态机:先注册成 Idle(1),警戒条**不显示**,等玩家走近由周期刷新推进到 Fight(4)。
|
||
// 这里是 void 方法,只能 fire-and-forget;Prime 内部自己 try/catch。
|
||
_ = PrimeLocalEnemyWarningAsync(session, uid);
|
||
|
||
// ⛑⛑ 血条是另一条路(2026-09-26 02:1x):警戒状态机动不了它,血条只看
|
||
// UnitStateConfig.HideHp。spawn 时挂上 ⇒ 收起,第一次命中由 Reveal 撤掉。
|
||
// 见 GameRouter.EnemyHpBar.cs 类注释里的客户端判据。
|
||
_ = HideLocalEnemyHpBarAsync(session, uid);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
internal static async Task EngageSpawnedEnemyAsync(TcpSession session, ulong uid, ulong playerUnit)
|
||
{
|
||
if (playerUnit == 0)
|
||
return;
|
||
try
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncUnitLockTarget,
|
||
UxSerializer.Serialize(new SceneMethods.SyncUnitLockTarget { entityId = uid, targetId = playerUnit }),
|
||
CancellationToken.None);
|
||
await session.NotifyAsync(MethodId.SyncEnemyFightEdict,
|
||
UxSerializer.Serialize(new SceneMethods.SyncEnemyFightEdict { uid = uid, isGive = true }),
|
||
CancellationToken.None);
|
||
await session.NotifyAsync(MethodId.SyncEnemyDisarmState,
|
||
UxSerializer.Serialize(new SceneMethods.SyncEnemyDisarmState { id = uid, isDisarmed = false }),
|
||
CancellationToken.None);
|
||
|
||
// Combat behaviour-tree switch. LockTarget + FightEdict alone are not enough to
|
||
// make an enemy act: the native side flips BaseUnit.set_IsBattleAiS here, and that
|
||
// is what moves the unit off its idle/ambient tree onto CombatBehaviorTree. The
|
||
// donor build sends it as the third of its four engagement packets; this port used
|
||
// to omit it, which is the most likely reason spawned enemies never attacked.
|
||
await session.NotifyAsync(MethodId.SyncBattleAiS,
|
||
UxSerializer.Serialize(new SceneMethods.SyncBattleAiS { agentId = uid, value = true }),
|
||
CancellationToken.None);
|
||
|
||
await session.NotifyAsync(MethodId.SyncEnemyDetect,
|
||
UxSerializer.Serialize(new GameMethods.EnemyDetectData4229938 { Id = uid, Stealth = 0 }),
|
||
CancellationToken.None);
|
||
|
||
// Alertness state — the sixth engagement packet and the one that was missing.
|
||
// Everything above arms the behaviour tree, but the unit stays at
|
||
// EnemyDetectState.None until this lands; EnemyHUDCtrl keys the STEALTH ALERTNESS
|
||
// METER on unitDataSet.detectionState (EnemyHUDCtrl.lua:85 -> OnShowDetectionEffect),
|
||
// so a spawned enemy fought, took damage and died with no alertness meter anywhere.
|
||
// Fight(4) is the slot that maps onto Behavior_Battle.
|
||
// ⛑⛑ 2026-09-26:警戒状态机接管之后,这里<b>不再</b>发 Fight(4),改由
|
||
// GameRouter.EnemyWarning.cs 从 Idle(1) 起步,玩家走近才推进到 Fight(4)。
|
||
// ⛑⛑⛑ 订正(02:1x):detectionState 只管<b>警戒条</b>,<b>管不了血条</b> ——
|
||
// 血条看的是 cfg.ShowHPBar + UnitStateConfig.HideHp,见 GameRouter.EnemyHpBar.cs。
|
||
// 上面五件套(LockTarget / FightEdict / DisarmState / BattleAiS / SyncEnemyDetect)
|
||
// 一个都不动:它们管的是行为树(敌人会不会打人),跟 HUD 无关。
|
||
// 关掉 gameplay.combat.enemies.warningStateMachine 即回到这里发 Fight 的旧行为。
|
||
if (!EnemyHit.WarningStateMachine)
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncEnemyDetectStatus,
|
||
UxSerializer.Serialize(new GameMethods.SyncEnemyDetectStatusData4229938
|
||
{
|
||
Id = uid,
|
||
StealthValue = 0f,
|
||
State = LocalEnemyDetectStateFight,
|
||
}), CancellationToken.None);
|
||
}
|
||
session.Log.Info($"[ENEMY] engage uid={uid} -> playerUnit={playerUnit} (lock+edict+battleAiS+detect" +
|
||
(EnemyHit.WarningStateMachine ? ", detectStatus=deferred to warning FSM)" : "+detectStatus=Fight)"));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Info($"[ENEMY] engage skip uid={uid} ({ex.Message})");
|
||
}
|
||
}
|
||
|
||
/// <summary>Re-sends the engagement batch 1.5s after spawn: the first batch can be
|
||
/// consumed before the client finishes initialising the unit.</summary>
|
||
internal static async Task ReEngageSpawnedEnemyAsync(TcpSession session, ulong uid)
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(1500);
|
||
var runtime = GetLocalEnemyRuntimeState(session);
|
||
ulong playerUnit;
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
if (!runtime.Enemies.TryGetValue(uid, out var record) || record is null)
|
||
return; // already defeated / removed
|
||
playerUnit = record.PlayerUnit;
|
||
}
|
||
if (playerUnit == 0)
|
||
return;
|
||
await EngageSpawnedEnemyAsync(session, uid, playerUnit);
|
||
session.Log.Info($"[ENEMY] re-engage uid={uid} -> playerUnit={playerUnit}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Info($"[ENEMY] re-engage skip uid={uid} ({ex.Message})");
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Hit intake.
|
||
// ------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
internal async Task TrackLocalEnemyHitAsync(RpcContext ctx, SceneMethods.SkillHitData hit)
|
||
{
|
||
if (!LocalEnemyCombatEnabled())
|
||
return;
|
||
|
||
var uid = hit.HitTarget;
|
||
if (uid == 0)
|
||
return;
|
||
|
||
// Two uid spaces share this ledger (see ServerSpawnUidFloor). A server-spawned
|
||
// agent is settled only when SpawnAgentAsync registered it — an unregistered uid
|
||
// above the floor belongs to some other system. A client-spawned local unit is
|
||
// discovered lazily here on its first hit, as before.
|
||
var serverSpawned = uid >= ServerSpawnUidFloor;
|
||
|
||
var state = GetWorldState(ctx);
|
||
if (uid == state.ActiveSpiritUnitId || uid == state.WorldEntryControlUnit || uid == Profile.PlayerPid)
|
||
return;
|
||
|
||
var runtime = GetLocalEnemyRuntimeState(ctx.Session);
|
||
LocalEnemyRecord record;
|
||
bool isNew;
|
||
bool dead;
|
||
bool shouldStartDeath;
|
||
bool brokeStance = false;
|
||
float ratio;
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
isNew = !runtime.Enemies.TryGetValue(uid, out var existing);
|
||
if (isNew)
|
||
{
|
||
if (serverSpawned)
|
||
return; // spawned by nobody we know — not an agent of ours
|
||
if (runtime.Enemies.Count >= LocalEnemyLedgerCap)
|
||
return;
|
||
existing = new LocalEnemyRecord
|
||
{
|
||
Uid = uid,
|
||
FirstSeenTicks = Environment.TickCount64,
|
||
Position = new Vec3(hit.ClientHitPosition.X, hit.ClientHitPosition.Y, hit.ClientHitPosition.Z),
|
||
};
|
||
runtime.Enemies.Add(uid, existing);
|
||
}
|
||
|
||
record = existing!;
|
||
dead = record.Dead;
|
||
shouldStartDeath = false;
|
||
|
||
if (!dead)
|
||
{
|
||
record.Hits++;
|
||
record.Hp = Math.Max(0f, record.Hp - LocalEnemyHitDamage);
|
||
record.Position = new Vec3(hit.ClientHitPosition.X, hit.ClientHitPosition.Y, hit.ClientHitPosition.Z);
|
||
record.LastSkillId = hit.SkillId;
|
||
record.LastHurtEffectId = hit.HurtEffectId;
|
||
// Suppresses the attack AI briefly (GameRouter.EnemyAiActions.cs): being shot
|
||
// must not make the enemy swing faster.
|
||
record.LastHurtFeedbackTicks = Environment.TickCount64;
|
||
|
||
// Stance drains per hit, independent of HP. Reaching zero BREAKS the enemy: it
|
||
// stops swinging for LocalEnemyGroggyMs (checked in RefreshEnemyActionAsync) and
|
||
// the pool refills, so the bar is a repeating rhythm rather than a one-way trip.
|
||
// A stance break does no damage — HP and stance are separate axes on purpose.
|
||
if (record.Poise > 0f)
|
||
{
|
||
record.Poise = Math.Max(0f, record.Poise - LocalEnemyPoiseDamagePerHit);
|
||
if (record.Poise <= 0f)
|
||
{
|
||
record.GroggyUntilTicks = Environment.TickCount64 + LocalEnemyGroggyMs;
|
||
record.GroggyCount++;
|
||
brokeStance = true;
|
||
// Poise STAYS at 0 for the whole window — it used to be reset to full right
|
||
// here, which made the stance bar snap back to 1.0 in the same frame it
|
||
// broke and left the client with no zero frame to enter 虚弱 on. The refill
|
||
// belongs to the exit path (ExitLocalEnemyGroggyAsync), which is also what
|
||
// pushes SyncEnemyDisarmState(false).
|
||
}
|
||
}
|
||
if (record.Hp <= 0f)
|
||
{
|
||
record.Dead = true;
|
||
dead = true;
|
||
}
|
||
}
|
||
|
||
if (dead && !record.DeathStarted)
|
||
{
|
||
record.DeathStarted = true;
|
||
shouldStartDeath = true;
|
||
}
|
||
|
||
ratio = Math.Clamp(record.Hp / record.MaxHp, 0f, 1f);
|
||
}
|
||
|
||
if (isNew)
|
||
{
|
||
ctx.Session.Log.Info($"[LENEMY] tracked new local unit uid={uid} hp={LocalEnemyMaxHp:F0} (first hit by skill={hit.SkillId} at {record.Position.X:F2},{record.Position.Y:F2},{record.Position.Z:F2})");
|
||
// A client-spawned unit is created without any server-side HP table, so the bar
|
||
// has to be established here, on the first hit — there is no spawn batch of ours
|
||
// to hang it on (this is the difference from the donor build, which spawns its
|
||
// enemies server-side and can send the baseline up front).
|
||
await SendLocalEnemyHpBaselineAsync(ctx, uid, LocalEnemyMaxHp);
|
||
}
|
||
|
||
// DEATH LATCH. Measured in console-latest.log: after the killing blow the client
|
||
// keeps reporting hits for another ~5s (40+ packets at ~10/s in the 22:26 run) and
|
||
// every one of them re-enters the hurt pipeline, which restarts whatever pose the
|
||
// death sequence just started. Only the lethal hit itself is allowed through.
|
||
if (dead && !shouldStartDeath)
|
||
return;
|
||
|
||
// SyncUnitHurtEffect is fed for EVERY tracked unit, server-spawned ones included.
|
||
// It used to be withheld from server-spawned agents on the theory that native
|
||
// already reacts to them; that theory was wrong. 68349329 is LuaAndCSharp and is
|
||
// the packet the client's own hurt pipeline is driven from on this build, and it is
|
||
// what the LY-3.3 reference server echoes back on every hit (see its
|
||
// AskPlayHurtEffect handler). Withholding it is the most likely reason both the
|
||
// flinch and the death pose were missing. The fastpatch no longer overrides it, so
|
||
// nothing stacks a second reaction.
|
||
// 受击三件套全部可在 private-server.json 里单独关(gameplay.combat.enemies.*):
|
||
// poiseDrainPerHit / pushStaggerAction / pushHurtEffect。
|
||
bool sendHurtEffect = EnemyHit.PushHurtEffect;
|
||
var tag = record.ServerSpawned ? "[ENEMY]" : "[LENEMY]";
|
||
|
||
// SyncUnitAttacked FIRST, on every hit including the lethal one. This is the packet
|
||
// the client's native combat code actually consumes; HP, stagger and the death beat
|
||
// are all downstream of it. Without it the client knows the unit's bar moved but
|
||
// never puts the unit into its hurt state, so nothing reacts and nothing dies on
|
||
// screen — exactly the "shot it ten times, it just vanished" report.
|
||
// ⛑⛑ 23:27 行为:致死击同样带满额架势伤害(不做 poiseDrainOverride)。
|
||
// 00:2x 那两轮为了让倒地播出来,给致死击传了 0 并在死亡前清虚弱,结果死亡动画
|
||
// 确实出来了,但玩家继续攻击时会把它顶掉(用户原话「死亡动画又会被我的普通攻击打断」)。
|
||
// ⇒ 23:27 的组合(每击都打满架势,包括致死击)实测是受击 + 死亡都正常的那一版。
|
||
// 血条出场:第一次命中撤掉 HideHp(GameRouter.EnemyHpBar.cs)。
|
||
// 之后 HpBarHidden 已置 false,这里是 no-op,不会每一下都刷包。
|
||
await RevealLocalEnemyHpBarAsync(ctx.Session, uid);
|
||
|
||
await SendLocalEnemyAttackedAsync(ctx, uid, hit, LocalEnemyHitDamage, record.ServerSpawned);
|
||
|
||
// 破防出场:SyncEnemyDisarmState(true) + 架势条 0。放在伤害包之后,顺序与捐赠端一致
|
||
// (先掉血/掉架势,再进虚弱)。客户端的起身报告 / 超时会关掉这个窗口
|
||
// (GameRouter.EnemyStiff.cs)——在接回执之前,这个窗口永远关不掉,敌人就"卡住"了。
|
||
// 致死那一击不再开虚弱窗口:单位马上要走死亡序列,开窗口只会让周期重推的 67149
|
||
// 和倒地动作 88300 抢同一个动作槽(死亡序列开头也有一次保险清理)。
|
||
if (brokeStance && !dead)
|
||
await EnterLocalEnemyGroggyAsync(ctx.Session, uid, "poise-break");
|
||
|
||
if (dead)
|
||
{
|
||
if (!shouldStartDeath)
|
||
return;
|
||
ctx.Session.Log.Info($"{tag} killed uid={uid} hits={record.Hits} deadlySkill={hit.SkillId} -> down/die/drop/despawn sequence");
|
||
if (sendHurtEffect)
|
||
await SendLocalEnemyHurtEffectAsync(ctx, uid, hit);
|
||
await SendLocalEnemyHpAsync(ctx, uid, 0f);
|
||
_ = LocalEnemyDeathSequenceAsync(ctx, uid, hit.SkillId, record.Position);
|
||
return;
|
||
}
|
||
|
||
ctx.Session.Log.Info($"{tag} hit uid={uid} hits={record.Hits} dmg={LocalEnemyHitDamage:F0} hp={record.Hp:F0}/{record.MaxHp:F0} ({ratio:P0}) " +
|
||
$"stance={record.Poise:F0}/{LocalEnemyMaxPoise:F0}{(brokeStance ? " **BREAK**" : "")} skill={hit.SkillId} hurtEffect={hit.HurtEffectId}");
|
||
|
||
// Stagger first: it is the reaction the player notices, and the one the client's
|
||
// BattleManager.HasHitState guard can drop. The VFX+HP follow immediately.
|
||
// NOTE (2026-09-23): off by default, see LocalEnemyPushStaggerAction — the client already
|
||
// plays its own hit reaction from SyncUnitAttacked + SyncUnitHurtEffect, and this one-shot
|
||
// action has no exit packet, which is what parked enemies inside their stagger pose.
|
||
if (LocalEnemyPushStaggerAction && EnemyHit.PushStaggerAction)
|
||
_ = SendLocalEnemyStaggerAsync(ctx, uid);
|
||
if (sendHurtEffect)
|
||
await SendLocalEnemyHurtEffectAsync(ctx, uid, hit);
|
||
await SendLocalEnemyHpAsync(ctx, uid, record.Hp);
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Outgoing reactions.
|
||
// ------------------------------------------------------------------
|
||
|
||
/// <summary>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 填的是 <b>EnemyCombatSettings.PoiseDrainPerHit</b>,和服务端自己的
|
||
/// 架势账本是同一个数,两边同步破防。
|
||
/// 历史:这里填过 damage(100),客户端架势池就是 100 ⇒ 每一下都打空 ⇒ 每一下都进虚弱/倒地,
|
||
/// 而当时<b>没有退出通道</b>(没有 ReportUnitStandUp 回执、没有 SyncEnemyDisarmState(false)、
|
||
/// 没有超时兜底),敌人就永久卡在破防里 —— 那正是 2026-09-23 上午"受打击反馈正常但打不死"
|
||
/// 的完整解释。14:4x 把它改成 0(donor 值)解决了卡住,但也把反馈改没了;现在退出通道齐了,
|
||
/// 这个值可以按手感自由调:100 = 每下破防(反馈最强)/ 34 = 三下破防 / 0 = 不破防。
|
||
/// </summary>
|
||
/// 2026-09-24 00:2x 曾加过「致死那一击 PoiseBasicValue 传 0」(poiseDrainOverride),
|
||
/// 01:2x 已按用户要求撤掉:它让倒地动画播出来了,但玩家的后续攻击会把倒地顶掉。
|
||
/// 23:27 实测正常的一版就是**不加这个特例**。`poiseDrainOverride` 参数保留但现在没有调用点传值,
|
||
/// 将来若再要单独控制致死击的架势,改调用点即可。</summary>
|
||
/// <summary>Resolve the attacker <b>unit id</b> for a hit.
|
||
///
|
||
/// ⛑⛑⛑ 2026-09-26(伤害数字攻坚·离线取证结论):<b>回退值不能用 <c>Profile.PlayerPid</c></b>。
|
||
/// 客户端画飘字的唯一入口是 <c>LX6.Utils.GuiUtils.HurtFloat</c>(RVA 0x14824610,
|
||
/// 全 .text 只有一处调用点:SyncUnitAttacked 消费函数 0x1275F320 内的 0x1276261B),
|
||
/// 它在调用 <c>ShowHurtMsg</c> 之前判一条:
|
||
/// <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>
|
||
/// 而 <c>attacker</c> 是 <c>SyncUnitAttacked.attackerId</c> 经
|
||
/// <c>GetUnit → GetNpcByPid → Unit_GetByPid</c> 三段解析出来的(0x1275F48D/4AF/882),
|
||
/// 三段全落空 ⇒ attacker = null ⇒ 上面那条 IsMe 判据拿不到 true ⇒ <b>不画飘字</b>。
|
||
/// <c>Profile.PlayerPid</c> 是 json <c>player.pid</c>(我们 = 100,捐赠端 = 666),
|
||
/// 那是<b>账号 pid,不在 unit id 空间里</b>,三段必然全落空。
|
||
/// 捐赠端 7k-v0.5 <c>GameRouter.Enemy.cs:400-402</c> 回退的是
|
||
/// <c>world?.ActiveSpiritUnitId ?? Profile.InitialUnitId</c>(unit id 空间),所以它能过。
|
||
/// 开关 <see cref="EnemyCombatSettings.AttackerIdFallbackToSpiritUnit"/>(默认 true)。</summary>
|
||
private static ulong ResolveLocalHitAttackerId(RpcContext ctx, SceneMethods.SkillHitData hit)
|
||
{
|
||
if (hit.ReleaserId != 0) return hit.ReleaserId;
|
||
if (!EnemyHit.AttackerIdFallbackToSpiritUnit) return Profile.PlayerPid;
|
||
var st = GetStateIfExists(ctx.Session);
|
||
return st is not null && st.ActiveSpiritUnitId != 0 ? st.ActiveSpiritUnitId : Profile.InitialUnitId;
|
||
}
|
||
|
||
private static async Task SendLocalEnemyAttackedAsync(RpcContext ctx, ulong uid, SceneMethods.SkillHitData hit,
|
||
float damage, bool serverSpawned,
|
||
float? poiseDrainOverride = null)
|
||
{
|
||
// 和服务端账本扣的架势是同一个数 —— 见 LocalEnemyPoiseDamagePerHit。
|
||
float poiseDrain = poiseDrainOverride ?? LocalEnemyPoiseDamagePerHit;
|
||
try
|
||
{
|
||
// 2026-09-26(伤害数字攻坚):对齐 7K GameRouter.Enemy.cs:425-431 —— 模型/occur
|
||
// 重建会覆盖 spawn 快照里的属性,每次伤害/HUD 事件前重断言 MaxHp 分母(不重置 HP)。
|
||
// 原生飘字(C# 消费 SyncUnitAttacked)需要这个分母;这是与 7K 逐字段 diff 后
|
||
// 伤害链上最后一处未对齐的差异。开关 ReassertMaxHpBeforeHit(json 开)。
|
||
if (EnemyHit.ReassertMaxHpBeforeHit)
|
||
{
|
||
await ctx.NotifyAsync(MethodId.SyncUnitAttrs, new SceneMethods.SyncUnitAttrs
|
||
{
|
||
unitId = uid,
|
||
attrs = new Dictionary<uint, float> { [1] = LocalEnemyMaxHp },
|
||
});
|
||
}
|
||
|
||
await ctx.NotifyAsync(MethodId.SyncUnitAttacked, new GameMethods.SyncUnitAttackedData4229938
|
||
{
|
||
Id = uid,
|
||
AttackerId = ResolveLocalHitAttackerId(ctx, hit),
|
||
HpChange = new GameMethods.DamageData4229938
|
||
{
|
||
SourceAmount = damage,
|
||
FromType = GameMethods.EntityType4229938.Player,
|
||
// 攻击者模板 id。捐赠端 7k-v0.5(GameRouter.Enemy.cs:441)填的是真实模板,
|
||
// 我们一直是 0。见 EnemyCombatSettings.FillDamageSourceIdentity 的说明:
|
||
// 这是对齐,不是已验证的修复。
|
||
SourceTemplateId = EnemyHit.FillDamageSourceIdentity
|
||
? GetStateIfExists(ctx.Session)?.ActiveSpiritTemplateId ?? Profile.InitialSpiritTemplateId
|
||
: 0u,
|
||
SkillId = hit.SkillId,
|
||
CreationId = 0,
|
||
TriggerIndex = unchecked((int)hit.TriggerIndex),
|
||
BuffId = 0,
|
||
SpecialEffects = GameMethods.DamageEffect4229938.Normal,
|
||
ClientHitPosition = new SceneMethods.UxVector3(
|
||
hit.ClientHitPosition.X, hit.ClientHitPosition.Y, hit.ClientHitPosition.Z),
|
||
ElementType = 0,
|
||
// 同上:捐赠端(:446)填 hit.Id,我们填 0。
|
||
HitIndex = EnemyHit.FillDamageSourceIdentity ? hit.Id : 0,
|
||
// ⛑⛑ 2026-09-24 01:2x 修正:回退到 23:27 = **这里填 poiseDrain(默认 100)**。
|
||
// 上一轮(01:1x)我把这里改成 0f、并把 json 的 poiseDrainPerHit 调到 34,
|
||
// 那其实退过了头 —— 34/0 是 23:23 之前的版本,不是 23:27。
|
||
// 23:27 实测「受击反馈 + 死亡动画都正常」的那一版 = poiseDrainPerHit 100
|
||
// + 每击(含致死击)都发满额架势。
|
||
//
|
||
// ⛑⛑ 2026-09-26 Wave 1(原生管线复活):默认改发**客户端上报的 hit.FirmHurt**
|
||
// (捐赠端 7k-v0.5 GameRouter.Enemy.cs:447 的取值来源),开关
|
||
// EnemyCombatSettings.RelayFirmHurtAsPoise(默认 true)。实测先行值:
|
||
// console-20260926-105902.log 里 10 次命中 firm=0.00 ⇒ 中继 ≈ 发 0 =
|
||
// 客户端架势池不动、原生破防不再被每击强制触发(「忽然定住」的锁死源消失)。
|
||
// 开关关掉 = 回到上面 23:27 的旧行为(发 poiseDrain)。
|
||
PoiseBasicValue = EnemyHit.RelayFirmHurtAsPoise ? hit.FirmHurt : poiseDrain,
|
||
HpDecreased = damage,
|
||
Amount = damage,
|
||
ShieldDecreased = 0f,
|
||
// 2026-09-26:对齐 7K(Enemy.cs:450-451)—— 盾字段透传客户端上报值,
|
||
// 不再写死 -1/0。
|
||
ShieldIndex = hit.ShieldDefendIndex,
|
||
ShieldId = hit.ShieldId,
|
||
IsHeadShoot = false,
|
||
},
|
||
});
|
||
// ⛑ 诊断字段(2026-09-26 加):firm = 客户端上报的 FirmHurt,即捐赠端
|
||
// PoiseBasicValue 的取值来源。**已有实测值**:console-20260926-105902.log
|
||
// 10 次命中全部 firm=0.00(stiff=0 也是恒 0)—— 所以 RelayFirmHurtAsPoise
|
||
// 开启时 PoiseBasicValue ≈ 0。这行日志继续留着:若 Wave 2 的 spawn 握手让
|
||
// 客户端开始报非 0,这里能第一时间看到。
|
||
// 2026-09-26(伤害数字攻坚):rel / atk 必须一起打 —— rel=0 就说明走的是回退分支,
|
||
// 而回退值落在哪个 id 空间(unit id vs 账号 pid)正是飘字 IsMe 判据的关键。
|
||
ctx.Session.Log.Info($"[LENEMY] unitAttacked uid={uid} dmg={damage:F0} skill={hit.SkillId} " +
|
||
$"rel={hit.ReleaserId} atk={ResolveLocalHitAttackerId(ctx, hit)} " +
|
||
$"serverSpawned={serverSpawned} " +
|
||
$"hitIdx={hit.Id} srcTpl={(EnemyHit.FillDamageSourceIdentity ? GetStateIfExists(ctx.Session)?.ActiveSpiritTemplateId ?? Profile.InitialSpiritTemplateId : 0u)} " +
|
||
$"firm={hit.FirmHurt:F2} stiff={hit.StiffId} hurt={hit.HurtEffectId} " +
|
||
$"pos=({hit.ClientHitPosition.X:F1},{hit.ClientHitPosition.Y:F1},{hit.ClientHitPosition.Z:F1})");
|
||
|
||
if (PushPoiseProbe)
|
||
{
|
||
// Stance pool, published alongside every hit. Verified in-game 2026-09-23: the
|
||
// client renders this as the enemy's stance bar even though its C# side is an
|
||
// empty shell on this build, so the Lua impl is the real consumer — the dump
|
||
// having no IDMAP entry is NOT evidence that the client ignores it.
|
||
// Ratio is the stance pool, not HP: draining it is what breaks the enemy, and it
|
||
// refills after every break so the bar is a rhythm rather than a one-way trip.
|
||
var stanceState = GetLocalEnemyRuntimeState(ctx.Session);
|
||
float stance = 1f;
|
||
bool groggy = false;
|
||
lock (stanceState.SyncRoot)
|
||
{
|
||
if (stanceState.Enemies.TryGetValue(uid, out var probe) && probe is not null)
|
||
{
|
||
stance = Math.Clamp(probe.Poise / LocalEnemyMaxPoise, 0f, 1f);
|
||
groggy = Environment.TickCount64 < probe.GroggyUntilTicks;
|
||
}
|
||
}
|
||
|
||
await ctx.NotifyAsync(MethodId.SyncEnemyPoiseRate, new GameMethods.SyncEnemyPoiseRateData4229938
|
||
{
|
||
Id = uid,
|
||
Ratio = stance,
|
||
});
|
||
ctx.Session.Log.Info($"[ENEMY-POISE] uid={uid} stance={stance:F2} groggy={groggy}");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ctx.Session.Log.Warn($"[LENEMY] unit-attacked send failed uid={uid} serverSpawned={serverSpawned}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>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` 不再硬编码,改由
|
||
/// <see cref="ResolveLocalHurtPresentation"/> 查 <c>HurtEffectCatalog.json</c>。</summary>
|
||
private static async Task SendLocalEnemyHurtEffectAsync(RpcContext ctx, ulong uid, SceneMethods.SkillHitData hit)
|
||
{
|
||
try
|
||
{
|
||
var presentation = ResolveLocalHurtPresentation(hit.HurtEffectId);
|
||
await ctx.NotifyAsync(MethodId.SyncUnitHurtEffect, new GameMethods.SyncUnitHurtEffectData4229938
|
||
{
|
||
HurterId = uid,
|
||
AttackerId = ResolveLocalHitAttackerId(ctx, hit),
|
||
// 7K 发的是 presentation.Id。查表未命中时 Resolve 回传原始 hurtEffectId,
|
||
// 与旧行为逐字节一致。
|
||
HurtId = presentation.Id,
|
||
SkillId = hit.SkillId,
|
||
HurtEffectFacePos = new SceneMethods.UxVector3(
|
||
hit.ClientHitPosition.X, hit.ClientHitPosition.Y, hit.ClientHitPosition.Z),
|
||
HurtImpact = presentation.Impact,
|
||
HurtLeftOrRightHit = presentation.LeftOrRight,
|
||
});
|
||
ctx.Session.Log.Info($"[LENEMY] hurtEffect uid={uid} hurt={hit.HurtEffectId} " +
|
||
$"-> id={presentation.Id} impact={presentation.Impact} " +
|
||
$"side={presentation.LeftOrRight} sound={presentation.SoundId} " +
|
||
$"catalog={(EnemyHit.HurtEffectCatalogLookup ? "on" : "off")}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ctx.Session.Log.Warn($"[LENEMY] hurt-effect send failed uid={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>查表关掉时退回的旧常量值(2026-09-26 之前这里写死 1)。</summary>
|
||
private const uint LocalEnemyHurtImpactLegacy = 1;
|
||
|
||
// 2026-09-26 Wave 1:LocalEnemyBulletHurtPresentation(55001201,7K 枪械整行替换的
|
||
// 目标行,表里 Impact=1 / LeftOrRight=0 / SoundId=0)随「整行替换」语义一起移除 ——
|
||
// 现在的枪械分支是只升不降(保留作者行),不再引用它。行值留在这条注释里备查。
|
||
|
||
/// <summary>
|
||
/// 按 <c>hit.HurtEffectId</c> 解析出这次命中该发的表现参数。
|
||
/// 移植自捐赠端 7k-v0.5 <c>GameRouter.Enemy.cs:590 ResolveHurtPresentation</c>。
|
||
///
|
||
/// 关键差异(<b>有意为之</b>):捐赠端无条件走枪械重定向,我们把它放在
|
||
/// <see cref="EnemyCombatSettings.HurtEffectBulletOverride"/> 后面 —— 见那个开关的注释。
|
||
/// 2026-09-26 Wave 1 起该分支从「整行替换成 55001201」改成<b>只升不降</b>:
|
||
/// 保留作者行,只把 Impact 抬到 ≥ 2。7K 的替换会把 3 行 Impact=4 的作者配置
|
||
/// (55004539 / 55004615 / 55004741,还带 SoundId=70400806)打成 Impact=2 / 无声。
|
||
///
|
||
/// ⛑ 下限夹到 1 是必须的:1841 行里有 306 行 Impact = 0,照发就是"完全没反应",
|
||
/// 比原来的硬编码 1 还差。查表未命中同样兜到 1,保证不劣化。
|
||
/// </summary>
|
||
private static HurtEffectEntry4229938 ResolveLocalHurtPresentation(uint hurtEffectId)
|
||
{
|
||
if (!EnemyHit.HurtEffectCatalogLookup)
|
||
return new HurtEffectEntry4229938(hurtEffectId, LocalEnemyHurtImpactLegacy, 0u, 0u);
|
||
|
||
// 捐赠端:build 4229938 里 PhysicalAttackType 4/5 的精确 FireArmID 预设。
|
||
// 不重定向火箭/爆炸、近战、黑客或未知 ID。
|
||
if (EnemyHit.HurtEffectBulletOverride &&
|
||
hurtEffectId is 55004538 or 55004539 or >= 55004611 and <= 55004618
|
||
or 55004736 or 55004740 or 55004741)
|
||
{
|
||
// 只升不降(2026-09-26 Wave 1):作者行原样保留,Impact 抬到第一可见档。
|
||
// 7K 是整行换成 55001201 —— 见 HurtEffectBulletOverride 的注释。
|
||
var authoredGun = HurtEffectCatalog4229938.Find(hurtEffectId);
|
||
return new HurtEffectEntry4229938(
|
||
hurtEffectId,
|
||
Math.Max(2u, authoredGun?.Impact ?? 2u),
|
||
authoredGun?.LeftOrRight ?? 0u,
|
||
authoredGun?.SoundId ?? 0u);
|
||
}
|
||
|
||
var authored = HurtEffectCatalog4229938.Find(hurtEffectId);
|
||
return new HurtEffectEntry4229938(
|
||
hurtEffectId,
|
||
Math.Max(1u, authored?.Impact ?? 1u),
|
||
authored?.LeftOrRight ?? 0u,
|
||
authored?.SoundId ?? 0u);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private static async Task SendLocalEnemyHpBaselineAsync(RpcContext ctx, ulong uid, float maxHp)
|
||
{
|
||
try
|
||
{
|
||
await ctx.NotifyAsync(MethodId.SyncUnitAttrs, new SceneMethods.SyncUnitAttrs
|
||
{
|
||
unitId = uid,
|
||
attrs = new Dictionary<uint, float> { [1] = maxHp },
|
||
});
|
||
await ctx.NotifyAsync(MethodId.SyncUnitHp, new SceneMethods.SyncUnitHp { unitId = uid, hp = maxHp });
|
||
ctx.Session.Log.Info($"[LENEMY] hp baseline uid={uid} maxHp={maxHp:F0}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ctx.Session.Log.Warn($"[LENEMY] hp baseline send failed uid={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private static async Task SendLocalEnemyHpAsync(RpcContext ctx, ulong uid, float hp)
|
||
{
|
||
try
|
||
{
|
||
await ctx.NotifyAsync(MethodId.SyncUnitHp, new SceneMethods.SyncUnitHp { unitId = uid, hp = hp });
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ctx.Session.Log.Warn($"[LENEMY] hp send failed uid={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>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).
|
||
/// </summary>
|
||
private static async Task SendLocalEnemyStaggerAsync(RpcContext ctx, ulong uid)
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(LocalEnemyStiffDelayMs);
|
||
var runtime = GetLocalEnemyRuntimeState(ctx.Session);
|
||
bool send;
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
if (!runtime.Enemies.TryGetValue(uid, out var record) || record is null)
|
||
return;
|
||
|
||
// 击晕窗口内不推每击硬直:67149 会把 88300 倒地姿势顶掉,敌人看起来就是
|
||
// "一直播受击动画、没有击晕表现"。倒地期间本来也不该有受击反馈。
|
||
if (record.GroggyUntilTicks > 0)
|
||
return;
|
||
|
||
var now = Environment.TickCount64;
|
||
send = now - record.LastStiffTicks >= LocalEnemyStiffCooldownMs;
|
||
if (send)
|
||
record.LastStiffTicks = now;
|
||
}
|
||
|
||
if (!send)
|
||
return;
|
||
|
||
await ctx.NotifyAsync(MethodId.SyncEnemyPlayAction, new GameMethods.EnemyPlayActionData4229938
|
||
{
|
||
Uid = uid,
|
||
ActionId = LocalEnemyStaggerActionId,
|
||
MaxTime = 0f,
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ctx.Session.Log.Warn($"[LENEMY] stagger send failed uid={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>把倒地动作重新顶回尸体身上:SyncEnemyPlayAction(88300)。
|
||
///
|
||
/// 2026-09-23 实测(uid=35184372188842,近战连招致死):hp 归零后 250ms 服务端推了 88300,
|
||
/// 客户端也确实倒下了;但玩家近战连招没停,死后 3.6 s 客户端又上报了一次
|
||
/// <c>AskAgentEnterStiff</c>(16:11:59.670)—— 本地的命中反应把倒地动作顶掉,尸体站着等到
|
||
/// 4.2 s 后被 SyncClearSpoonEnemies 拆掉,玩家看到的就是"近战杀死敌人只会消失"。
|
||
/// 对照组 uid=35184372188839(远程)打完就停手,倒地完整播完。
|
||
///
|
||
/// 服务端拦不住客户端本地的命中反应,能做的只有把倒地动作顶回去:每次客户端在尸体上
|
||
/// 再进一次硬直,就重推一次 88300。节流 1.5 s(LocalEnemyDeathReassertCooldownMs)。</summary>
|
||
/// ⛑⛑ 2026-09-23 18:4x 重新打开(上一轮判它为"死通道"是<b>误判</b>,理由见下)。
|
||
/// 误判的来路:当时以 Player.log 里 "death pose already playing via playAction, skipping
|
||
/// restart" 一次都没出现为据,反推"服务端推的 88300 从来没落到客户端"。那条日志只在
|
||
/// 服务端<b>先</b>推过 88300(会写 __DRMK_DeathAct)时才可能打,而那一轮我们已经把
|
||
/// LocalEnemyPushDownAction 删掉了 —— 服务端压根没发,当然永远不打。是"没发"不是"没收"。
|
||
/// 反证在 Player.log 18:23:12:<c>injected 68039914 reader on …LTConfigWrap ok=true</c>
|
||
/// —— fastpatch 已经把 SyncEnemyPlayAction 缺的 midToReader / midToName 补齐,通道是通的。
|
||
///
|
||
/// 现在它同时也是对抗"受击顶掉死亡动画"的<b>唯一</b>服务端手段:客户端本地的命中反应走
|
||
/// native(SyncUnitHurtEffect 在这个 build 上没有 Lua impl,fastpatch 明确把它 left to
|
||
/// native),服务端既拦不住也关不掉,只能在事后把倒地动作顶回去。</summary>
|
||
private static readonly bool LocalEnemyDeathReassertEnabled = true;
|
||
|
||
internal static async Task ReassertLocalEnemyDeathAsync(TcpSession session, ulong uid)
|
||
{
|
||
if (!LocalEnemyDeathReassertEnabled)
|
||
return;
|
||
|
||
var runtime = GetLocalEnemyRuntimeState(session);
|
||
var now = Environment.TickCount64;
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
if (!runtime.PendingDeathClear.ContainsKey(uid))
|
||
return;
|
||
|
||
// 死亡后 LocalEnemyDeathQuietMs 内一律不补推。
|
||
// 2026-09-23 19:4x 实测:远程杀死敌人时倒地动作<b>放了两次</b>。原因就是这一段 ——
|
||
// 致死那一击本身会让客户端在尸体上再报一次 AskAgentEnterStiff(日志里 killed 与
|
||
// "stiff reported on a corpse" 常常同一秒),而 fastpatch 的 SyncRemoveNpc hook
|
||
// 那一刻刚播过 88300。两份 88300 相隔不到 1 s = 一次动作重启 = 玩家看到倒两次。
|
||
// 真正的"被顶掉"只可能发生在玩家继续连招之后,所以前 900 ms 的残留上报直接无视。
|
||
if (runtime.DeathEarliest.TryGetValue(uid, out var earliest)
|
||
&& now - (earliest - LocalEnemyMinCorpseMs) < LocalEnemyDeathQuietMs)
|
||
{
|
||
session.Log.Info($"[ENEMY] down pose re-push skipped uid={uid} (corpse still inside the quiet window)");
|
||
return;
|
||
}
|
||
|
||
if (runtime.LastDeathReassert.TryGetValue(uid, out var last)
|
||
&& now - last < LocalEnemyDeathReassertCooldownMs)
|
||
return;
|
||
runtime.LastDeathReassert[uid] = now;
|
||
}
|
||
|
||
await SendLocalEnemyDownPoseAsync(session, uid, "client re-stiffed a corpse");
|
||
}
|
||
|
||
/// <summary>把倒地动作 88300 推给一个已经在走死亡流程的单位(<b>不带节流</b>)。
|
||
/// 节流由调用方按自己的节奏做:AskAgentEnterStiff 上报走
|
||
/// ReassertLocalEnemyDeathAsync,周期扫描由 SweepLocalEnemyDeathClearsAsync 自己做。</summary>
|
||
private static async Task SendLocalEnemyDownPoseAsync(TcpSession session, ulong uid, string why)
|
||
{
|
||
try
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncEnemyPlayAction,
|
||
UxSerializer.Serialize(new GameMethods.EnemyPlayActionData4229938
|
||
{
|
||
Uid = uid,
|
||
ActionId = LocalEnemyDownActionId,
|
||
MaxTime = 0f,
|
||
}),
|
||
CancellationToken.None);
|
||
session.Log.Info($"[ENEMY] down pose re-push uid={uid} action={LocalEnemyDownActionId} ({why})");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[ENEMY] down pose re-push failed uid={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>虚弱窗口内把硬直姿势顶回去:SyncEnemyPlayAction(67149)。
|
||
///
|
||
/// 2026-09-23 17:1x 实测:破防后客户端只是站着(SyncEnemyDisarmState / SyncEnemyPoiseRate 在这个
|
||
/// build 里都是空实现),5 s 后照常挥击,玩家读作"打晕没有动画"。一次性动作撑不满 5 s 的窗口,
|
||
/// 所以由 MaintainLocalEnemyGroggyAsync 按 LocalEnemyGroggyReassertMs 周期重推,直到窗口关闭
|
||
/// (ExitLocalEnemyGroggyAsync 会清掉节流记录)。</summary>
|
||
internal static async Task SendLocalEnemyGroggyPoseAsync(TcpSession session, ulong uid)
|
||
{
|
||
try
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncEnemyPlayAction,
|
||
UxSerializer.Serialize(new GameMethods.EnemyPlayActionData4229938
|
||
{
|
||
Uid = uid,
|
||
ActionId = LocalEnemyGroggyActionId,
|
||
MaxTime = LocalEnemyGroggyMs / 1000f,
|
||
}),
|
||
CancellationToken.None);
|
||
session.Log.Info($"[ENEMY] groggy pose re-assert uid={uid} action={LocalEnemyGroggyActionId}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[ENEMY] groggy pose push failed uid={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>虚弱窗口内客户端又上报一次硬直时的<b>即时</b>补推(节流 LocalEnemyStiffNudgeMs)。
|
||
///
|
||
/// 为什么需要它:MaintainLocalEnemyGroggyAsync 只在 AI tick 上按 LocalEnemyGroggyReassertMs
|
||
/// 周期补推,而"倒地被顶掉"是客户端先知道的 —— 它受击命中后立刻上报 AskAgentEnterStiff。
|
||
/// 等周期到点再补,中间那一段敌人就是站着的(玩家读作"打晕没动画 / 只有挨打反应")。
|
||
/// 这里收到上报就补一次;与周期补推共用 LastGroggyReassert 节流表,两者不会叠成每帧一次。</summary>
|
||
internal static async Task ReassertLocalEnemyGroggyAsync(TcpSession session, ulong uid)
|
||
{
|
||
if (!LocalEnemyGroggyPoseEnabled)
|
||
return;
|
||
|
||
var runtime = GetLocalEnemyRuntimeState(session);
|
||
var now = Environment.TickCount64;
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
if (!runtime.Enemies.TryGetValue(uid, out var record) || record is null)
|
||
return;
|
||
if (record.GroggyUntilTicks == 0 || now >= record.GroggyUntilTicks)
|
||
return;
|
||
if (runtime.LastGroggyReassert.TryGetValue(uid, out var last)
|
||
&& now - last < LocalEnemyStiffNudgeMs)
|
||
return;
|
||
runtime.LastGroggyReassert[uid] = now;
|
||
}
|
||
|
||
await SendLocalEnemyGroggyPoseAsync(session, uid);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private static async Task LocalEnemyDeathSequenceAsync(RpcContext ctx, ulong uid, uint deadlySkillId, Vec3 deathPos)
|
||
{
|
||
try
|
||
{
|
||
var runtime = GetLocalEnemyRuntimeState(ctx.Session);
|
||
bool serverSpawned;
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
serverSpawned = runtime.Enemies.TryGetValue(uid, out var dying) && dying is not null && dying.ServerSpawned;
|
||
|
||
// 死在虚弱窗口里时必须立刻把窗口关掉,否则 MaintainLocalEnemyGroggyAsync 会继续
|
||
// 周期重推 67149,把这里马上要播的倒地动作顶掉(2026-09-23 17:3x,uid=…836:
|
||
// 17:30:08 致死、窗口到 17:30:12,结果只有"直接消失")。不推出场包——对一个
|
||
// 已经在走死亡流程的单位推 PoiseRate(1)/DisarmState(false) 没有意义。
|
||
if (dying is not null && dying.GroggyUntilTicks != 0)
|
||
{
|
||
dying.GroggyUntilTicks = 0;
|
||
ctx.Session.Log.Info($"[ENEMY] uid={uid} groggy window dropped — unit died inside it");
|
||
}
|
||
runtime.LastGroggyReassert.Remove(uid);
|
||
}
|
||
|
||
// ① 先让客户端把这个单位搬出战斗管线、进入死亡流程。
|
||
// ⛑⛑ 顺序是 2026-09-23 17:4x 用客户端 Player.log 定下来的:fastpatch 的 SyncRemoveNpc
|
||
// hook 每一次都成功播了 88300(17:43:51 / 17:44:27 / 17:44:49 / 17:45:07 四次
|
||
// "death PlayAction(88300) ok=true"),3 s 后 DestroyAgentUnit 也成功、单位确实 removed。
|
||
// 也就是说 hook 从来没坏过 —— 坏的是它挡不住客户端<b>原生的</b>受击反应:
|
||
// hook 只能关掉自己注入的那条 Lua hurt 路径(__DRMK_Dead 闸门),而玩家近战连招的
|
||
// 受击动作是 C# 原生管线演的("SyncUnitHurtEffect left to native" 那条日志)。
|
||
// 能让它停的只有"单位已经死了"这个状态,而建立这个状态的包是 SyncEnemyPrepareDie ——
|
||
// 之前它排在 SyncRemoveNpc 后面约 900 ms,那段时间里的连招照打,倒地一直被顶掉。
|
||
try
|
||
{
|
||
// RpcContext.NotifyAsync 只有两参重载(byte[] / 泛型),没有 CancellationToken 版。
|
||
await ctx.NotifyAsync(MethodId.SyncEnemyFightEdict,
|
||
UxSerializer.Serialize(new SceneMethods.SyncEnemyFightEdict { uid = uid, isGive = false }));
|
||
// 警戒状态机:发 None(0) 收起血条 + 清掉账本里的警戒字段。
|
||
// 等价于原来那句直接发 None,只是现在连账本一起清(GameRouter.EnemyWarning.cs)。
|
||
await ClearLocalEnemyWarningAsync(ctx.Session, uid);
|
||
|
||
// ②′ 2026-09-24 01:1x 回退:这段是 00:2x 加的(死亡前先解除虚弱再走死亡流程),
|
||
// 同一批改动一起撤掉 —— 用户对这批改动的评价是"死亡动画被我的攻击打断"。
|
||
// 23:27 的行为就是死亡序列不额外干预客户端的破防/虚弱状态,只靠
|
||
// DeathReassert + DeathQuietMs + MinCorpseMs 挡住死后继续推的受击包。
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ctx.Session.Log.Warn($"[LENEMY] death disengage failed uid={uid}: {ex.Message}");
|
||
}
|
||
|
||
await ctx.NotifyAsync(MethodId.SyncEnemyPrepareDie, new GameMethods.SyncEnemyPrepareDieArgs4229938
|
||
{
|
||
Id = uid,
|
||
DieInfo = new GameMethods.EnemyDieInfo4229938
|
||
{
|
||
HasDieAnimation = true,
|
||
HasDieEffect = true,
|
||
DieEffectId = 0,
|
||
DelayDestroyDistance = LocalEnemyDelayDestroyDistance,
|
||
LastHitHurtEffect = 0,
|
||
DeadlySkillId = deadlySkillId,
|
||
Killer = Profile.PlayerPid,
|
||
HasPlayedDeathSkill = false,
|
||
WeaponDropDestructibleId = 0,
|
||
DieType = GameMethods.DieType4229938.Damage,
|
||
WeaponId = 0,
|
||
},
|
||
});
|
||
ctx.Session.Log.Info($"[LENEMY] uid={uid} prepareDie sent (DieType=Damage)");
|
||
|
||
// ③′ 死亡标记:把单位打进 UnitStateConfig.DeadS(2)。
|
||
// 捐赠端 7k-v0.5(GameRouter.Enemy.cs:496-506)在 prepareDie + SyncUnitHp(0) 之后必发这一包,
|
||
// 注释原文:"HP alone only changes the client data/UI and does not enter
|
||
// UnitDeathBehaviorModule."
|
||
// ⇒ 只把血打到 0 是不够的:客户端的单位还是"活的",它原生的受击管线照常跑,
|
||
// 玩家后续的每一次命中都能重新抢到动作槽,把刚播起来的倒地/死亡动作顶掉
|
||
// (用户原话「敌人死亡不会自动无视我们的攻击 / 死亡动画会被普通攻击打断」)。
|
||
// 服务端这边早有闸门(dead && !shouldStartDeath 直接 return,一个包都不发),
|
||
// 但那挡的是**我们**发的包,挡不住客户端自己演 —— 能让它停手的只有
|
||
// "这个单位已经是 DeadS"这个状态本身。
|
||
// 可关:gameplay.combat.enemies.pushDeadUnitState(false = 回到 23:27 既有行为)。
|
||
if (EnemyHit.PushDeadUnitState)
|
||
{
|
||
await ctx.NotifyAsync(MethodId.SyncAddUnitState, UxSerializer.Serialize(
|
||
new GameMethods.SyncAddUnitStateArgs4229938
|
||
{
|
||
Id = uid,
|
||
State = LocalEnemyDeadUnitState,
|
||
EffectFreezeState = 0u,
|
||
}));
|
||
ctx.Session.Log.Info($"[LENEMY] uid={uid} addUnitState sent (DeadS={LocalEnemyDeadUnitState})");
|
||
}
|
||
|
||
// Register the corpse for teardown. The client normally answers with
|
||
// ReportEnemyPrepareFinish / ReportAgentDiePerfEnd, which completes the clear
|
||
// immediately; the deadline is the fallback so a dropped report cannot leave an
|
||
// immortal corpse behind (GameRouter.EnemyStiff.cs).
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
runtime.PendingDeathClear[uid] = Environment.TickCount64 + LocalEnemyDeathClearGraceMs;
|
||
// 尸体最短存活时间:客户端的死亡报告来得再早,也不许在这之前拆掉它。
|
||
runtime.DeathEarliest[uid] = Environment.TickCount64 + LocalEnemyMinCorpseMs;
|
||
}
|
||
|
||
// ② 死亡动作的播放点 + 尸体销毁定时器。hook 里 PlayAction(88300) 之后 3 s DestroyAgentUnit。
|
||
// 服务端不再自己推 88300(LocalEnemyPushDownAction = false):hook 的注释明写
|
||
// "the restart is what makes the corpse snap upright before it drops" —— 两个 88300
|
||
// 相隔几百毫秒就是一次动作重启,倒地被自己打断。
|
||
await Task.Delay(LocalEnemyRemoveNpcDelayMs);
|
||
await ctx.NotifyAsync(MethodId.SyncRemoveNpc, new GameMethods.SyncRemoveNpcData4229938
|
||
{
|
||
NpcId = unchecked((int)uid),
|
||
TaskId = 0,
|
||
EnemyId = uid,
|
||
});
|
||
ctx.Session.Log.Info($"[ENEMY] uid={uid} removeNpc sent (death-beat trigger, serverSpawned={serverSpawned})");
|
||
|
||
// SyncRemoveNpc 已经在序列开头发过(见那里关于"命中闸门"的说明),这里不再发。
|
||
// 剩余等待按 DropDelay 从头算:hook 的销毁定时器是 3 s,3.5 s 兜底拆尸体刚好在其后。
|
||
await Task.Delay(LocalEnemyDropDelayMs);
|
||
|
||
await ctx.NotifyAsync(MethodId.SyncEnemyEndItemDrop, new GameMethods.EnemyEndItemDropData4229938
|
||
{
|
||
EnemyInstanceId = uid,
|
||
BindItemsIndex = -1,
|
||
ItemRotation = new SceneMethods.UxVector3(),
|
||
Position = new SceneMethods.UxVector3(deathPos.X, deathPos.Y, deathPos.Z),
|
||
});
|
||
|
||
// ★ T1-17:死亡后卸载战斗模块。必须等死亡特效播完(3 s 起 / 5.3 s 完)才能发,
|
||
// 所以排在 prepareDie + DropDelay(3500) 之后再等 LocalEnemyDetachBattleUnitDelayMs。
|
||
// ⛑ 只有这条「服务端驱动的完整死亡序列」发;客户端自己上报的那条路径
|
||
//(AskEnemyStartFall,本文件另一处)**故意不发** —— 那里在回执之后还要占住 handler,
|
||
// 多等几秒会把该 session 的后续包排队,收益不抵风险。
|
||
if (LocalEnemyDetachBattleUnitDelayMs > 0)
|
||
{
|
||
await Task.Delay(LocalEnemyDetachBattleUnitDelayMs);
|
||
await ctx.NotifyAsync(MethodId.SyncDetachBattleUnit, new SceneMethods.SyncDetachBattleUnit { id = uid });
|
||
ctx.Session.Log.Info($"[LENEMY] uid={uid} detachBattleUnit sent");
|
||
}
|
||
|
||
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(uid));
|
||
ctx.Session.Log.Info($"[LENEMY] uid={uid} endItemDrop+logicAgentLeave sent");
|
||
|
||
// (SyncRemoveNpc 已经统一在上面发过,这里不再按 serverSpawned 分流。)
|
||
|
||
// THE ACTUAL REMOVAL. SyncEnemyPrepareDie only asked the client to play the death;
|
||
// SyncClearSpoonEnemies is what tears the unit down. Without it the corpse keeps
|
||
// standing with an empty bar and every later hit on it is ignored (its ledger entry was
|
||
// dropped here), which is exactly the "it came back and I cannot kill it" report.
|
||
// Also drops the ledger entry — idempotent, see CompleteLocalEnemyDeathClearAsync.
|
||
await CompleteLocalEnemyDeathClearAsync(ctx.Session, uid, "death-sequence");
|
||
ctx.Session.Log.Info($"[ENEMY] uid={uid} death sequence complete (clearSpoonEnemies{(serverSpawned ? "" : "+removeNpc")})");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ctx.Session.Log.Warn($"[LENEMY] death sequence failed uid={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Client-reported combat C2S. These are accepted for observability: nothing here
|
||
// changes server state except the fall handshake, and each one logs what the client
|
||
// itself believes happened. All arguments are read defensively.
|
||
// ------------------------------------------------------------------
|
||
|
||
/// <summary>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.</summary>
|
||
private static ulong TryReadUnitId(UxRpcMessage msg)
|
||
{
|
||
try
|
||
{
|
||
return msg.GetArgs<GameMethods.EnemyIdData4229938>().EnemyId;
|
||
}
|
||
catch
|
||
{
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private static string RawBody(UxRpcMessage msg)
|
||
{
|
||
var body = msg.Body;
|
||
if (body.Length == 0)
|
||
return "<empty>";
|
||
var shown = Math.Min(body.Length, 64);
|
||
return $"{body.Length}b {Convert.ToHexString(body, 0, shown)}" + (body.Length > shown ? "..." : "");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.</summary>
|
||
[Handler(MethodId.AskPlayHurtEffect)]
|
||
private async Task LocalEnemyHurtEffectReport(Connection conn, UxRpcMessage msg)
|
||
{
|
||
// LY-3.3 parity: the client reports the hurt effect it expects to see on the
|
||
// target and the server echoes it back as SyncUnitHurtEffect. It was a log-only
|
||
// stub here, which dropped the one round trip the reference build uses to drive
|
||
// the visible hit reaction (GameRouter.Enemy.cs:835 in the LY-3.3 source).
|
||
try
|
||
{
|
||
var args = msg.GetArgs<SceneMethods.SkillHitData>();
|
||
if (args.HitTarget != 0)
|
||
{
|
||
conn.Log.Info($"[LENEMY] AskPlayHurtEffect target={args.HitTarget} hurtEffect={args.HurtEffectId} skill={args.SkillId}");
|
||
|
||
// 通缉系统挂钩(2026-09-23):玩家打中一个单位 ⇒ 记一次 npc-hit。
|
||
// attackerId 传 0(= 按玩家算):这条上报里的 ReleaserId 可能是 pid 而不是
|
||
// ActiveSpiritUnitId,拿它去比对会把所有命中都过滤掉。玩家自己挨打的情况由
|
||
// RegisterWantedCrimeIncidentAsync 自己按 targetId == playerId 排除。
|
||
await RegisterWantedCrimeIncidentAsync(
|
||
conn.Session, "npc-hit", args.HitTarget, 0, msg.Context.CancellationToken);
|
||
|
||
// 2026-09-26:与 SendLocalEnemyHurtEffectAsync 同源,Impact / LeftOrRight 查表。
|
||
var reportPresentation = ResolveLocalHurtPresentation(args.HurtEffectId);
|
||
await conn.Session.NotifyAsync(MethodId.SyncUnitHurtEffect,
|
||
UxSerializer.Serialize(new GameMethods.SyncUnitHurtEffectData4229938
|
||
{
|
||
HurterId = args.HitTarget,
|
||
AttackerId = args.ReleaserId != 0 ? args.ReleaserId : Profile.PlayerPid,
|
||
HurtId = reportPresentation.Id,
|
||
SkillId = args.SkillId,
|
||
HurtEffectFacePos = new SceneMethods.UxVector3(
|
||
args.ClientHitPosNormalDir.X,
|
||
args.ClientHitPosNormalDir.Y,
|
||
args.ClientHitPosNormalDir.Z),
|
||
HurtImpact = reportPresentation.Impact,
|
||
HurtLeftOrRightHit = reportPresentation.LeftOrRight,
|
||
}), CancellationToken.None);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
conn.Log.Info($"[LENEMY] AskPlayHurtEffect raw (parse failed: {ex.Message}) {RawBody(msg)}");
|
||
}
|
||
|
||
if (msg.IsInvoke)
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[Handler(MethodId.AskEnemyStartFall)]
|
||
private async Task LocalEnemyStartFall(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var uid = TryReadUnitId(msg);
|
||
var isInvoke = msg.IsInvoke;
|
||
if (uid == 0)
|
||
{
|
||
if (isInvoke)
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
var runtime = GetLocalEnemyRuntimeState(conn.Session);
|
||
bool tracked;
|
||
bool own;
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
tracked = runtime.Enemies.TryGetValue(uid, out var record) && record is not null;
|
||
own = false;
|
||
if (tracked)
|
||
{
|
||
own = !record!.DeathStarted;
|
||
record.Dead = true;
|
||
record.DeathStarted = true;
|
||
}
|
||
}
|
||
|
||
if (!tracked)
|
||
{
|
||
conn.Log.Info($"[LENEMY] AskEnemyStartFall uid={uid} (not tracked, skipped)");
|
||
if (isInvoke)
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
conn.Log.Info($"[LENEMY] AskEnemyStartFall uid={uid} — client death reported, {(own ? "owning" : "re-answering")} the sequence");
|
||
|
||
// 通缉系统挂钩(2026-09-23):被我们跟踪的单位死了 ⇒ 记一次 npc-kill。
|
||
// attackerId 传 0 —— 这条上报里没有攻击者,RegisterWantedCrimeIncidentAsync 会按玩家算。
|
||
await RegisterWantedCrimeIncidentAsync(
|
||
conn.Session, "npc-kill", uid, 0, msg.Context.CancellationToken);
|
||
|
||
await msg.Context.NotifyAsync(MethodId.SyncEnemyPlayAction, new GameMethods.EnemyPlayActionData4229938
|
||
{
|
||
Uid = uid,
|
||
ActionId = LocalEnemyDownActionId,
|
||
MaxTime = 0f,
|
||
});
|
||
await Task.Delay(LocalEnemyDeathBeatDelayMs);
|
||
await msg.Context.NotifyAsync(MethodId.SyncEnemyPrepareDie, new GameMethods.SyncEnemyPrepareDieArgs4229938
|
||
{
|
||
Id = uid,
|
||
DieInfo = new GameMethods.EnemyDieInfo4229938
|
||
{
|
||
HasDieAnimation = true,
|
||
HasDieEffect = true,
|
||
DieEffectId = 0,
|
||
DelayDestroyDistance = LocalEnemyDelayDestroyDistance,
|
||
LastHitHurtEffect = 0,
|
||
DeadlySkillId = 0,
|
||
Killer = Profile.PlayerPid,
|
||
HasPlayedDeathSkill = false,
|
||
WeaponDropDestructibleId = 0,
|
||
DieType = GameMethods.DieType4229938.Damage,
|
||
WeaponId = 0,
|
||
},
|
||
});
|
||
// 同样登记尸体待拆:这条路径是客户端自己报的死亡,没有走 LocalEnemyDeathSequenceAsync,
|
||
// 不登记就没人发 SyncClearSpoonEnemies("尸体还在"的同一条根因)。
|
||
lock (runtime.SyncRoot)
|
||
runtime.PendingDeathClear[uid] = Environment.TickCount64 + LocalEnemyDeathClearGraceMs;
|
||
if (isInvoke)
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The client finished the fall. Drop the loot and remove the unit, then forget it.
|
||
/// Both packet kinds, empty OK on invoke.
|
||
/// </summary>
|
||
[Handler(MethodId.AskEnemyEndFall)]
|
||
private async Task LocalEnemyEndFall(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var uid = TryReadUnitId(msg);
|
||
var isInvoke = msg.IsInvoke;
|
||
if (uid == 0)
|
||
{
|
||
if (isInvoke)
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
var runtime = GetLocalEnemyRuntimeState(conn.Session);
|
||
bool tracked;
|
||
bool serverSpawned = false;
|
||
var deathPos = new Vec3();
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
tracked = runtime.Enemies.TryGetValue(uid, out var record) && record is not null;
|
||
if (tracked)
|
||
{
|
||
serverSpawned = record!.ServerSpawned;
|
||
deathPos = record.Position;
|
||
runtime.Enemies.Remove(uid);
|
||
}
|
||
}
|
||
|
||
if (!tracked)
|
||
{
|
||
if (isInvoke)
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
return;
|
||
}
|
||
|
||
var endTag = serverSpawned ? "[ENEMY]" : "[LENEMY]";
|
||
conn.Log.Info($"{endTag} AskEnemyEndFall uid={uid} — drop + leave{(serverSpawned ? "" : " + removeNpc")}");
|
||
await msg.Context.NotifyAsync(MethodId.SyncEnemyEndItemDrop, new GameMethods.EnemyEndItemDropData4229938
|
||
{
|
||
EnemyInstanceId = uid,
|
||
BindItemsIndex = -1,
|
||
ItemRotation = new SceneMethods.UxVector3(),
|
||
Position = new SceneMethods.UxVector3(deathPos.X, deathPos.Y, deathPos.Z),
|
||
});
|
||
await msg.Context.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(uid));
|
||
|
||
// SyncRemoveNpc is the local-unit destroy path (see the death sequence above). A
|
||
// server-spawned agent is a logic agent and is already gone via LogicAgentLeave.
|
||
if (!serverSpawned)
|
||
{
|
||
await msg.Context.NotifyAsync(MethodId.SyncRemoveNpc, new GameMethods.SyncRemoveNpcData4229938
|
||
{
|
||
NpcId = unchecked((int)uid),
|
||
TaskId = 0,
|
||
EnemyId = uid,
|
||
});
|
||
}
|
||
if (isInvoke)
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>Fall handshake acknowledgement point — logged, and always acknowledged.
|
||
/// Both packet kinds, empty OK on invoke.</summary>
|
||
[Handler(MethodId.AskEnemyFallGround)]
|
||
private async Task LocalEnemyFallGround(Connection conn, UxRpcMessage msg)
|
||
{
|
||
conn.Log.Info($"[LENEMY] AskEnemyFallGround uid={TryReadUnitId(msg)}");
|
||
if (msg.IsInvoke)
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[Handler(MethodId.AskEnemyUseClientSkill)]
|
||
private async Task LocalEnemyUseClientSkill(Connection conn, UxRpcMessage msg)
|
||
{
|
||
conn.Log.Info($"[LENEMY] client-AI cast raw {RawBody(msg)}");
|
||
if (msg.IsInvoke)
|
||
await conn.ReturnEmptyOkAsync(msg);
|
||
}
|
||
}
|