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; /// /// 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 PlayerClientInfo.GameplayTalentInfos (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; gameplay.talents.pushPlayerInfoOnChange 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. /// internal sealed partial class GameRouter { /// /// 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 flatCost) and the layer cap from the same row. /// [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(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); } /// /// 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. /// [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(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(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(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(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); } /// /// 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. /// 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(byte[] body) where T : class { try { return UxSerializer.Deserialize(body); } catch { return null; } } }