254 lines
10 KiB
C#
254 lines
10 KiB
C#
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);
|
||
|
|
}
|
||
|
|
}
|