using System.Buffers.Binary; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.Protocol.Client4229938; namespace Ananta.Server.Handlers.Game; /// /// 巡卫官(里希 / 15021021)盘查链路的最后一步 —— 开罚单出结果。 /// /// /// 客户端 PoliceExamineManager.lua:1163 gPoliceJobManager.cs:AskPoliceFine(pid, fineIdList) /// ⇒ C2S AskPoliceFineNpc(63548238) /// ⇒ 服务端必须回 S2C ShowReceiveRewardDetail(64145515) 且 Reason == ItemReason.PoliceFine(180001) /// ⇒ GameToClientImpl.lua:266 gPoliceJobManager:OnDropPoliceJobExp(msg.Reward) /// ⇒ PoliceExamineManager.lua:618 填 fineMoney / JobExpInfo /// ⇒ PoliceExamineManager.lua:601 ShowFineResult(罚款结果面板) /// /// /// ⛑⛑ 为什么之前"开罚单没结果":AskPoliceFineNpc 既没进 /// Enabled4229938MethodIds(硬规则 94)也没 handler ⇒ 客户端 await 卡死, /// 服务端从来没回过掉落帧 ⇒ needShowFineResult=true 但 OnDropPoliceJobExp /// 永远不触发 ⇒ ShowFineResult 一次都没被调用。 /// /// ⛑ 反编译陷阱(又是同一条,见已作废结论): /// GameToClientImpl.lua:266 里写的是 if msg.Reason ~= ItemReason.PoliceFine then, /// 字面读是"非罚款才回调",但同一函数里 if msg.Reason ~= Mall or msg.Reason ~= MallBundle /// 恒为真会无条件 return ⇒ 字面读整段逻辑自相矛盾 ⇒ 反编译器把 == 翻成了 ~=。 /// 真意:Reason == PoliceFine 时才走 OnDropPoliceJobExp。 /// /// ItemReason.PoliceFine = 180001(dump.cs:1737137,enum ItemReason TypeDefIndex:32206)。 /// /// 回滚 = 删本文件 + 从 GameRouter.cs 白名单删掉 MethodId.AskPoliceFineNpc。 /// internal sealed partial class GameRouter { /// ItemReason.PoliceFine(dump.cs enum ItemReason)。唯一能让罚款结果弹出来的 Reason。 internal const int ItemReasonPoliceFine = 180001; /// /// UrbanJobJobClassConfig.Police —— NCCA Officer / Detective / Inspector 的 JobClass /// (UrbanJobConfig.json 里 Id 301/302/303 三条 JobClass 全是 11300003, /// 里希 DefaultUrbanJob=301)。JobExpInfo 的 key 就是它。 /// internal const uint PoliceJobClass = 11300003; // ================================================================================= // ① C2S AskPoliceFineNpc(63548238) // ================================================================================= /// /// 玩家在盘查面板里选完罪名、点「开罚单」时由 C# 侧发出来的那条。 /// /// 形状:AskPoliceFine(ulong pid, object data)(dump.cs:673124),data 是 int[]。 /// 极性 CSharpOnly ⇒ lua 没有 writer 可抄、count 编码不确定 ⇒ 不硬解析, /// 只把前 8 字节当 u64 pid 记日志,其余原样打 hex 留作取证。 /// /// ⛑ 用 HandlerPacketKind.Both:INVOKE / NOTIFY 都不能漏。 /// INVOKE 不回执 ⇒ 客户端 await 到超时(硬规则 94 的同一类坑)。 /// [Handler(MethodId.AskPoliceFineNpc, HandlerPacketKind.Both)] private async Task AskPoliceFineNpc(Connection conn, UxRpcMessage msg) { var hex = msg.Body.Length > 0 ? Convert.ToHexString(msg.Body) : "(empty)"; ulong pid = msg.Body.Length >= 8 ? BinaryPrimitives.ReadUInt64LittleEndian(msg.Body.AsSpan(0, 8)) : 0; // 先回执:这一条比什么都重要(INVOKE 不回 = 面板点不动)。 await conn.ReturnEmptyOkAsync(msg); msg.Context.Session.Log.Info( $"[POLICE-FINE] AskPoliceFineNpc(63548238) pid={pid} bytes={msg.Body.Length} hex={hex}"); // 立刻回掉落帧:Reason=PoliceFine ⇒ 客户端弹罚款结果。 var (ok, message) = await PushFineRewardAsync(msg.Context.Session, DefaultFineMoney, DefaultFineJobExp); msg.Context.Session.Log.Info($"[POLICE-FINE] ShowReceiveRewardDetail(64145515) → ok={ok} {message}"); } internal const double DefaultFineMoney = 500; internal const uint DefaultFineJobExp = 50; // ================================================================================= // ② S2C ShowReceiveRewardDetail(64145515) —— 手搓字节 // ================================================================================= /// /// 推一帧 ShowReceiveRewardDetail(64145515, LuaOnly),Reason = PoliceFine(180001)。 /// /// 载荷(照 Auto.Reader[308] / Auto.Reader[500],RPCDeserializeAuto.lua:37641 / 37521): /// /// int32 Reason = 180001 /// uint32 RewardTemplate = 0 /// list FirstItemInfo = FF + int32 count + uint32* (空) /// cplx ExtraInfo = 00(null —— 必须 null!DropManager.lua:126 见 ExtraInfo 非空 /// 就会拿 ExtraInfo.Pos 判"掉落物在地上"并 return,罚款结果就没了) /// dict Reward = FF + int32 count + { byte key, cplx RewardDetail } /// /// RewardDetail(Reader[500])28 个字段全按声明顺序写,空的集合一律 FF + int32 0 /// (Serializer.ReadList / ReadDict = peek byte FF + 原始 int32 count,不带 +1 偏置; /// 只有 ReadList7Bit / ReadDict7Bit 才走 Int7 +1 偏置)。 /// SpiritTalentExpInfo 是 ReadComplex ⇒ 写 00。 /// /// ⛑⛑ 关键判据(PoliceExamineManager.lua:618): /// /// fineMoney = Σ RewardDetail.Money /// if RewardDetail.JobExpInfo[Police] then JobExpInfo = RewardDetail.JobExpInfo end /// if JobExpInfo or fineMoney <= 0 then ShowFineResult() end /// /// ⇒ JobExpInfo 里必须命中 Police(11300003),否则 money>0 时永远不弹。 /// 所以这里 money 与 exp 都给,双保险。 /// internal static async Task<(bool Ok, string Message)> PushFineRewardAsync( TcpSession session, double money, uint jobExp, CancellationToken token = default) { var bytes = BuildFineRewardWire(money, jobExp); await session.NotifyAsync(MethodId.ShowReceiveRewardDetail, bytes, token); return (true, $"ShowReceiveRewardDetail(64145515) Reason=PoliceFine(180001) " + $"money={money} jobExp[{PoliceJobClass}]={jobExp} bytes={bytes.Length}"); } /// 照 Auto.Reader[308] / Auto.Reader[500] 手搓 ShowReceiveRewardDetail。 internal static byte[] BuildFineRewardWire(double money, uint jobExp) { var w = new UxWriter(); // ---- Reader[308] ---- w.I32(ItemReasonPoliceFine); w.U32(0); // RewardTemplate = 0 ⇒ DropManager 回落 DEFAULT_SHOW_TYPE EmptyList(w); // FirstItemInfo : list w.U8(0x00); // ExtraInfo : null(必须!见上面注释) // ---- Reward : dict ---- w.U8(0xFF); w.I32(1); w.U8(1); // key = 1 w.U8(0xFF); // complex present // ---- Reader[500] RewardDetail ---- EmptyDict(w); // 1 DropIdCnt dict w.U32(0); // 2 ReasonTextId w.Bool(false); // 3 FactionMerge w.F64(money); // 4 Money w.F64(0); // 5 Gold w.F64(0); // 6 BindingGold w.F64(0); // 7 FreeGold EmptyList(w); // 8 Items list EmptyList(w); // 9 UrbanAbilityInfo list EmptyList(w); // 10 UrbanAbilities list EmptyDict(w); // 11 AbilityExpInfo dict EmptyDict(w); // 12 FanInfo dict EmptyDict(w); // 13 FactionDispositionInfo dict EmptyDict(w); // 14 FactionInfluenceInfo dict // 15 JobExpInfo dict —— ★罚款结果能不能弹就看这里 w.U8(0xFF); w.I32(1); w.U32(PoliceJobClass); w.U32(jobExp); EmptyDict(w); // 16 NpcFavors dict EmptyDict(w); // 17 OriginalNpcFavors dict w.F32(0); // 18 Popularity float w.U32(0); // 19 EyeCoinRewardCount w.U32(0); // 20 CommonSpiritTalentExp w.U8(0x00); // 21 SpiritTalentExpInfo complex → null EmptyList(w); // 22 WeaponList list EmptyList(w); // 23 RumorIds list EmptyDict(w); // 24 BattlePassExpInfo dict EmptyDict(w); // 25 ExtractionShooterInfo dict EmptyDict(w); // 26 SeasonProgressInfo dict EmptyList(w); // 27 FishingFishRewards list EmptyList(w); // 28 FishingGearRewards list return w.ToArray(); } /// Serializer.ReadList 的空集合:peek FF + 原始 int32 count = 0。 private static void EmptyList(UxWriter w) { w.U8(0xFF); w.I32(0); } /// Serializer.ReadDict 的空字典:peek FF + 原始 int32 count = 0。 private static void EmptyDict(UxWriter w) { w.U8(0xFF); w.I32(0); } }