using System.Text.Json; using Ananta.Server.Configuration; namespace Ananta.Server.ClientData.Client4229938; /// /// Client talent tables (天赋树) imported from the 4229938 config dump into /// paths.clientConfigs: /// · 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 . /// · 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. /// internal static class TalentTreeRepository { private static readonly Lazy Cache = new(Load); /// Forces the (optional) tables to load so the startup log reports what was imported. internal static void Warmup() => _ = Cache.Value; /// The gameplay ids the panel offers, ascending (1..5 in stock 4229938). internal static IReadOnlyList GameplayIds => Cache.Value.GameplayIds; /// Every tree row, ascending by id (18 in stock 4229938). internal static IReadOnlyList Trees => Cache.Value.Trees; /// True once at least one node row was imported. 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!); /// /// Nodes of every tree matching , 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. /// internal static IReadOnlyList NodesOfTrees(Func predicate) { var result = new List(); var seen = new HashSet(); 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; } /// /// Every node belonging to one talent tree (TalentTreeid row), ascending by id. /// /// Needed by the career-talent seeding (TalentTreeSettings.SeedCareerTalents): 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. /// internal static IReadOnlyList NodesOfTree(uint treeId) { if (treeId == 0) return []; return Cache.Value.ByTree.TryGetValue(treeId, out var nodes) ? nodes : []; } /// One talent node. is the first TalentTreeid row (a node can /// belong to several trees in the dump; the panel key we echo back only needs one). internal sealed record TalentNode(uint Id, uint TreeId, uint CostPoint, uint LayerNum); /// /// One talent tree, i.e. one TalentTreeConfig row. The three ids are mutually exclusive /// in stock 4229938 and decide which client container the tree's nodes have to be published in /// (TalentTreeMgr.OnStartCurrent): /// /// != 0 — keyed by gameplay id, read from GameplayTalentInfos. /// != 0 — keyed by job class, read from that career's /// SpiritJob.TalentInfo (so a tree only shows up for characters that own the career). /// both 0 — dumped into SpiritInfo.TalentInfo, further narrowed by /// (Kingpin lists the two protagonists; some rows pin a single spirit). /// /// internal sealed record TalentTree( uint Id, string Name, uint GameplayId, uint JobClassId, IReadOnlyList SpiritIds); private sealed class Data { internal List GameplayIds { get; } = []; internal List Trees { get; } = []; internal Dictionary Nodes { get; } = []; internal Dictionary> ByTree { get; } = []; } private static Data Load() { var cfg = PrivateServerConfigStore.Current; var root = PrivateServerConfigStore.ResolveProjectPath(cfg.Paths.ClientConfigs); var warnings = new List(); 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(); 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(); 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 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); } }