215 lines
9.0 KiB
C#
215 lines
9.0 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// GM 控制台(ALT+F1)职业命令族 —— <c>GmJobTake</c> / <c>GmJobStart</c> / <c>GmJobQuit</c> /
|
||
/// <c>GmJobPromote</c> / <c>GmSetJobLevel</c> / <c>GmJobEnd</c>。
|
||
///
|
||
/// 移植自捐赠端 DremkaPS(2026-09-24)。他们这一族里 <c>GmTaskRecord4229938</c> 的 TaskState 字节
|
||
/// 标了 "verified in game",是整份捐赠材料里极少数实机验证过的东西;其余(钱包 / 台词 / 过场 /
|
||
/// 敌群)捐赠端自己承认"排队做完、都还没实机验证"。
|
||
///
|
||
/// ⛑ 三条本服的取舍,与捐赠端不同:
|
||
/// 1. <b>推送是「合并」不是「替换」</b>。捐赠端把 <c>spiritJobs</c> 整个换成 GM 记录,本服则把它
|
||
/// 叠加在 <see cref="RuntimePayloadFactory.SpiritJobInfo4229938"/> 配置驱动的那份之上 ——
|
||
/// 否则玩家在控制台点一条职业命令,角色原本的职业就整片消失。
|
||
/// 2. <b>CurrentJob 默认 100(Jobless)不是 0</b>。0 不是合法 <c>UrbanJobConfig</c> id,客户端
|
||
/// <c>SpiritJobManager</c> 会去解引用一个不存在的配置(本服踩过,见
|
||
/// <c>RuntimePayloadFactory.DefaultSpiritJobInfo4229938</c> 的注释)。
|
||
/// 3. <b>职业记录带天赋</b>。捐赠端因无表而填 <c>TalentInfo = null</c> / <c>RegisterTime = 0</c>;
|
||
/// 本服统一走 <see cref="RuntimePayloadFactory.GmJobRecord4229938"/>,与本服 7k 验证过的 wire 一致。
|
||
///
|
||
/// 捐赠端注释还点明了一件关键事实:<b>客户端自己在本地生成 GM 实体,GM invoke 的调用点根本不看
|
||
/// 返回值</b>。所以这些 handler 的价值是「服务端认得这条指令、不再掉进 unknown-methods.log,并且
|
||
/// 能把职业变化真的推给客户端」,而不是「返回一个客户端会用的值」。
|
||
/// </summary>
|
||
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);
|
||
|
||
/// <summary>
|
||
/// <c>GmJobEnd</c>(65110787) 是这一族里唯一<b>不带参数</b>的(客户端序列化器为空),
|
||
/// 结束的是「当前职业」而不是命令里点名的那个。
|
||
/// </summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解参数。契约解码失败时退回裸字节 —— GM 控制台的序列化器是客户端自己生成的,
|
||
/// 服务端这一侧的契约一旦和它对不齐,最坏情况应该是「还能读出 jobClassId」而不是整条丢弃。
|
||
/// </summary>
|
||
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<GameMethods.GmSetJobLevel>(out var setLevel)
|
||
&& setLevel is not null)
|
||
{
|
||
return (setLevel.JobClassId, setLevel.Level);
|
||
}
|
||
|
||
if (msg.TryGetArgs<GameMethods.GmJobCommand>(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}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 推 <c>SyncSpiritJobInfo</c>(64913222)。
|
||
///
|
||
/// ⛑ 合并而非替换:以 <see cref="RuntimePayloadFactory.SpiritJobInfo4229938"/> 的配置结果为底,
|
||
/// GM 记录覆盖/追加在上面。<c>currentJob</c> 只有在 GM 真的动过职业之后才改用控制台的值。
|
||
/// </summary>
|
||
private async Task PushGmJobInfoAsync(Connection conn, WorldEntryState state, string trigger)
|
||
{
|
||
var spiritId = state.ActiveSpiritTemplateId;
|
||
var base_ = RuntimePayloadFactory.SpiritJobInfo4229938(spiritId);
|
||
var jobs = new Dictionary<uint, Auto.SpiritJob>(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}");
|
||
}
|
||
}
|