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
Ananta-ps/Ananta.Server/Ananta.Core/ClientData/Client4229938/TalentTreeRepository.cs
T

254 lines
10 KiB
C#
Raw Normal View History

using System.Text.Json;
using Ananta.Server.Configuration;
namespace Ananta.Server.ClientData.Client4229938;
/// <summary>
/// Client talent tables (天赋树) imported from the 4229938 config dump into
/// <c>paths.clientConfigs</c>:
/// · TalentTreeGamePlayConfig.json — the gameplay ids the talent panel is split into (1..5).
/// · TalentTreeConfig.json — one row per tree: how the client keys it (gameplay / job
/// class / spirit) — see <see cref="TalentTree"/>.
/// · TalentTreeTalentConfig.json — one row per talent node: owning tree, point cost, max layer.
///
/// Optional, like the economy tables: a missing file only disables talent point accounting instead
/// of refusing to start. The server needs these numbers because the client's AskActiveGameplayTalentLayer
/// only carries (gameplayId, talentId, addLayer) — no cost — so a server that wants to debit points
/// has to know what the node costs itself.
/// </summary>
internal static class TalentTreeRepository
{
private static readonly Lazy<Data> Cache = new(Load);
/// <summary>Forces the (optional) tables to load so the startup log reports what was imported.</summary>
internal static void Warmup()
=> _ = Cache.Value;
/// <summary>The gameplay ids the panel offers, ascending (1..5 in stock 4229938).</summary>
internal static IReadOnlyList<uint> GameplayIds => Cache.Value.GameplayIds;
/// <summary>Every tree row, ascending by id (18 in stock 4229938).</summary>
internal static IReadOnlyList<TalentTree> Trees => Cache.Value.Trees;
/// <summary>True once at least one node row was imported.</summary>
internal static bool HasNodes => Cache.Value.Nodes.Count > 0;
internal static bool TryTalent(uint id, out TalentNode node)
=> Cache.Value.Nodes.TryGetValue(id, out node!);
/// <summary>
/// Nodes of every tree matching <paramref name="predicate"/>, de-duplicated by node id and
/// ascending. A node can sit in several trees (33 of them do), so the union has to be distinct.
/// </summary>
internal static IReadOnlyList<TalentNode> NodesOfTrees(Func<TalentTree, bool> predicate)
{
var result = new List<TalentNode>();
var seen = new HashSet<uint>();
foreach (var tree in Cache.Value.Trees)
{
if (!predicate(tree) || !Cache.Value.ByTree.TryGetValue(tree.Id, out var nodes))
continue;
foreach (var node in nodes)
{
if (seen.Add(node.Id))
result.Add(node);
}
}
result.Sort(static (a, b) => a.Id.CompareTo(b.Id));
return result;
}
/// <summary>
/// Every node belonging to one talent tree (<c>TalentTreeid</c> row), ascending by id.
///
/// Needed by the career-talent seeding (<c>TalentTreeSettings.SeedCareerTalents</c>): nodes have
/// prerequisites, so activating one node in isolation can still read as locked on the client.
/// Publishing a whole tree sidesteps that dependency order entirely.
/// </summary>
internal static IReadOnlyList<TalentNode> NodesOfTree(uint treeId)
{
if (treeId == 0)
return [];
return Cache.Value.ByTree.TryGetValue(treeId, out var nodes) ? nodes : [];
}
/// <summary>One talent node. <paramref name="TreeId"/> is the first TalentTreeid row (a node can
/// belong to several trees in the dump; the panel key we echo back only needs one).</summary>
internal sealed record TalentNode(uint Id, uint TreeId, uint CostPoint, uint LayerNum);
/// <summary>
/// One talent tree, i.e. one <c>TalentTreeConfig</c> row. The three ids are mutually exclusive
/// in stock 4229938 and decide which client container the tree's nodes have to be published in
/// (<c>TalentTreeMgr.OnStartCurrent</c>):
/// <list type="bullet">
/// <item><see cref="GameplayId"/> != 0 — keyed by gameplay id, read from <c>GameplayTalentInfos</c>.</item>
/// <item><see cref="JobClassId"/> != 0 — keyed by job class, read from that career's
/// <c>SpiritJob.TalentInfo</c> (so a tree only shows up for characters that own the career).</item>
/// <item>both 0 — dumped into <c>SpiritInfo.TalentInfo</c>, further narrowed by
/// <see cref="SpiritIds"/> (Kingpin lists the two protagonists; some rows pin a single spirit).</item>
/// </list>
/// </summary>
internal sealed record TalentTree(
uint Id,
string Name,
uint GameplayId,
uint JobClassId,
IReadOnlyList<uint> SpiritIds);
private sealed class Data
{
internal List<uint> GameplayIds { get; } = [];
internal List<TalentTree> Trees { get; } = [];
internal Dictionary<uint, TalentNode> Nodes { get; } = [];
internal Dictionary<uint, List<TalentNode>> ByTree { get; } = [];
}
private static Data Load()
{
var cfg = PrivateServerConfigStore.Current;
var root = PrivateServerConfigStore.ResolveProjectPath(cfg.Paths.ClientConfigs);
var warnings = new List<string>();
var data = new Data();
foreach (var row in ReadRecords(Path.Combine(root, "TalentTreeGamePlayConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var id) || id == 0)
continue;
if (!data.GameplayIds.Contains(id))
data.GameplayIds.Add(id);
}
data.GameplayIds.Sort();
foreach (var row in ReadRecords(Path.Combine(root, "TalentTreeConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var id) || id == 0)
continue;
TryUInt32(row, "GameplayId", out var gameplayId);
TryUInt32(row, "JobClassId", out var jobClassId);
var spirits = new List<uint>();
if (TryProperty(row, "SpiritId", out var list) && list.ValueKind == JsonValueKind.Array)
{
foreach (var spirit in list.EnumerateArray())
{
if (spirit.TryGetUInt32(out var spiritId) && spiritId != 0)
spirits.Add(spiritId);
}
}
var name = TryProperty(row, "Name", out var rawName) ? rawName.ToString() : string.Empty;
data.Trees.Add(new TalentTree(id, name, gameplayId, jobClassId, spirits));
}
data.Trees.Sort(static (a, b) => a.Id.CompareTo(b.Id));
foreach (var row in ReadRecords(Path.Combine(root, "TalentTreeTalentConfig.json"), warnings))
{
if (!TryUInt32(row, "Id", out var id) || id == 0)
continue;
TryUInt32(row, "CostPoint", out var cost);
TryUInt32(row, "LayerNum", out var layer);
// A node can sit in several trees (33 of the 425 rows do), so index it under every one —
// keying the first row only would hide shared nodes from whichever tree lists them second.
var treeIds = new List<uint>();
if (TryProperty(row, "TalentTreeid", out var trees) && trees.ValueKind == JsonValueKind.Array)
{
foreach (var tree in trees.EnumerateArray())
{
if (tree.TryGetUInt32(out var treeId) && treeId != 0)
treeIds.Add(treeId);
}
}
var firstTreeId = treeIds.Count > 0 ? treeIds[0] : 0u;
// Dump rows with a single layer omit LayerNum or carry 0; the panel then still offers one
// activation, so treat 0 as 1 rather than as "cannot be activated at all".
var node = new TalentNode(id, firstTreeId, cost, layer == 0 ? 1u : layer);
data.Nodes[id] = node;
foreach (var treeId in treeIds)
{
if (!data.ByTree.TryGetValue(treeId, out var siblings))
{
siblings = [];
data.ByTree[treeId] = siblings;
}
siblings.Add(node);
}
}
foreach (var siblings in data.ByTree.Values)
siblings.Sort(static (a, b) => a.Id.CompareTo(b.Id));
var log = new Ananta.SDK.Logging.ServerLogger("clientdata-talent");
if (warnings.Count > 0)
{
foreach (var warning in warnings)
log.Warn($"[TALENT] {warning}");
}
log.Info($"[TALENT] imported gameplayIds={data.GameplayIds.Count} trees={data.Trees.Count} " +
$"nodes={data.Nodes.Count} treeSlots={data.ByTree.Values.Sum(x => x.Count)} from {root}");
return data;
}
private static JsonElement[] ReadRecords(string path, List<string> warnings)
{
if (!File.Exists(path))
{
warnings.Add($"optional client config is missing: {path}");
return [];
}
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array)
{
warnings.Add($"client config has no 'records' array: {path}");
return [];
}
return records.EnumerateArray().Select(x => x.Clone()).ToArray();
}
catch (Exception ex)
{
warnings.Add($"client config failed to parse ({ex.GetType().Name}: {ex.Message}): {path}");
return [];
}
}
// Dump records may carry fully qualified keys ("LT.ConfigGen.IFoo.Id"), so match on the leaf name.
private static bool TryProperty(JsonElement row, string name, out JsonElement value)
{
if (row.TryGetProperty(name, out value))
return true;
foreach (var property in row.EnumerateObject())
{
if (property.Name.AsSpan()[(property.Name.LastIndexOf('.') + 1)..].SequenceEqual(name))
{
value = property.Value;
return true;
}
}
value = default;
return false;
}
private static bool TryUInt32(JsonElement row, string name, out uint result)
{
result = 0;
return TryProperty(row, name, out var value) && value.TryGetUInt32(out result);
}
}