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; /// /// Urban-job (职业) handshakes — the gate every job app walks through before its own panel opens. /// /// Why this file exists: the courier app's start button never got past the "领取驾照" screen because /// the RPC behind it was not served at all. DeliveryAPPBeginPanelStore.OnStartClick sends /// AskStartJob as an INVOKE and only opens the order board from inside its callback, so an /// unhandled method is not a silent no-op — the callback never fires and the button looks dead /// (hard rule 94). Same shape for AskQuitJob (离职) and AskAcceptTask (the 晋升 button /// on the job template, DeliveryAPPBeginPanelStore.OnPromoteClick). /// /// ⛑ Client-side polarity check (this is why these return bare Ok rather than a payload): /// every caller compares against LTConfig.MessageConfig.Ok and treats a MATCH as the error /// branch — if errorId == Ok then DisplayServerMessageId(errorId); return end. So the success /// path is any code that is NOT Ok, which is what ReturnEmptyOkAsync emits (0). /// /// These are deliberately stateless: the client only needs the ack to advance. Persisting an /// "on duty" flag would be dead weight until a real job-progress model exists. /// internal sealed partial class GameRouter { /// JobClass of the courier career; the only class this build of the app asks for. private const uint UrbanJobClassDelivery = 11300005u; // ----------------------------------------------------------------------------------------- // ⛑⛑ 2026-09-29 修正:AskStartJob / AskQuitJob **不是 void**。 // 客户端 `midToReturnMessageReader[63441623 / 63990693]` 都是 // `Base.ReadComplex(reader, Auto.Reader[435])`,而原实现走 `ReturnEmptyOkAsync` // —— 那个是 **0 字节载荷**(Connection.cs:32 `Array.Empty()`)⇒ 客户端读空 complex。 // 实测 [JOB] 每个会话 4 次左右(入职/离职按钮),属于稳定触发。 // // Reader[435] 的 wire(逐层从 `RPCDeserializeAuto.lua` 钉死,共 33 字节): // FF complex presence(SerializeObjectMarkNull 实测是 0xFF, // 反编译常量写的 0 是**假的**,按记忆 §5 以实测包体为准) // Job u32 // Exp u32 // Level u8 // RegisterTime u32 // UnregisterTime u32 // TalentInfo = Auto.Dispatch[1521]: // Dispatch 读 1 字节 tp,要求 == 0xFF,再用 `Concrete[1521][tp]` 选 reader; // Concrete[1521] 只有 [1]/[2] 与 __index 兜底 ⇒ [0xFF] 走 __index ⇒ Auto.Reader[1521] // ⇒ 等价于 `FF` + Reader[1521] // Reader[1521] = TalentPoint u32 + 两个 ReadDict(u32 → complex) // ⛑ ReadDict 计数是 **Int32、无 +1 偏置**(RPCDeserializeBase.lua:162) // ⇒ 空字典 = `FF 00000000`(5 字节),不是 Int7 的 `FF 01` // ⛑ 回滚:把两个 handler 的 return 换回 `conn.ReturnEmptyOkAsync(msg)`。 // ----------------------------------------------------------------------------------------- private static byte[] BuildJobInfoPayload(uint jobClass, byte level, uint registerTime, uint unregisterTime) { var w = new UxWriter(); w.U8(0xFF); // Complex[435] presence w.U32(jobClass); // Job w.U32(0); // Exp w.U8(level); // Level w.U32(registerTime); // RegisterTime w.U32(unregisterTime); // UnregisterTime // TalentInfo = Auto.Dispatch[1521] w.U8(0xFF); // presence w.U32(0); // TalentPoint w.U8(0xFF); w.I32(0); // UnlockTalentInfoDict(空字典) w.U8(0xFF); w.I32(0); // TalentTreeRecordDict(空字典) return w.ToArray(); } private static uint NowUnixSeconds() => (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); /// /// AskStartJob(63441623) — IN(u32 jobClassId) → Reader[435] 的 JobInfo。 /// Sent by the "领取驾照 / 开始接单" button of every job app. The callback is what opens the /// panel, so answering is the whole feature. /// [Handler(MethodId.AskStartJob, HandlerPacketKind.Invoke)] private async Task AskStartJob(Connection conn, UxRpcMessage msg) { // ★ 捐赠端 GameRouter.CareerApps.cs:48-49:里希开始职业 ⇒ 发布警察运行时 // (S002 root + 职业 + F 按钮)。没有这一步,整条盘查链根本不会启动。 var s0 = GetStateIfExists(conn.Session); if (s0 is not null && s0.ActiveSpiritTemplateId == LixiSpirit4229938) await PublishLixiPolice4229938(conn.Session, s0.ActiveSpiritTemplateId); var jobClass = JobClassOf(msg); var payload = BuildJobInfoPayload(jobClass, 1, NowUnixSeconds(), 0u); conn.Log.Info($"[JOB] startJob class={jobClass} " + $"{(jobClass == UrbanJobClassDelivery ? "(delivery)" : "(other)")} " + $"-> jobInfo {payload.Length}B"); await conn.ReturnRawAsync(msg, payload); } /// /// AskQuitJob(63990693) — IN(u32 jobClassId) → Reader[435] 的 JobInfo。 /// The 离职 button; the panel closes itself from the callback, so leaving this unhandled would /// strand the confirm dialog. /// [Handler(MethodId.AskQuitJob, HandlerPacketKind.Invoke)] private async Task AskQuitJob(Connection conn, UxRpcMessage msg) { // ★ 捐赠端 GameRouter.CareerApps.cs:里希结束职业 ⇒ StopLixiPolice。 var s0 = GetStateIfExists(conn.Session); if (s0 is not null && s0.ActiveSpiritTemplateId == LixiSpirit4229938) await StopLixiPolice4229938(conn.Session); var jobClass = JobClassOf(msg); var payload = BuildJobInfoPayload(jobClass, 0, 0u, NowUnixSeconds()); conn.Log.Info($"[JOB] quitJob class={jobClass} -> jobInfo {payload.Length}B"); await conn.ReturnRawAsync(msg, payload); } /// /// AskAcceptTask(63422116) — IN(u32 taskId), void. Reached by the job template's 晋升 button /// (OnPromoteClick) once the promotion task is in Accepted. There is no task /// catalogue behind this build, so the task id is echoed for the log and nothing is persisted. /// [Handler(MethodId.AskAcceptTask, HandlerPacketKind.Invoke)] private Task AskAcceptTask(Connection conn, UxRpcMessage msg) { var taskId = JobClassOf(msg); conn.Log.Info($"[JOB] acceptTask id={taskId} (no task catalog — ack only)"); return conn.ReturnEmptyOkAsync(msg); } /// All three bodies are a single u32, written with WritePrimitive (no marker). private static uint JobClassOf(UxRpcMessage msg) => msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u; }