Files

215 lines
9.0 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}");
}
}