using System.Buffers.Binary; using Ananta.SDK.Rpc; using Ananta.Server.Protocol.Client4229938; using Auto = Ananta.Server.RpcTypes.Client4229938.Auto; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// GM 控制台(ALT+F1)职业命令族 —— GmJobTake / GmJobStart / GmJobQuit / /// GmJobPromote / GmSetJobLevel / GmJobEnd。 /// /// 移植自捐赠端 DremkaPS(2026-09-24)。他们这一族里 GmTaskRecord4229938 的 TaskState 字节 /// 标了 "verified in game",是整份捐赠材料里极少数实机验证过的东西;其余(钱包 / 台词 / 过场 / /// 敌群)捐赠端自己承认"排队做完、都还没实机验证"。 /// /// ⛑ 三条本服的取舍,与捐赠端不同: /// 1. 推送是「合并」不是「替换」。捐赠端把 spiritJobs 整个换成 GM 记录,本服则把它 /// 叠加在 配置驱动的那份之上 —— /// 否则玩家在控制台点一条职业命令,角色原本的职业就整片消失。 /// 2. CurrentJob 默认 100(Jobless)不是 0。0 不是合法 UrbanJobConfig id,客户端 /// SpiritJobManager 会去解引用一个不存在的配置(本服踩过,见 /// RuntimePayloadFactory.DefaultSpiritJobInfo4229938 的注释)。 /// 3. 职业记录带天赋。捐赠端因无表而填 TalentInfo = null / RegisterTime = 0; /// 本服统一走 ,与本服 7k 验证过的 wire 一致。 /// /// 捐赠端注释还点明了一件关键事实:客户端自己在本地生成 GM 实体,GM invoke 的调用点根本不看 /// 返回值。所以这些 handler 的价值是「服务端认得这条指令、不再掉进 unknown-methods.log,并且 /// 能把职业变化真的推给客户端」,而不是「返回一个客户端会用的值」。 /// internal sealed partial class GameRouter { [Handler(MethodId.GmJobTake)] private Task GmJobTake(Connection conn, UxRpcMessage msg) => HandleGmJobAsync(conn, msg, GmJobAction4229938.Take); [Handler(MethodId.GmJobStart)] private Task GmJobStart(Connection conn, UxRpcMessage msg) => HandleGmJobAsync(conn, msg, GmJobAction4229938.Start); [Handler(MethodId.GmJobQuit)] private Task GmJobQuit(Connection conn, UxRpcMessage msg) => HandleGmJobAsync(conn, msg, GmJobAction4229938.Quit); [Handler(MethodId.GmJobPromote)] private Task GmJobPromote(Connection conn, UxRpcMessage msg) => HandleGmJobAsync(conn, msg, GmJobAction4229938.Promote); [Handler(MethodId.GmSetJobLevel)] private Task GmSetJobLevel(Connection conn, UxRpcMessage msg) => HandleGmJobAsync(conn, msg, GmJobAction4229938.SetLevel); /// /// GmJobEnd(65110787) 是这一族里唯一不带参数的(客户端序列化器为空), /// 结束的是「当前职业」而不是命令里点名的那个。 /// [Handler(MethodId.GmJobEnd)] private async Task GmJobEnd(Connection conn, UxRpcMessage msg) { var state = GetWorldState(msg.Context); Auto.SpiritJob job; string detail; lock (state.GmJobs.SyncRoot) { var target = state.GmJobs.CurrentJob; if (target == 0 || target == RuntimePayloadFactory.JoblessUrbanJob4229938) { job = new Auto.SpiritJob(); detail = "no current job (ignored)"; } else { (job, detail) = ApplyGmJob(state.GmJobs, target, 0, GmJobAction4229938.End); } } conn.Log.Info($"[GM-JOB] GmJobEnd {detail}"); await PushGmJobInfoAsync(conn, state, "GmJobEnd"); if (msg.IsInvoke) await conn.ReturnAsync(msg, job); } // ------------------------------------------------------------------ shared path private async Task HandleGmJobAsync(Connection conn, UxRpcMessage msg, GmJobAction4229938 action) { var (jobClassId, level) = ReadGmJobArgs(msg); var state = GetWorldState(msg.Context); Auto.SpiritJob job; string detail; lock (state.GmJobs.SyncRoot) (job, detail) = ApplyGmJob(state.GmJobs, jobClassId, level, action); conn.Log.Info($"[GM-JOB] {action} {detail}"); await PushGmJobInfoAsync(conn, state, action.ToString()); if (msg.IsInvoke) await conn.ReturnAsync(msg, job); } /// /// 解参数。契约解码失败时退回裸字节 —— GM 控制台的序列化器是客户端自己生成的, /// 服务端这一侧的契约一旦和它对不齐,最坏情况应该是「还能读出 jobClassId」而不是整条丢弃。 /// private static (uint JobClassId, byte Level) ReadGmJobArgs(UxRpcMessage msg) { // GmSetJobLevel 的 body 是 5 字节(uint32 + byte),其余四条是 4 字节。 // GmJobCommand 对 5 字节 body 也能"解码成功"(只读前 4 字节),所以必须先试长的那个。 if (msg.Body.Length >= 5 && msg.TryGetArgs(out var setLevel) && setLevel is not null) { return (setLevel.JobClassId, setLevel.Level); } if (msg.TryGetArgs(out var cmd) && cmd is not null) return (cmd.JobClassId, 1); if (msg.Body.Length == 0) return (0, 1); return (msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body) : 0u, msg.Body.Length >= 5 ? msg.Body[4] : (byte)1); } private static (Auto.SpiritJob Job, string Detail) ApplyGmJob( GmJobState4229938 state, uint jobClassId, byte level, GmJobAction4229938 action) { if (jobClassId == 0) return (new Auto.SpiritJob(), "no job class id (ignored)"); state.HasGmOverride = true; var record = state.Ensure(jobClassId, level); switch (action) { case GmJobAction4229938.Take: // 只登记职业,不切换当前职业(客户端的 take 与 start 是两个动作)。 break; case GmJobAction4229938.Start: state.CurrentJob = jobClassId; break; case GmJobAction4229938.Quit: state.Jobs.Remove(jobClassId); if (state.CurrentJob == jobClassId) state.CurrentJob = RuntimePayloadFactory.JoblessUrbanJob4229938; break; case GmJobAction4229938.Promote: record.Level = (byte)Math.Min(byte.MaxValue, record.Level + 1); break; case GmJobAction4229938.SetLevel: record.Level = Math.Max((byte)1, level); break; case GmJobAction4229938.End: if (state.CurrentJob == jobClassId) state.CurrentJob = RuntimePayloadFactory.JoblessUrbanJob4229938; break; } record.Action = action; record.UpdatedUtc = DateTime.UtcNow; var job = RuntimePayloadFactory.GmJobRecord4229938(jobClassId, record.Level); return (job, $"job={jobClassId} action={action} level={job.Level} " + $"tracked={state.Jobs.Count} current={state.CurrentJob}"); } /// /// 推 SyncSpiritJobInfo(64913222)。 /// /// ⛑ 合并而非替换:以 的配置结果为底, /// GM 记录覆盖/追加在上面。currentJob 只有在 GM 真的动过职业之后才改用控制台的值。 /// private async Task PushGmJobInfoAsync(Connection conn, WorldEntryState state, string trigger) { var spiritId = state.ActiveSpiritTemplateId; var base_ = RuntimePayloadFactory.SpiritJobInfo4229938(spiritId); var jobs = new Dictionary(base_.AvailableJobs); uint currentJob; lock (state.GmJobs.SyncRoot) { foreach (var (id, record) in state.GmJobs.Jobs) jobs[id] = RuntimePayloadFactory.GmJobRecord4229938(id, record.Level); currentJob = state.GmJobs.HasGmOverride ? state.GmJobs.CurrentJob : base_.CurrentJob; } // 0 不是合法 id —— 兜底到 Jobless(100),别把客户端的 SpiritJobManager 打崩。 if (currentJob == 0) currentJob = RuntimePayloadFactory.JoblessUrbanJob4229938; await conn.NotifyAsync(MethodId.SyncSpiritJobInfo, new GameMethods.SyncSpiritJobInfo4229938 { spiritId = spiritId, spiritJobs = jobs, currentJob = currentJob, }); conn.Log.Info($"[GM-JOB] push {trigger} spirit={spiritId} jobs={jobs.Count} " + $"current={currentJob} gmOverride={state.GmJobs.HasGmOverride}"); } }