250 lines
11 KiB
C#
250 lines
11 KiB
C#
using Ananta.SDK.Rpc;
|
|
using Ananta.SDK.Serialization;
|
|
using Ananta.Server.ClientData.Client4229938;
|
|
using Ananta.Server.Configuration;
|
|
using Ananta.Server.Protocol.Client4229938;
|
|
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
|
|
|
namespace Ananta.Server.Handlers.Game;
|
|
|
|
/// <summary>
|
|
/// Talent trees (天赋) for build 4229938 — T1-6.
|
|
///
|
|
/// The panel is client-driven: it reads the tree layout from its own config tables and only asks the
|
|
/// server to activate a node. What the server owns is the point balance and the set of activated
|
|
/// nodes, both published in <c>PlayerClientInfo.GameplayTalentInfos</c> (a dictionary keyed by
|
|
/// TalentTreeGamePlayConfig id, 1..5 in stock 4229938).
|
|
///
|
|
/// ⛑️ Why nothing here sends SyncGameplayTalentPoint (64859579) / SyncActiveGameplayTalentLayer
|
|
/// (64743495): those eight S2Cs have no contract, no lua reader in RPCDeserializeAuto and no client
|
|
/// reader we can verify, and a hand-built frame with a missing trailing field desynchronises the
|
|
/// stream — which on this build does not fail loudly, it takes the client process down (see
|
|
/// GameplaySettings.PolicePayloadMode for the same failure mode). SyncPlayerInfo (64682159) carries
|
|
/// the very same PlayerClientInfo the client already parses at login, so that is what is re-pushed
|
|
/// after an activation; <c>gameplay.talents.pushPlayerInfoOnChange</c> turns it off.
|
|
///
|
|
/// ⛑️ gameplayId must be a real TalentTreeGamePlayConfig id. A donor build invented 1020 and the
|
|
/// panel then showed no points at all, because the client keys the whole view by that id and never
|
|
/// falls back. The ids published here come from the imported table, never from the request alone.
|
|
/// </summary>
|
|
internal sealed partial class GameRouter
|
|
{
|
|
/// <summary>
|
|
/// AskActiveGameplayTalentLayer (63772877) — activate one or more layers of a talent node.
|
|
/// The request is (gameplayId, talentId, addLayer) and carries no cost, so the cost comes from
|
|
/// TalentTreeTalentConfig (or <c>flatCost</c>) and the layer cap from the same row.
|
|
/// </summary>
|
|
[Handler(MethodId.AskActiveGameplayTalentLayer, HandlerPacketKind.Invoke)]
|
|
private async Task AskActiveGameplayTalentLayer(Connection conn, UxRpcMessage msg)
|
|
{
|
|
var settings = PrivateServerConfigStore.Current.Gameplay.Talents;
|
|
if (!settings.Enabled)
|
|
{
|
|
conn.Log.Info($"[TALENT] disabled, ack-only {msg.MethodId} {msg.Body.Length}b");
|
|
await conn.ReturnEmptyOkAsync(msg);
|
|
return;
|
|
}
|
|
|
|
var args = TryDecode4229938<GameMethods.AskActiveGameplayTalentLayerArgs4229938>(msg.Body);
|
|
if (args is null)
|
|
{
|
|
conn.Log.Warn($"[TALENT] unparsable AskActiveGameplayTalentLayer body={Convert.ToHexString(msg.Body)}");
|
|
await conn.ReturnEmptyOkAsync(msg);
|
|
return;
|
|
}
|
|
|
|
if (args.gameplayId == 0 || args.talentId == 0)
|
|
{
|
|
conn.Log.Warn(
|
|
$"[TALENT] AskActiveGameplayTalentLayer with empty id gameplay={args.gameplayId} talent={args.talentId}");
|
|
await conn.ReturnEmptyOkAsync(msg);
|
|
return;
|
|
}
|
|
|
|
var known = TalentTreeRepository.TryTalent(args.talentId, out var node);
|
|
if (!known && !settings.AllowUnknownTalents)
|
|
{
|
|
conn.Log.Warn(
|
|
$"[TALENT] unknown talent {args.talentId} (gameplay {args.gameplayId}); " +
|
|
"set gameplay.talents.allowUnknownTalents=true to accept it anyway");
|
|
await conn.ReturnEmptyOkAsync(msg);
|
|
return;
|
|
}
|
|
|
|
var state = GetWorldState(msg.Context);
|
|
var tree = RuntimePayloadFactory.EnsureGameplayTalentTree4229938(state, args.gameplayId);
|
|
|
|
var maxLayer = known ? Math.Max(node!.LayerNum, 1u) : 1u;
|
|
var unitCost = settings.FlatCost > 0 ? settings.FlatCost : (known ? node!.CostPoint : 0u);
|
|
var wanted = args.addLayer == 0 ? 1u : args.addLayer;
|
|
|
|
uint added = 0;
|
|
uint layer = 0;
|
|
uint point = 0;
|
|
string? refusal = null;
|
|
lock (state.SyncRoot)
|
|
{
|
|
var current = tree.Nodes.TryGetValue(args.talentId, out var existing) ? existing : 0u;
|
|
if (current >= maxLayer)
|
|
{
|
|
refusal = $"already at max layer {maxLayer}";
|
|
}
|
|
else
|
|
{
|
|
added = Math.Min(wanted, maxLayer - current);
|
|
var total = unitCost * added;
|
|
if (!settings.IgnoreCost && tree.Point < total)
|
|
{
|
|
refusal = $"needs {total} point(s), tree has {tree.Point}";
|
|
added = 0;
|
|
}
|
|
else
|
|
{
|
|
tree.Nodes[args.talentId] = current + added;
|
|
if (!settings.IgnoreCost)
|
|
tree.Point -= total;
|
|
|
|
layer = tree.Nodes[args.talentId];
|
|
point = tree.Point;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (refusal is not null)
|
|
{
|
|
conn.Log.Info($"[TALENT] reject gameplay={args.gameplayId} talent={args.talentId}: {refusal}");
|
|
await conn.ReturnEmptyOkAsync(msg);
|
|
return;
|
|
}
|
|
|
|
conn.Log.Info(
|
|
$"[TALENT] active gameplay={args.gameplayId} talent={args.talentId} +{added} " +
|
|
$"-> layer={layer}/{maxLayer} cost={unitCost * added} left={point}");
|
|
|
|
await PublishTalentState4229938(conn, state, settings);
|
|
await conn.ReturnEmptyOkAsync(msg);
|
|
}
|
|
|
|
/// <summary>
|
|
/// AskResetGameplayTalent (63365723) — respec one gameplay tree. Every point spent in the tree is
|
|
/// refunded, and the refund is recomputed from the node costs instead of being tracked separately,
|
|
/// so the two can never drift apart.
|
|
/// </summary>
|
|
[Handler(MethodId.AskResetGameplayTalent, HandlerPacketKind.Invoke)]
|
|
private async Task AskResetGameplayTalent(Connection conn, UxRpcMessage msg)
|
|
{
|
|
var settings = PrivateServerConfigStore.Current.Gameplay.Talents;
|
|
if (!settings.Enabled)
|
|
{
|
|
await conn.ReturnEmptyOkAsync(msg);
|
|
return;
|
|
}
|
|
|
|
var args = TryDecode4229938<GameMethods.AskResetGameplayTalentArgs4229938>(msg.Body);
|
|
if (args is null || args.gameplayId == 0)
|
|
{
|
|
conn.Log.Warn($"[TALENT] unparsable AskResetGameplayTalent body={Convert.ToHexString(msg.Body)}");
|
|
await conn.ReturnEmptyOkAsync(msg);
|
|
return;
|
|
}
|
|
|
|
var state = GetWorldState(msg.Context);
|
|
var tree = RuntimePayloadFactory.EnsureGameplayTalentTree4229938(state, args.gameplayId);
|
|
|
|
uint nodes = 0;
|
|
uint refunded = 0;
|
|
uint point = 0;
|
|
lock (state.SyncRoot)
|
|
{
|
|
nodes = (uint)tree.Nodes.Count;
|
|
foreach (var (talentId, layer) in tree.Nodes)
|
|
{
|
|
var cost = settings.FlatCost > 0
|
|
? settings.FlatCost
|
|
: (TalentTreeRepository.TryTalent(talentId, out var node) ? node.CostPoint : 0u);
|
|
refunded += cost * layer;
|
|
}
|
|
|
|
tree.Nodes.Clear();
|
|
tree.Point += refunded;
|
|
point = tree.Point;
|
|
}
|
|
|
|
conn.Log.Info(
|
|
$"[TALENT] reset gameplay={args.gameplayId} nodes={nodes} refunded={refunded} left={point}");
|
|
|
|
await PublishTalentState4229938(conn, state, settings);
|
|
await conn.ReturnEmptyOkAsync(msg);
|
|
}
|
|
|
|
// Career talent trees (职业天赋) are keyed by character + job class rather than by gameplay id and
|
|
// ride SpiritJob.TalentInfo — plus SpiritInfo.TalentInfo, which is the view the scene HACK limit
|
|
// actually consults. Their nodes are seeded as already-activated from
|
|
// TalentTreeTalentConfig (see RuntimePayloadFactory.CareerTalentInfo4229938,
|
|
// gameplay.talents.seedCareerTalents), because TalentTreeMgr.CheckTalentIsLocked resolves them at
|
|
// registration time and a locked node leaves every hack prompt greyed out. Nothing is persisted
|
|
// here yet, so these three stay ack-only: re-logging in re-seeds the same node set.
|
|
|
|
[Handler(MethodId.AskActiveSpiritJobTalentLayer, HandlerPacketKind.Invoke)]
|
|
private Task AskActiveSpiritJobTalentLayer(Connection conn, UxRpcMessage msg)
|
|
{
|
|
var args = TryDecode4229938<GameMethods.AskActiveSpiritJobTalentLayerArgs4229938>(msg.Body);
|
|
conn.Log.Info(args is null
|
|
? $"[TALENT] unparsable AskActiveSpiritJobTalentLayer body={Convert.ToHexString(msg.Body)}"
|
|
: $"[TALENT] spirit job layer spirit={args.spiritId} job={args.jobClassId} " +
|
|
$"talent={args.talentId} add={args.addLayer} (not persisted yet)");
|
|
|
|
return conn.ReturnEmptyOkAsync(msg);
|
|
}
|
|
|
|
[Handler(MethodId.AskResetSpiritJobTalent, HandlerPacketKind.Invoke)]
|
|
private Task AskResetSpiritJobTalent(Connection conn, UxRpcMessage msg)
|
|
{
|
|
var args = TryDecode4229938<GameMethods.AskResetSpiritJobTalentArgs4229938>(msg.Body);
|
|
conn.Log.Info(args is null
|
|
? $"[TALENT] unparsable AskResetSpiritJobTalent body={Convert.ToHexString(msg.Body)}"
|
|
: $"[TALENT] reset spirit job talent spirit={args.spiritId} job={args.jobClassId} (not persisted yet)");
|
|
|
|
return conn.ReturnEmptyOkAsync(msg);
|
|
}
|
|
|
|
[Handler(MethodId.AskConvertCommonSpiritTalentExp, HandlerPacketKind.Invoke)]
|
|
private Task AskConvertCommonSpiritTalentExp(Connection conn, UxRpcMessage msg)
|
|
{
|
|
var args = TryDecode4229938<GameMethods.AskConvertCommonSpiritTalentExpArgs4229938>(msg.Body);
|
|
conn.Log.Info(args is null
|
|
? $"[TALENT] unparsable AskConvertCommonSpiritTalentExp body={Convert.ToHexString(msg.Body)}"
|
|
: $"[TALENT] convert common talent exp spirit={args.spiritId} exp={args.convertExp} (not persisted yet)");
|
|
|
|
return conn.ReturnEmptyOkAsync(msg);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Republishes the login frame after a talent change. SyncPlayerInfo is the same payload the
|
|
/// client already accepted at login, so GameplayTalentInfos arrives through a wire we know is
|
|
/// correct instead of through one of the unverified talent S2Cs.
|
|
/// </summary>
|
|
private static async Task PublishTalentState4229938(
|
|
Connection conn, WorldEntryState state, TalentTreeSettings settings)
|
|
{
|
|
PlayerProgressStore.Save(state, conn.Log, quiet: true);
|
|
if (!settings.PushPlayerInfoOnChange)
|
|
return;
|
|
|
|
await conn.NotifyAsync(MethodId.SyncPlayerInfo, RuntimePayloadFactory.MinimalPlayerInfo4229938(state));
|
|
}
|
|
|
|
private static T? TryDecode4229938<T>(byte[] body)
|
|
where T : class
|
|
{
|
|
try
|
|
{
|
|
return UxSerializer.Deserialize<T>(body);
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
}
|