feat: Add metro transit, gadgets, shop, social apps, and traffic models checkpoint

This commit is contained in:
CAtlas0784 committed 2026-10-29 01:55:01 +07:00
1 parent caa42b84ed
commit 74e2963a12
79 files changed
+527962 -496

No files matched your search

@@ -0,0 +1,122 @@
using System.Text.Json;
using Ananta.Server.Configuration;
namespace Ananta.Server.ClientData.Client4229938;
internal static class BehaviorTaskCatalog
{
internal sealed record BehaviorTask(
uint Id,
int AnimState,
int BehaviorLayer,
int ActionSelector,
int PostureTag,
IReadOnlyList<int> MainActionIds);
private static readonly Lazy<IReadOnlyList<BehaviorTask>> Cache = new(Load);
internal static IReadOnlyList<BehaviorTask> All => Cache.Value;
internal static int Count => Cache.Value.Count;
internal static BehaviorTask? FirstByAnimState(int animState)
=> Cache.Value.FirstOrDefault(t => t.AnimState == animState && t.MainActionIds.Count > 0);
internal static uint CrowdTaskId => FirstByAnimState(1)?.Id ?? 0;
internal static uint VehicleNpcTaskId => FirstByAnimState(3)?.Id ?? 0;
private static IReadOnlyList<BehaviorTask> Load()
{
var result = new List<BehaviorTask>(512);
try
{
var root = PrivateServerConfigStore.ResolveProjectPath(
PrivateServerConfigStore.Current.Paths.ClientConfigs);
var path = Path.Combine(root, "AetherNpcBehaviorTaskDefineConfig.json");
if (!File.Exists(path))
{
Console.WriteLine($"[BEHAVIOR] 找不到 {path}(行为任务自动挑选将不可用)");
return result;
}
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("records", out var recs)
|| recs.ValueKind != JsonValueKind.Array)
return result;
foreach (var r in recs.EnumerateArray())
{
if (!r.TryGetProperty("Id", out var idNode) || !idNode.TryGetUInt32(out var id) || id == 0)
continue;
var mains = new List<int>(8);
if (r.TryGetProperty("MainActionIds", out var m)
&& m.ValueKind == JsonValueKind.Array)
{
foreach (var x in m.EnumerateArray())
if (x.ValueKind == JsonValueKind.Number && x.TryGetInt32(out var v))
mains.Add(v);
}
result.Add(new BehaviorTask(
id,
r.TryGetProperty("AnimState", out var a) && a.TryGetInt32(out var av) ? av : -1,
r.TryGetProperty("BehaviorLayer", out var l) && l.TryGetInt32(out var lv) ? lv : -1,
r.TryGetProperty("ActionSelector", out var s) && s.TryGetInt32(out var sv) ? sv : -1,
r.TryGetProperty("BehaviorPostureGameplayTag", out var p) && p.TryGetInt32(out var pv) ? pv : -1,
mains));
}
result.Sort(static (a, b) => a.Id.CompareTo(b.Id));
Console.WriteLine($"[BEHAVIOR] AetherNpcBehaviorTaskDefineConfig 已载入 {result.Count} 条"
+ $"(带动作 {result.Count(static t => t.MainActionIds.Count > 0)} 条)");
}
catch (Exception ex)
{
Console.WriteLine($"[BEHAVIOR] 载入失败: {ex.Message}");
}
return result;
}
internal static (int Count, uint Crowd, uint Vehicle, string Detail) Probe()
{
var all = All;
var byState = all.GroupBy(static t => t.AnimState)
.OrderBy(static g => g.Key)
.Select(static g => $"{g.Key}:{g.Count()}");
var lines = new List<string>
{
" AnimState 分布(None0/Default1/Sitting2/Driving3/Riding4/Impacted5/Interacting6/Sick7) = "
+ string.Join(" ", byState),
};
foreach (var st in new[] { 1, 3, 5, 6 })
{
var t = FirstByAnimState(st);
if (t is null) { lines.Add($" AnimState={st} → (无)"); continue; }
lines.Add($" AnimState={st} → Id={t.Id} Layer={t.BehaviorLayer} "
+ $"Selector={t.ActionSelector} Posture={t.PostureTag} "
+ $"MainActionIds=[{string.Join(",", t.MainActionIds.Take(4))}]");
}
return (all.Count, CrowdTaskId, VehicleNpcTaskId, string.Join('\n', lines));
}
}
@@ -28,6 +28,15 @@ internal static class EconomyConfigRepository
internal static bool TryPool(uint id, out GachaPool pool)
=> Cache.Value.Pools.TryGetValue(id, out pool!);
/// <summary>Distinct non-zero gacha ticket item ids (pool MoneyId) across every pool.</summary>
internal static IReadOnlyCollection<uint> GachaTicketItemIds()
=> Cache.Value.Pools.Values
.Select(p => p.MoneyId)
.Where(id => id != 0)
.Distinct()
.OrderBy(id => id)
.ToArray();
internal static bool TryTierRule(uint id, out GachaTierRule rule)
=> Cache.Value.TierRules.TryGetValue(id, out rule!);
@@ -0,0 +1,49 @@
using System.Text.Json;
using Ananta.Server.Configuration;
namespace Ananta.Server.ClientData.Client4229938;
/// <summary>
/// Chinese → English name mapping for the debug panel spawn lists. The tables are generated
/// by VaultTool/build_translation.py (word segmentation + single-char fallback) over the
/// AgentConfig / UrbanDiversityPOIActionConfig name columns; unknown names fall back to the
/// original string.
/// </summary>
internal static class NameTranslator
{
private static readonly Lazy<Dictionary<string, string>> Agents = new(() => Load("agent_names_en.json"));
private static readonly Lazy<Dictionary<string, string>> PoiActions = new(() => Load("poi_actions_en.json"));
public static string Agent(string? name)
=> Translate(Agents.Value, name);
public static string PoiAction(string? name)
=> Translate(PoiActions.Value, name);
private static string Translate(Dictionary<string, string> table, string? name)
{
if (string.IsNullOrWhiteSpace(name))
return name ?? string.Empty;
return table.TryGetValue(name, out var en) && !string.IsNullOrWhiteSpace(en) ? en : name;
}
private static Dictionary<string, string> Load(string file)
{
try
{
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
var path = Path.Combine(root, file);
if (!File.Exists(path))
return [];
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var dict = new Dictionary<string, string>();
foreach (var p in doc.RootElement.EnumerateObject())
dict[p.Name] = p.Value.GetString() ?? string.Empty;
return dict;
}
catch
{
return [];
}
}
}
@@ -49,7 +49,10 @@ internal static class NpcCatalog4229938
if (list.Count >= limit) break;
continue;
}
if (!entry.Name.Contains(q!, StringComparison.OrdinalIgnoreCase) &&
// Match the translated English name, the original (Chinese) name and the id.
var translated = NameTranslator.Agent(entry.Name);
if (!translated.Contains(q!, StringComparison.OrdinalIgnoreCase) &&
!entry.Name.Contains(q!, StringComparison.OrdinalIgnoreCase) &&
!entry.Id.ToString().Contains(q!))
continue;
}
@@ -5,6 +5,7 @@ namespace Ananta.Server.ClientData.Client4229938;
internal sealed record PoiActionCatalogEntry4229938(uint Id, uint MainActionGroup, uint[] MainActions)
{
internal string Name { get; init; } = "";
internal uint UsageId { get; init; }
internal uint[] BelongingSceneItems { get; init; } = [];
internal uint SelectMainAction(ulong entityId)
@@ -18,6 +19,9 @@ internal static class PoiActionCatalog4229938
internal static bool TryGet(uint id, out PoiActionCatalogEntry4229938 entry)
=> Cache.Value.TryGetValue(id, out entry!);
/// <summary>Every row of UrbanDiversityPOIActionConfig, for the panel's POI picker.</summary>
internal static IReadOnlyCollection<PoiActionCatalogEntry4229938> All() => Cache.Value.Values;
private static Dictionary<uint, PoiActionCatalogEntry4229938> Load()
{
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
@@ -59,7 +63,12 @@ internal static class PoiActionCatalog4229938
if (slotItem != 0) belongingItems = [slotItem];
}
}
result[id] = new(id, actionGroup, actions) { UsageId = usageId, BelongingSceneItems = belongingItems };
result[id] = new(id, actionGroup, actions)
{
Name = row.TryGetProperty("Name", out var nameNode) && nameNode.ValueKind == JsonValueKind.String ? nameNode.GetString() ?? "" : "",
UsageId = usageId,
BelongingSceneItems = belongingItems,
};
}
return result;
}
@@ -0,0 +1,387 @@
using System.Text.Json;
using Ananta.Server.Configuration;
namespace Ananta.Server.ClientData.Client4229938;
/// <summary>One RailLineConfig row (a physical light-rail / cruise line, and its direction).</summary>
internal sealed record RailLine4229938(
uint Id,
string LineName,
uint TrainTemplateId,
string PrefabPath,
string BlobName,
bool RingLine,
bool IsTestPath,
int DepartureInterval,
int MaxTrainAmount,
double TotalTravelDuration,
double DestinationArrivalTime,
int AvgVelocity,
bool FirstStopDumb,
bool LastStopDumb,
int FirstTrainHour,
int FirstTrainMinute,
int LastTrainHour,
int LastTrainMinute)
{
/// <summary>
/// How many carriages the client should keep alive on this line. The timetable repeats every
/// TotalTravelDuration seconds and a train leaves every DepartureInterval, so the steady-state
/// fleet size is the ratio of the two - capped by the config's own MaxTrainAmount.
/// </summary>
internal int EffectiveTrainCount
{
get
{
if (TotalTravelDuration <= 0 || DepartureInterval <= 0)
return Math.Clamp(MaxTrainAmount, 1, 32);
var byInterval = (int)Math.Floor(TotalTravelDuration / DepartureInterval);
var cap = MaxTrainAmount > 0 ? MaxTrainAmount : 32;
return Math.Clamp(byInterval, 1, Math.Clamp(cap, 1, 32));
}
}
}
/// <summary>
/// One RailStopConfig row (a scheduled call of one line). Note that the JSON field literally named
/// "LineName" holds the <em>stop</em> name (e.g. 白帆湾车库 for the depot stop), not the line name.
/// </summary>
internal sealed record RailStop4229938(
uint Id,
uint LineId,
string StopName,
uint NextStopId,
double ArrivalTime,
double InsertTime,
uint GateOpenSide,
bool DefaultExitStop,
uint StationId);
/// <summary>One RailStationConfig row (a platform shared by one or more lines).</summary>
internal sealed record RailStation4229938(
uint Id,
string Name,
string ChineseName,
string EnglishName,
double X,
double Y,
double Z,
uint[] Lines)
{
/// <summary>Localised display name, falling back to the English/raw name.</summary>
internal string DisplayName(bool chinese)
{
if (chinese)
{
if (!string.IsNullOrWhiteSpace(ChineseName)) return ChineseName;
if (!string.IsNullOrWhiteSpace(Name)) return Name;
return EnglishName ?? string.Empty;
}
if (!string.IsNullOrWhiteSpace(EnglishName)) return EnglishName;
if (!string.IsNullOrWhiteSpace(Name)) return Name;
return ChineseName ?? string.Empty;
}
internal double DistanceTo(RailStation4229938 other)
{
var dx = X - other.X;
var dy = Y - other.Y;
var dz = Z - other.Z;
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
}
}
/// <summary>
/// A line with its stops already ordered along the travel direction, so the nth entry of
/// <see cref="Stops"/> is the nth station of <see cref="RailLine4229938"/>'s own Stations array.
/// </summary>
internal sealed record RailRoute4229938(RailLine4229938 Line, IReadOnlyList<RailStop4229938> Stops);
/// <summary>Where a train is right now, derived from ElapsedTime + the baked timetable.</summary>
internal sealed record RailTrainProgress(
int StopIndex,
int NextStopIndex,
RailStop4229938 CurrentStop,
RailStop4229938? NextStop,
RailStation4229938? CurrentStation,
RailStation4229938? NextStation,
double SecondsToNext,
double MetresToNext,
bool AtStation);
/// <summary>
/// Build-4229938 light-rail / cruise catalog, read from the Rail*Config.json files in the configured
/// client-data directory. Field names verified against ConfigDump_v3 and the Lua
/// Auto.WriteMetroClientInfo writer in RPCSerializeAuto.lua.
/// </summary>
internal static class RailCatalog4229938
{
private static readonly Lazy<Dictionary<uint, RailLine4229938>> LineCache = new(LoadLines);
private static readonly Lazy<Dictionary<uint, RailStation4229938>> StationCache = new(LoadStations);
private static readonly Lazy<Dictionary<uint, RailRoute4229938>> RouteCache = new(LoadRoutes);
internal static IReadOnlyList<RailLine4229938> AllLines => LineCache.Value.Values
.OrderBy(l => l.Id)
.ToArray();
internal static bool TryGetLine(uint id, out RailLine4229938 line)
=> LineCache.Value.TryGetValue(id, out line!);
internal static bool TryGetStation(uint id, out RailStation4229938 station)
=> StationCache.Value.TryGetValue(id, out station!);
internal static bool TryGetRoute(uint lineId, out RailRoute4229938 route)
=> RouteCache.Value.TryGetValue(lineId, out route!);
internal static IReadOnlyList<RailStation4229938> AllStations => StationCache.Value.Values
.OrderBy(s => s.Id)
.ToArray();
/// <summary>
/// Position of train <paramref name="trainIndex"/> of <paramref name="line"/> at
/// <paramref name="elapsedSeconds"/> seconds into the day's service. Trains are evenly spaced by
/// DepartureInterval and wrap around TotalTravelDuration, which is what the stops' ArrivalTime
/// values are measured against (the last stop's arrival equals the total travel duration).
/// </summary>
internal static double ElapsedForTrain(RailLine4229938 line, int trainIndex, double elapsedSeconds)
{
var total = line.TotalTravelDuration;
if (total <= 0) return 0;
var phase = elapsedSeconds + (trainIndex * (double)line.DepartureInterval);
var wrapped = phase % total;
return wrapped < 0 ? wrapped + total : wrapped;
}
/// <summary>Resolves "where is the train" from its ElapsedTime and the baked stop timetable.</summary>
internal static RailTrainProgress? ProgressAt(RailLine4229938 line, double elapsed)
{
if (!TryGetRoute(line.Id, out var route) || route.Stops.Count == 0)
return null;
var stops = route.Stops;
var index = 0;
for (var i = 0; i < stops.Count; i++)
{
if (stops[i].ArrivalTime <= elapsed + 0.001)
index = i;
else
break;
}
var current = stops[index];
var nextIndex = index + 1 < stops.Count ? index + 1 : (line.RingLine ? 0 : -1);
var next = nextIndex >= 0 ? stops[nextIndex] : null;
TryGetStation(current.StationId, out var currentStation);
RailStation4229938? nextStation = null;
if (next is not null) TryGetStation(next.StationId, out nextStation);
var secondsToNext = next is null ? 0 : Math.Max(0, next.ArrivalTime - elapsed);
var metresToNext = 0d;
if (next is not null)
{
var leg = next.ArrivalTime - current.ArrivalTime;
if (currentStation is not null && nextStation is not null && leg > 0.001)
{
// Straight-line interpolation of the leg: the stop positions are the platform anchors,
// so this is an estimate, not the true track distance.
var legLength = currentStation.DistanceTo(nextStation);
metresToNext = legLength * (secondsToNext / leg);
}
else if (line.AvgVelocity > 0)
{
metresToNext = secondsToNext * line.AvgVelocity;
}
}
return new RailTrainProgress(
index,
nextIndex,
current,
next,
currentStation,
nextStation,
secondsToNext,
metresToNext,
elapsed - current.ArrivalTime <= current.InsertTime + 0.5);
}
private static Dictionary<uint, RailLine4229938> LoadLines()
{
var result = new Dictionary<uint, RailLine4229938>();
foreach (var row in ReadRecords("RailLineConfig.json"))
{
var id = UInt(row, "Id");
var total = Double(row, "TotalTravelDuration");
if (id == 0 || total <= 0)
continue;
var (firstHour, firstMinute) = Clock(row, "FirstTrainTime", 6, 0);
var (lastHour, lastMinute) = Clock(row, "LastTrainTime", 23, 30);
result[id] = new RailLine4229938(
id,
Str(row, "LineName"),
UInt(row, "TrainTemplateId"),
Str(row, "PrefabPath"),
Str(row, "BlobName"),
Bool(row, "RingLine"),
Bool(row, "IsTestPath"),
Int(row, "DepartureInterval"),
Int(row, "MaxTrainAmount"),
total,
Double(row, "DestinationArrivalTime"),
Int(row, "AvgVelocity"),
Bool(row, "FirstStopDumb"),
Bool(row, "LastStopDumb"),
firstHour,
firstMinute,
lastHour,
lastMinute);
}
return result;
}
private static Dictionary<uint, RailStation4229938> LoadStations()
{
var result = new Dictionary<uint, RailStation4229938>();
foreach (var row in ReadRecords("RailStationConfig.json"))
{
var id = UInt(row, "Id");
if (id == 0)
continue;
var position = row.TryGetProperty("Position", out var pos) && pos.ValueKind == JsonValueKind.Array
? pos.EnumerateArray().Select(v => v.ValueKind == JsonValueKind.Number ? v.GetDouble() : 0d).ToArray()
: [];
result[id] = new RailStation4229938(
id,
Str(row, "StationName"),
Str(row, "StationChineseName"),
Str(row, "StationEnglishName"),
position.Length > 0 ? position[0] : 0,
position.Length > 1 ? position[1] : 0,
position.Length > 2 ? position[2] : 0,
UIntArray(row, "Lines"));
}
return result;
}
private static Dictionary<uint, RailRoute4229938> LoadRoutes()
{
var lines = LineCache.Value;
var stations = StationCache.Value;
// The line's Stations array is index-aligned with the stop rows, so the nth stop of a line
// belongs to the nth station id (id 0 is the depot placeholder, which has no RailStationConfig row).
var stationIdsByLine = new Dictionary<uint, uint[]>();
foreach (var row in ReadRecords("RailLineConfig.json"))
{
var id = UInt(row, "Id");
if (id != 0)
stationIdsByLine[id] = UIntArray(row, "Stations");
}
// RailStopConfig only carries LineId, so grouping by it already gives one ordered run per line
// (Line 2 is the reverse direction and therefore has its own stop rows).
var byLine = new Dictionary<uint, List<RailStop4229938>>();
foreach (var row in ReadRecords("RailStopConfig.json"))
{
var lineId = UInt(row, "LineId");
if (lineId == 0)
continue;
var stop = new RailStop4229938(
UInt(row, "Id"),
lineId,
Str(row, "LineName"),
UInt(row, "NextStop"),
Double(row, "ArrivalTime"),
Double(row, "InsertTime"),
UInt(row, "GateOpenSide"),
Bool(row, "DefaultExitStop"),
0);
if (!byLine.TryGetValue(lineId, out var list))
byLine[lineId] = list = [];
list.Add(stop);
}
var routes = new Dictionary<uint, RailRoute4229938>();
foreach (var (lineId, raw) in byLine)
{
if (!lines.TryGetValue(lineId, out var line))
continue;
var ordered = raw
.OrderBy(s => s.ArrivalTime)
.ThenBy(s => s.Id)
.ToList();
var stationIds = stationIdsByLine.TryGetValue(lineId, out var ids) ? ids : [];
for (var i = 0; i < ordered.Count; i++)
{
var stationId = i < stationIds.Length ? stationIds[i] : 0;
if (stationId != 0 && !stations.ContainsKey(stationId))
stationId = 0;
ordered[i] = ordered[i] with { StationId = stationId };
}
routes[lineId] = new RailRoute4229938(line, ordered);
}
return routes;
}
private static uint[] UIntArray(JsonElement row, string name)
{
if (!row.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.Array)
return [];
var values = new List<uint>();
foreach (var item in node.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number && item.TryGetUInt32(out var value))
values.Add(value);
}
return values.ToArray();
}
private static IEnumerable<JsonElement> ReadRecords(string fileName)
{
try
{
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
var path = Path.Combine(root, fileName);
if (!File.Exists(path))
return [];
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array)
return [];
// Clone so the elements survive the disposed JsonDocument.
return records.EnumerateArray().Select(row => row.Clone()).ToArray();
}
catch
{
return [];
}
}
private static string Str(JsonElement row, string name)
=> row.TryGetProperty(name, out var node) && node.ValueKind == JsonValueKind.String ? node.GetString() ?? string.Empty : string.Empty;
private static uint UInt(JsonElement row, string name)
=> row.TryGetProperty(name, out var node) && node.ValueKind == JsonValueKind.Number && node.TryGetUInt32(out var value) ? value : 0u;
private static int Int(JsonElement row, string name)
=> row.TryGetProperty(name, out var node) && node.ValueKind == JsonValueKind.Number && node.TryGetInt32(out var value) ? value : 0;
private static double Double(JsonElement row, string name)
=> row.TryGetProperty(name, out var node) && node.ValueKind == JsonValueKind.Number ? node.GetDouble() : 0d;
private static bool Bool(JsonElement row, string name)
=> row.TryGetProperty(name, out var node) && node.ValueKind == JsonValueKind.True;
private static (int Hour, int Minute) Clock(JsonElement row, string name, int fallbackHour, int fallbackMinute)
{
if (!row.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.Object)
return (fallbackHour, fallbackMinute);
var hour = node.TryGetProperty("hour", out var h) && h.TryGetInt32(out var hourValue) ? hourValue : fallbackHour;
var minute = node.TryGetProperty("minute", out var m) && m.TryGetInt32(out var minuteValue) ? minuteValue : fallbackMinute;
return (hour, minute);
}
}
@@ -0,0 +1,207 @@
using System.Text.Json;
using Ananta.Server.Configuration;
namespace Ananta.Server.ClientData.Client4229938;
internal static class SocialCatalogRepository4229938
{
internal sealed record PostRow(
uint Id,
uint Publisher,
string Txt,
uint Likes,
uint LikeDrop,
bool IfShowTips,
bool IfStory,
bool IfPinStory,
bool WithMe,
IReadOnlyList<uint> CommentIds);
internal sealed record PublisherRow(
uint Id,
string Name,
uint Simage,
uint NpcCultivation,
IReadOnlyList<uint> SamePublishers);
internal sealed record CommentRow(uint Id, uint Publisher, uint NextId, string Txt);
private static readonly object Sync = new();
private static List<PostRow>? _posts;
private static Dictionary<uint, PublisherRow>? _publishers;
private static Dictionary<uint, CommentRow>? _comments;
private static string Root => PrivateServerConfigStore.ResolveProjectPath(
PrivateServerConfigStore.Current.Paths.ClientConfigs);
internal static IReadOnlyList<PostRow> AllPosts
{
get { EnsureLoaded(); lock (Sync) return _posts!; }
}
internal static PostRow? Post(uint id)
{
EnsureLoaded();
lock (Sync)
return _posts!.FirstOrDefault(x => x.Id == id);
}
internal static PublisherRow? Publisher(uint id)
{
EnsureLoaded();
lock (Sync)
return _publishers!.TryGetValue(id, out var v) ? v : null;
}
internal static CommentRow? Comment(uint id)
{
EnsureLoaded();
lock (Sync)
return _comments!.TryGetValue(id, out var v) ? v : null;
}
internal static string Summary()
{
EnsureLoaded();
lock (Sync)
return $"SocialMediaConfig={_posts!.Count} 条动态"
+ $" / SocialMediaNPCConfig={_publishers!.Count} 个发布者"
+ $" / SocialMediaCommentConfig={_comments!.Count} 条评论"
+ $"(WithMe={_posts.Count(x => x.WithMe)})";
}
private static void EnsureLoaded()
{
if (_posts is not null)
return;
lock (Sync)
{
if (_posts is not null)
return;
var posts = new List<PostRow>();
var publishers = new Dictionary<uint, PublisherRow>();
var comments = new Dictionary<uint, CommentRow>();
var root = Root;
ForEachRecord(root, "SocialMediaConfig.json", row =>
{
var id = U32(row, "Id");
if (id == 0)
return;
posts.Add(new PostRow(
id,
U32(row, "Publisher"),
Str(row, "Txt") ?? string.Empty,
U32(row, "Likes"),
U32(row, "LikeDrop"),
Bool(row, "IfShowTips"),
Bool(row, "IfStory"),
Bool(row, "IfPinStory"),
Bool(row, "WithMe"),
U32List(row, "Comment")));
});
ForEachRecord(root, "SocialMediaNPCConfig.json", row =>
{
var id = U32(row, "Id");
if (id == 0)
return;
publishers[id] = new PublisherRow(
id,
Str(row, "Name") ?? string.Empty,
U32(row, "Simage"),
U32(row, "NpcCultivation"),
U32List(row, "SamePublishers"));
});
ForEachRecord(root, "SocialMediaCommentConfig.json", row =>
{
var id = U32(row, "Id");
if (id == 0)
return;
comments[id] = new CommentRow(
id,
U32(row, "Publisher"),
U32(row, "NextId"),
Str(row, "Txt") ?? string.Empty);
});
_posts = [.. posts.OrderBy(x => x.Id)];
_publishers = publishers;
_comments = comments;
Console.WriteLine($"[SOCIAL] {Summary()}");
}
}
private static void ForEachRecord(string root, string fileName, Action<JsonElement> visit)
{
var path = Path.Combine(root, fileName);
if (!File.Exists(path))
{
Console.WriteLine($"[SOCIAL] 缺少配置 {fileName}(叭叭 App 内容会不完整)");
return;
}
try
{
using var document = JsonDocument.Parse(File.ReadAllText(path));
if (!document.RootElement.TryGetProperty("records", out var records)
|| records.ValueKind != JsonValueKind.Array)
{
Console.WriteLine($"[SOCIAL] {fileName} 没有 records 数组");
return;
}
foreach (var row in records.EnumerateArray())
{
if (row.ValueKind == JsonValueKind.Object)
visit(row);
}
}
catch (Exception ex)
{
Console.WriteLine($"[SOCIAL] 读取 {fileName} 失败: {ex.Message}");
}
}
private static uint U32(JsonElement row, string name)
=> row.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
? v.GetUInt32()
: 0u;
private static string? Str(JsonElement row, string name)
=> row.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
private static bool Bool(JsonElement row, string name)
=> row.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True;
private static IReadOnlyList<uint> U32List(JsonElement row, string name)
{
if (!row.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.Array)
return [];
var list = new List<uint>();
foreach (var item in v.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number)
list.Add(item.GetUInt32());
}
return list;
}
}
@@ -0,0 +1,81 @@
using System.Text.Json;
using Ananta.Server.Configuration;
namespace Ananta.Server.ClientData.Client4229938;
/// <summary>One TaskConfig row: everything the quest state machine needs from the server side.</summary>
internal sealed record TaskEntry4229938(
uint Id,
string Name,
uint RelatedRaid,
int CounterCount,
uint[] NextTasks);
/// <summary>
/// Build-4229938 quest table (TaskConfig.json, 4.3k tasks from the client dump). The client
/// bakes the same config, so the server only tracks state per task id and chains NextTasks on
/// submit; names/objectives render from the client's own copy.
/// </summary>
internal static class TaskCatalog4229938
{
private static readonly Lazy<Dictionary<uint, TaskEntry4229938>> Cache = new(Load);
internal static IReadOnlyDictionary<uint, TaskEntry4229938> All => Cache.Value;
internal static bool TryGet(uint taskId, out TaskEntry4229938 entry)
=> Cache.Value.TryGetValue(taskId, out entry!);
internal static IEnumerable<TaskEntry4229938> ByRaid(uint raidId)
=> Cache.Value.Values.Where(t => t.RelatedRaid == raidId);
private static Dictionary<uint, TaskEntry4229938> Load()
{
var result = new Dictionary<uint, TaskEntry4229938>();
try
{
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
var path = Path.Combine(root, "TaskConfig.json");
if (!File.Exists(path))
{
Console.WriteLine($"[TASK] TaskConfig.json not found at {path}; quest catalog disabled.");
return result;
}
using var doc = JsonDocument.Parse(File.ReadAllText(path));
// Dump table shape: {"_class":..., "records":[...]}.
if (!doc.RootElement.TryGetProperty("records", out var rows) || rows.ValueKind != JsonValueKind.Array)
return result;
foreach (var row in rows.EnumerateArray())
{
var id = U32(row, "Id");
if (id == 0)
continue;
var next = new List<uint>();
if (row.TryGetProperty("NextTasks", out var nt) && nt.ValueKind == JsonValueKind.Array)
{
foreach (var n in nt.EnumerateArray())
next.Add(n.GetUInt32());
}
var counterCount = 0;
if (row.TryGetProperty("Counter", out var counters) && counters.ValueKind == JsonValueKind.Array)
counterCount = counters.GetArrayLength();
result[id] = new TaskEntry4229938(
id,
row.TryGetProperty("Name", out var name) ? name.GetString() ?? string.Empty : string.Empty,
U32(row, "RelatedRaid"),
counterCount,
next.ToArray());
}
Console.WriteLine($"[TASK] Quest catalog ready: {result.Count} tasks.");
}
catch (Exception ex)
{
Console.WriteLine($"[TASK-ERROR] Failed to load TaskConfig.json: {ex.Message}");
}
return result;
}
private static uint U32(JsonElement row, string field)
=> row.TryGetProperty(field, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetUInt32() : 0u;
}
@@ -0,0 +1,266 @@
using System.Text.Json;
using Ananta.Server.Configuration;
namespace Ananta.Server.ClientData.Client4229938;
internal static class TaskCatalogRepository
{
private static readonly object Sync = new();
private static Dictionary<uint, string>? _names;
private static Dictionary<uint, string>? _titles;
private static Dictionary<uint, string>? _descriptions;
private static Dictionary<uint, int[]>? _counters;
private static Dictionary<uint, string[]>? _objectives;
private static Dictionary<uint, uint[]>? _nextTasks;
private static Dictionary<uint, int>? _titleIds;
private static Dictionary<uint, uint>? _relatedRaids;
internal static uint[] NextTasks(uint taskId)
{
EnsureLoaded();
lock (Sync)
return _nextTasks!.TryGetValue(taskId, out var value) ? value : [];
}
internal static int TitleId(uint taskId)
{
EnsureLoaded();
lock (Sync)
return _titleIds!.TryGetValue(taskId, out var value) ? value : 0;
}
internal static uint RelatedRaid(uint taskId)
{
EnsureLoaded();
lock (Sync)
return _relatedRaids!.TryGetValue(taskId, out var value) ? value : 0u;
}
internal static IReadOnlyList<uint> ChainFrom(uint rootId, int maxLength = 200)
{
EnsureLoaded();
var chain = new List<uint>();
var seen = new HashSet<uint>();
var current = rootId;
while (current != 0 && seen.Add(current) && chain.Count < maxLength)
{
chain.Add(current);
var next = NextTasks(current);
current = next.Length > 0 ? next[0] : 0u;
}
return chain;
}
internal static int[] Counters(uint taskId)
{
EnsureLoaded();
lock (Sync)
return _counters!.TryGetValue(taskId, out var value) ? value : [];
}
internal static string[] Objectives(uint taskId)
{
EnsureLoaded();
lock (Sync)
return _objectives!.TryGetValue(taskId, out var value) ? value : [];
}
internal static IReadOnlyDictionary<uint, string> Names
{
get { EnsureLoaded(); lock (Sync) return new Dictionary<uint, string>(_names!); }
}
internal static int Count
{
get { EnsureLoaded(); lock (Sync) return _names!.Count; }
}
internal static string? Name(uint taskId)
{
EnsureLoaded();
lock (Sync)
return _names!.TryGetValue(taskId, out var value) ? value : null;
}
internal static string? Title(uint taskId)
{
EnsureLoaded();
lock (Sync)
return _titles!.TryGetValue(taskId, out var value) ? value : null;
}
internal static string? Description(uint taskId)
{
EnsureLoaded();
lock (Sync)
return _descriptions!.TryGetValue(taskId, out var value) ? value : null;
}
internal static IEnumerable<(uint Id, string Name)> Search(string? term, int limit = 200)
{
EnsureLoaded();
lock (Sync)
{
var query = _names!.AsEnumerable();
if (!string.IsNullOrWhiteSpace(term))
{
var needle = term.Trim();
query = uint.TryParse(needle, out var exact)
? query.Where(x => x.Key == exact || x.Value.Contains(needle, StringComparison.OrdinalIgnoreCase))
: query.Where(x => x.Value.Contains(needle, StringComparison.OrdinalIgnoreCase));
}
return query.OrderBy(x => x.Key).Take(limit).Select(x => (x.Key, x.Value)).ToArray();
}
}
private static void EnsureLoaded()
{
if (_names is not null)
return;
lock (Sync)
{
if (_names is not null)
return;
_names = [];
_titles = [];
_descriptions = [];
_counters = [];
_objectives = [];
_nextTasks = [];
_titleIds = [];
_relatedRaids = [];
try
{
var config = PrivateServerConfigStore.Current;
var root = PrivateServerConfigStore.ResolveProjectPath(config.Paths.ClientConfigs);
var path = Path.Combine(root, "TaskConfig.json");
if (!File.Exists(path))
{
Console.WriteLine($"[TASKS] 找不到 {path};任务名不可用");
return;
}
using var document = JsonDocument.Parse(File.ReadAllText(path));
if (!document.RootElement.TryGetProperty("records", out var records) ||
records.ValueKind != JsonValueKind.Array)
{
Console.WriteLine($"[TASKS] {path} 没有 records 数组");
return;
}
foreach (var row in records.EnumerateArray())
{
if (!row.TryGetProperty("Id", out var idNode) || !idNode.TryGetUInt32(out var id) || id == 0)
continue;
var name = ReadString(row, "Name");
if (!string.IsNullOrWhiteSpace(name))
_names[id] = name!;
var title = ReadString(row, "Title");
if (!string.IsNullOrWhiteSpace(title))
_titles[id] = title!;
var description = ReadString(row, "WorkDescription");
if (!string.IsNullOrWhiteSpace(description))
_descriptions[id] = description!;
var counters = ReadIntArray(row, "Counter");
if (counters.Length > 0)
_counters[id] = counters;
var objectives = ReadStringArray(row, "EventObjective");
if (objectives.Length > 0)
_objectives[id] = objectives;
var next = ReadUIntArray(row, "NextTasks");
if (next.Length > 0)
_nextTasks[id] = next;
_titleIds[id] = ReadInt32(row, "Title");
_relatedRaids[id] = ReadUInt32(row, "RelatedRaid");
}
Console.WriteLine($"[TASKS] TaskConfig 已载入: {_names.Count} 个具名任务, {_counters.Count} 个带计数, 来自 {path}");
}
catch (Exception ex)
{
Console.WriteLine($"[TASKS] 载入 TaskConfig 失败: {ex.Message}");
}
}
}
private static string? ReadString(JsonElement row, string name)
{
if (!row.TryGetProperty(name, out var node))
return null;
return node.ValueKind switch
{
JsonValueKind.String => node.GetString(),
JsonValueKind.Number => node.ToString(),
_ => null,
};
}
private static int[] ReadIntArray(JsonElement row, string name)
{
if (!row.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.Array)
return [];
return node.EnumerateArray()
.Where(x => x.TryGetInt32(out _))
.Select(x => x.GetInt32())
.ToArray();
}
private static int ReadInt32(JsonElement row, string name)
=> row.TryGetProperty(name, out var node) && node.TryGetInt32(out var value) ? value : 0;
private static uint ReadUInt32(JsonElement row, string name)
=> row.TryGetProperty(name, out var node) && node.TryGetUInt32(out var value) ? value : 0u;
private static uint[] ReadUIntArray(JsonElement row, string name)
{
if (!row.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.Array)
return [];
return node.EnumerateArray()
.Where(x => x.TryGetUInt32(out _))
.Select(x => x.GetUInt32())
.ToArray();
}
private static string[] ReadStringArray(JsonElement row, string name)
{
if (!row.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.Array)
return [];
return node.EnumerateArray()
.Where(x => x.ValueKind == JsonValueKind.String)
.Select(x => x.GetString() ?? string.Empty)
.ToArray();
}
}
@@ -0,0 +1,90 @@
using System.Text.Json;
using Ananta.Server.Configuration;
namespace Ananta.Server.ClientData.Client4229938;
/// <summary>One VehicleSpawnConfig row: what NPC drives a vehicle config and its paint color pool.</summary>
internal sealed record VehicleSpawnEntry4229938(
uint VehicleId,
uint DriverNpcType,
uint PassengerNpcType,
int PassengerCount,
uint[] ColorMods);
/// <summary>
/// Build-4229938 ambient traffic spawn table (VehicleSpawnConfig.json): pairs each traffic
/// vehicle config with its driver NPC formwork, passengers and paint color pool — the same
/// data the real game uses (driver 45200091, passenger 45200093, color parts 9994xxxx).
/// </summary>
internal static class VehicleSpawnCatalog4229938
{
private static readonly VehicleSpawnEntry4229938 Fallback = new(0, 45200091, 45200093, 0, []);
private static readonly Lazy<Dictionary<uint, VehicleSpawnEntry4229938>> Cache = new(Load);
internal static VehicleSpawnEntry4229938 Get(uint vehicleId)
=> Cache.Value.TryGetValue(vehicleId, out var entry) ? entry : Fallback;
private static Dictionary<uint, VehicleSpawnEntry4229938> Load()
{
var result = new Dictionary<uint, VehicleSpawnEntry4229938>();
try
{
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
var path = Path.Combine(root, "VehicleSpawnConfig.json");
if (!File.Exists(path))
return result;
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array)
return result;
foreach (var row in records.EnumerateArray())
{
var vehicleId = U32(row, "VehicleId");
if (vehicleId == 0)
continue;
var driver = FirstU32(row, "NewNpcType");
if (driver == 0)
continue;
var passengerCount = I32(row, "HasPassenger");
var passenger = FirstU32(row, "NewPassengerType");
result[vehicleId] = new VehicleSpawnEntry4229938(
vehicleId,
driver,
passenger == 0 ? 45200093u : passenger,
Math.Clamp(passengerCount, 0, 3),
U32Array(row, "ColorMod"));
}
}
catch
{
// Missing/corrupt table: the fallback entry keeps traffic spawning with defaults.
}
return result;
}
private static uint FirstU32(JsonElement row, string name)
{
if (!row.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.Array)
return 0u;
foreach (var item in node.EnumerateArray())
if (item.ValueKind == JsonValueKind.Number && item.TryGetUInt32(out var value))
return value;
return 0u;
}
private static uint U32(JsonElement row, string name)
=> row.TryGetProperty(name, out var node) && node.ValueKind == JsonValueKind.Number && node.TryGetUInt32(out var value) ? value : 0u;
private static uint[] U32Array(JsonElement row, string name)
{
if (!row.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.Array)
return [];
var values = new List<uint>();
foreach (var item in node.EnumerateArray())
if (item.ValueKind == JsonValueKind.Number && item.TryGetUInt32(out var value))
values.Add(value);
return values.ToArray();
}
private static int I32(JsonElement row, string name)
=> row.TryGetProperty(name, out var node) && node.ValueKind == JsonValueKind.Number && node.TryGetInt32(out var value) ? value : 0;
}