This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

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;
}
}
}