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