131 lines
7.2 KiB
C#
131 lines
7.2 KiB
C#
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;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 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. <c>DeliveryAPPBeginPanelStore.OnStartClick</c> sends
|
|||
|
|
/// <c>AskStartJob</c> 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 <c>AskQuitJob</c> (离职) and <c>AskAcceptTask</c> (the 晋升 button
|
|||
|
|
/// on the job template, <c>DeliveryAPPBeginPanelStore.OnPromoteClick</c>).
|
|||
|
|
///
|
|||
|
|
/// ⛑ Client-side polarity check (this is why these return bare Ok rather than a payload):
|
|||
|
|
/// every caller compares against <c>LTConfig.MessageConfig.Ok</c> and treats a MATCH as the error
|
|||
|
|
/// branch — <c>if errorId == Ok then DisplayServerMessageId(errorId); return end</c>. So the success
|
|||
|
|
/// path is any code that is NOT Ok, which is what <c>ReturnEmptyOkAsync</c> 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.
|
|||
|
|
/// </summary>
|
|||
|
|
internal sealed partial class GameRouter
|
|||
|
|
{
|
|||
|
|
/// <summary>JobClass of the courier career; the only class this build of the app asks for.</summary>
|
|||
|
|
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<byte>()`)⇒ 客户端读空 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();
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// AskStartJob(63441623) — IN(u32 jobClassId) → <c>Reader[435]</c> 的 JobInfo。
|
|||
|
|
/// Sent by the "领取驾照 / 开始接单" button of every job app. The callback is what opens the
|
|||
|
|
/// panel, so answering is the whole feature.
|
|||
|
|
/// </summary>
|
|||
|
|
[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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// AskQuitJob(63990693) — IN(u32 jobClassId) → <c>Reader[435]</c> 的 JobInfo。
|
|||
|
|
/// The 离职 button; the panel closes itself from the callback, so leaving this unhandled would
|
|||
|
|
/// strand the confirm dialog.
|
|||
|
|
/// </summary>
|
|||
|
|
[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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// AskAcceptTask(63422116) — IN(u32 taskId), void. Reached by the job template's 晋升 button
|
|||
|
|
/// (<c>OnPromoteClick</c>) once the promotion task is in <c>Accepted</c>. There is no task
|
|||
|
|
/// catalogue behind this build, so the task id is echoed for the log and nothing is persisted.
|
|||
|
|
/// </summary>
|
|||
|
|
[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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>All three bodies are a single u32, written with <c>WritePrimitive</c> (no marker).</summary>
|
|||
|
|
private static uint JobClassOf(UxRpcMessage msg)
|
|||
|
|
=> msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u;
|
|||
|
|
}
|