using System.Text.Json; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; namespace Ananta.Server.ClientData.Client4229938; /// /// Reads authored client data directly from the extracted JSON configuration files. /// /// There is no generated character/switch catalog in the source tree. If you need to understand /// why a character or animation is available, inspect FightSpiritConfig.json / /// SwitchSpiritConfig.json in the configured client-data directory. /// internal static class ClientConfigRepository { // This exact 4229938 client/VFS capture fails to resolve the streaming animation hashes for // these generic switch timelines. Prefer another authored row when available; do not globally // disable streaming animation for every cutscene. private static readonly HashSet KnownBrokenLocalStreamingSwitchTimelines4229938 = new(StringComparer.Ordinal) { "SwitchChar_common_09", "SwitchChar_common_14", }; private static ClientData Data => Cache.Value; internal static IReadOnlyList Characters() => Data.Characters; // 科斯机甲(移植捐赠端 7k-v0.5 ClientConfigRepository.cs:31-34)。 // FightSpiritConfig 15022030 = "特摄战原型-科斯":IsPublish=false / CanJoin=false / Invisible=true, // 所以它永远不在 Characters() 里 —— 这是"隐藏可控原型",只能由服务端显式下发。 // 原生客户端 CheckIsKesi 用 cardId 15022030 判断。 // // ⛑⛑ UnitId **必须**落在 0x300000000000 段(= 52776558133248,GameAdminBridge.SummonedNpcUidBase), // 不能用 100000099030 那种 100000000000+ 的值 —— 硬规则 165:那段是客户端**预分配的切人身份**, // `SyncLogicAgentLeave`(68281244) 对它**静默 no-op**。2026-09-26 05:15 实测:返回原角色时 // 服务端确实发了 `reclaimedOldUnit=100000099030`,客户端一句都没执行 ⇒ 机甲 T-pose 残骸留在大世界。 // +800000 是为了避开 NextRetirableNpcUid() 的自增序数(留场/召唤共用,目前才用到个位数)。 internal static readonly CharacterCatalogEntry Kesi = new(15022030, 0x300000000000UL + 800000UL, "科斯机甲(隐藏原型)", 25901028, 100); /// /// 角色 id 反查:科斯不在 roster 里,必须单独放行;其余走 。 /// internal static CharacterCatalogEntry? ControlCharacter(uint templateId) => templateId == Kesi.TemplateId ? Kesi : Characters().FirstOrDefault(x => x.TemplateId == templateId); internal static IReadOnlyList UnlockSystemIds() => Data.UnlockSystemIds; internal static IReadOnlyList ActionItemIds() => Data.ActionItemIds; /// /// Every rank of the career that belongs to, lowest rank first. /// A career is authored as a PreJob chain (401 -> 402 -> 403) and the client treats the whole /// chain as one career class, so a character authored on any rank still owns every rank above /// and below it. Returns just when the id is unknown or Jobless. /// internal static IReadOnlyList UrbanJobChain(uint jobId) { var rows = Data.UrbanJobs; if (rows.Length == 0 || jobId == 0) return Array.Empty(); var byId = rows.ToDictionary(x => x.Id); if (!byId.TryGetValue(jobId, out var seed)) return Array.Empty(); // Walk up to the root of the chain, then down again: the seed may be any rank of it. var root = seed; var guard = 0; while (root.PreJob != 0 && byId.ContainsKey(root.PreJob) && guard++ < rows.Length) root = byId[root.PreJob]; var chain = new List { root }; for (var i = 0; i < chain.Count; i++) foreach (var row in rows) if (row.PreJob == chain[i].Id && !chain.Contains(row)) chain.Add(row); return chain.OrderBy(x => x.Level).ThenBy(x => x.Id).ToArray(); } /// /// The single authored row for , or null when it is unknown or /// Jobless. Read it for — that, not the job id, is what the /// job-level table () and the phone-app gate are keyed by. /// internal static UrbanJobRow? UrbanJob(uint jobId) => jobId == 0 ? null : Data.UrbanJobs.FirstOrDefault(x => x.Id == jobId); /// /// Auxiliary skills authored directly on the character row (FightSpiritConfig.TempSkill). /// They appear in no weapon's skill list, so the weapon sandbox alone would reject every /// request the client sends for them. Data-driven on purpose: never hard-code these ids. /// internal static IReadOnlyList TempSkills(uint templateId) => Data.TempSkillsBySpirit.TryGetValue(templateId, out var ids) ? ids : Array.Empty(); internal static IReadOnlyList InstalledPhoneAppIds() => Data.InstalledPhoneAppIds; internal static IReadOnlyList SelectableFashionIds() => Data.SelectableFashionIds; internal static IReadOnlyList DefaultFashionIds(uint templateId) => Data.DefaultFashionIdsBySpirit.TryGetValue(templateId, out var ids) ? ids : Array.Empty(); internal static (Vec3 Position, float Facing) EntryPlacement(uint switchShowId) => Data.SwitchRows.TryGetValue(switchShowId, out var row) ? (row.Position, row.Facing) : (Profile.WorldSpawn, Profile.WorldFacing); /// /// 面板「切人演出」用的**按动画分**清单 —— 一行 SwitchSpiritConfig = 一条切人演出。 /// /// ⛑ 捐赠端 GM 面板的「切人动画演出」子页主下拉也是这个粒度(201 条),第二个下拉 /// 才是「切成哪个角色」。我第一版按**角色**做是错的 —— 同一个角色有十几条演出, /// 按角色只能触发服务端替他挑的那一条(受 DisperseAnchorsByCharacter / 距离 / /// 权重影响),玩家想看「送人下车」那条根本选不到。 /// internal static IReadOnlyList SwitchShows() => Data.SwitchRows.Values .OrderBy(x => x.Id) .Select(ToSwitchShowRow) .ToArray(); /// 取单条切人演出;不存在返回 null。 internal static SwitchShowRow? SwitchShow(uint switchShowId) => Data.SwitchRows.TryGetValue(switchShowId, out var row) ? ToSwitchShowRow(row) : null; private static SwitchShowRow ToSwitchShowRow(SwitchRow r) => new( r.Id, r.Timeline, r.Description, r.TimelineDescription, r.SwitchType, r.RaidId, r.Weight, r.Invalid, r.LoginInvalid, r.Position, r.Facing, r.UseZeroPosAndAngle, r.FightSpiritIds.OrderBy(x => x).ToArray(), r.FirstSwitchSpiritIds.OrderBy(x => x).ToArray(), r.ForbidFightSpiritIds.OrderBy(x => x).ToArray(), r.AgentIds.OrderBy(x => x).ToArray(), // 是不是「通用行」:timeline 以 CommonTimelinePrefix 开头。 // ⛑ 语义照 ClientConfigRepository.IdsFor —— 只有通用行才允许 FightSpiritId 为空 // 表示「人人可用」;非通用行若 FightSpiritId/FirstSwitchSpiritId 都空 ⇒ 谁都演不了。 r.Timeline.StartsWith( PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.CommonTimelinePrefix, StringComparison.Ordinal)); internal static IReadOnlyList SwitchAnimations(uint templateId) => Data.SwitchIdsBySpirit.TryGetValue(templateId, out var ids) ? ids : Data.CommonSwitchIds; internal static IReadOnlyList EntryAnimations(uint templateId) => Data.EntryIdsBySpirit.TryGetValue(templateId, out var ids) ? ids : Data.CommonEntryIds; internal static uint RandomEntryAnimation(uint templateId) { var ids = EntryAnimations(templateId); if (ids.Count == 0) throw new InvalidOperationException($"No world-entry animations for spirit {templateId} in SwitchSpiritConfig.json."); // Entry presentation must agree with the configured world spawn. Prefer the nearest authored // row that does not require external agents and whose streaming animation is present in this // 4229938 client capture. This replaces the old random cross-map entry selection. var candidates = ids .Where(id => Data.SwitchRows.ContainsKey(id)) .Select(id => Data.SwitchRows[id]) .Where(row => row.AgentIds.Count == 0) .Where(row => !KnownBrokenLocalStreamingSwitchTimelines4229938.Contains(row.Timeline)) .Select(row => { var dx = (double)row.Position.X - Profile.WorldSpawn.X; var dz = (double)row.Position.Z - Profile.WorldSpawn.Z; return new { row.Id, Distance = Math.Sqrt(dx * dx + dz * dz), row.Weight }; }) .OrderBy(x => x.Distance) .ThenByDescending(x => x.Weight) .ThenBy(x => x.Id) .ToArray(); return candidates.Length > 0 ? candidates[0].Id : ids[0]; } internal static SwitchAnimationChoice RandomSwitchAnimation(uint templateId, uint previousSwitchShowId, Vec3 position) { var ids = SwitchAnimations(templateId); if (ids.Count == 0) throw new InvalidOperationException($"No SwitchSpiritConfig animations for spirit {templateId}."); // Generic server switching cannot satisfy authored companion-agent rows. Those rows carry // AgentId entries that stock SwitchTeleport expects the server to spawn before landing. var candidateRows = ids .Where(id => Data.SwitchRows.ContainsKey(id)) .Select(id => Data.SwitchRows[id]) .Where(row => row.AgentIds.Count == 0) .ToArray(); var preferredRows = candidateRows .Where(row => !KnownBrokenLocalStreamingSwitchTimelines4229938.Contains(row.Timeline)) .ToArray(); if (preferredRows.Length == 0) preferredRows = candidateRows; var usableRows = preferredRows .Select(row => { var dx = (double)row.Position.X - position.X; var dz = (double)row.Position.Z - position.Z; return new SwitchAnimationCandidate( row.Id, row.Timeline, row.Weight > 0f && float.IsFinite(row.Weight) ? row.Weight : 1f, Math.Sqrt(dx * dx + dz * dz), row.Position, row.Facing, row.UseZeroPosAndAngle); }) .OrderBy(x => x.AnchorDistance) .ThenByDescending(x => x.Weight) .ThenBy(x => x.Id) .ToArray(); if (usableRows.Length == 0) throw new InvalidOperationException($"No usable SwitchSpiritConfig rows for spirit {templateId}."); string? previousTimeline = null; if (previousSwitchShowId != 0 && Data.SwitchRows.TryGetValue(previousSwitchShowId, out var previousRow)) previousTimeline = previousRow.Timeline; // SwitchSpiritConfig rows are spatially authored. Randomly choosing a timeline first can bind a // current position to an animation whose authored anchor is kilometers away. Pick the nearest // compatible row; only avoid an immediate timeline repeat when an almost-equally-near alternative // exists, so anti-repeat never wins over spatial validity. var selected = usableRows[0]; if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.AvoidImmediateRepeat && previousTimeline is not null) { var maxAlternativeDistance = selected.AnchorDistance + 150d; var alternative = usableRows.FirstOrDefault(x => x.AnchorDistance <= maxAlternativeDistance && !string.Equals(x.Timeline, previousTimeline, StringComparison.Ordinal)); if (alternative is not null) selected = alternative; } return new SwitchAnimationChoice( selected.Id, selected.Timeline, selected.Weight, (float)selected.AnchorDistance, usableRows.Select(x => x.Timeline).Distinct(StringComparer.Ordinal).Count(), usableRows.Length, previousTimeline, selected.Position, selected.Facing, selected.UseZeroPosAndAngle); } /// /// 按角色分锚点:每个角色固定落到「属于自己的那一行」SwitchSpiritConfig, /// 而不是「离玩家最近的那行」。用户 2026-09-24 17:5x 实测:最近锚点模式会让所有角色 /// 都落在咖啡馆/电影院门口那一两个点上,挤成一团;按 templateId 散列选行后, /// 每个角色有自己固定的落点,全城分散。 /// 选择是确定性的(同一角色永远同一行),不做防连续重复 —— 一个角色的落点恒定。 /// internal static SwitchAnimationChoice SwitchAnimationForTemplate(uint templateId, uint previousSwitchShowId) { var ids = SwitchAnimations(templateId); if (ids.Count == 0) throw new InvalidOperationException($"No SwitchSpiritConfig animations for spirit {templateId}."); var candidateRows = ids .Where(id => Data.SwitchRows.ContainsKey(id)) .Select(id => Data.SwitchRows[id]) .Where(row => row.AgentIds.Count == 0) .ToArray(); var preferredRows = candidateRows .Where(row => !KnownBrokenLocalStreamingSwitchTimelines4229938.Contains(row.Timeline)) .ToArray(); if (preferredRows.Length == 0) preferredRows = candidateRows; if (preferredRows.Length == 0) throw new InvalidOperationException($"No usable SwitchSpiritConfig rows for spirit {templateId}."); // 按行 Id 排序后用 Knuth 乘散列取模 —— 相邻 templateId 也会散到不同的行。 var usableRows = preferredRows.OrderBy(x => x.Id).ToArray(); var index = (int)((templateId * 2654435761u) % (uint)usableRows.Length); var selected = usableRows[index]; string? previousTimeline = null; if (previousSwitchShowId != 0 && Data.SwitchRows.TryGetValue(previousSwitchShowId, out var previousRow)) previousTimeline = previousRow.Timeline; return new SwitchAnimationChoice( selected.Id, selected.Timeline, selected.Weight > 0f && float.IsFinite(selected.Weight) ? selected.Weight : 1f, 0f, usableRows.Select(x => x.Timeline).Distinct(StringComparer.Ordinal).Count(), usableRows.Length, previousTimeline, selected.Position, selected.Facing, selected.UseZeroPosAndAngle); } /// /// 切人演出行的落点。官方语义:Position 是被切过去的角色真正站立的位置(进场演出 /// 用的就是同一对值),客户端的 SwitchTeleport 镜头飞的就是这个点。 /// internal static (Vec3 Position, float Facing, bool UseZeroPosAndAngle) SwitchPlacement(uint switchShowId) => Data.SwitchRows.TryGetValue(switchShowId, out var row) ? (row.Position, row.Facing, row.UseZeroPosAndAngle) : (Profile.WorldSpawn, Profile.WorldFacing, false); private static readonly Lazy Cache = new(Load); private static ClientData Load() { var cfg = PrivateServerConfigStore.Current; var root = PrivateServerConfigStore.ResolveProjectPath(cfg.Paths.ClientConfigs); var fightSpirits = ReadRecords(Path.Combine(root, "FightSpiritConfig.json")); var switches = ReadRecords(Path.Combine(root, "SwitchSpiritConfig.json")); var unlockSystems = ReadRecords(Path.Combine(root, "SystemUnlockConfig.json")); var fashions = ReadRecords(Path.Combine(root, "FashionConfig.json")); var mobileApps = ReadRecords(Path.Combine(root, "MobileMenuSGuiConfig.json")); var urbanJobs = ReadRecords(Path.Combine(root, "UrbanJobConfig.json")); var roster = cfg.Content.Roster; var excluded = roster.ExcludedTemplateIds.ToHashSet(); // Force-included rows (see RosterSettings.AdditionalTemplateIds): these escape both the // explicit exclusion list and the placeholder-icon filter, so an unpublished row can join // the roster without loosening those two rules for everything else. var forcedInclusions = roster.AdditionalTemplateIds.ToHashSet(); // Panel display names for rows whose client-side copy was rewritten (rowOverrides). The // server projection keeps the stock AgentId, so without this the panel would print the // placeholder's own name ("OC FlightSpirit占位_幼女") instead of the character that row // now carries in the client. var labels = roster.RowOverrides .Where(x => x.SpiritId != 0 && !string.IsNullOrWhiteSpace(x.Label)) .ToDictionary(x => x.SpiritId, x => x.Label.Trim()); var characters = fightSpirits .Select(ParseCharacterSource) .Where(x => x.HasValue) .Select(x => x.GetValueOrDefault()) // A force-included row escapes the id window too, not just the two filters: the // placeholder rows we clone characters onto live above TemplateIdMaxExclusive, and // raising the window instead would drag ~68 dev rows (test robots, 武学 folders, // PVP dummies) into the roster and onto the Z wheel. .Where(x => forcedInclusions.Contains(x.TemplateId) || (x.TemplateId >= roster.TemplateIdMin && x.TemplateId < roster.TemplateIdMaxExclusive && !excluded.Contains(x.TemplateId) && // requireIconId=false 时放行"头像未制作"的行(SHeadIconID==0 或占位图 28000032)。 // 这些行在轮盘上会显示占位头像,但角色本身是可用的 —— 默认 true 保持旧行为。 (!roster.RequireIconId || (x.IconId > 0 && x.IconId != roster.GenericPlaceholderIconId)))) .OrderBy(x => x.TemplateId) .Select((x, index) => new CharacterCatalogEntry( x.TemplateId, roster.UnitIdBase + (ulong)index, labels.TryGetValue(x.TemplateId, out var label) ? label : x.Name, x.IconId, x.DefaultUrbanJob)) .ToArray(); if (characters.Length == 0) throw new InvalidDataException("FightSpiritConfig.json produced an empty playable roster."); if (characters[0].TemplateId != cfg.Player.InitialSpiritTemplateId) throw new InvalidDataException( $"Playable roster begins with {characters[0].TemplateId}, expected initial spirit {cfg.Player.InitialSpiritTemplateId}."); // Authored auxiliary skills carried straight on the character row (FightSpiritConfig.TempSkill). // They belong to no weapon, yet the client still sends them through C2S AskUseSkill — that is // how Seymour's spider/drone summon buttons reach us. Read from the raw rows rather than from // `characters` so a row outside the roster window still resolves. var tempSkillsBySpirit = fightSpirits .Select(x => (Id: TryUInt32(x, "Id", out var id) ? id : 0u, Skills: ReadUInt32Array(x, "TempSkill"))) .Where(x => x.Id != 0 && x.Skills.Count > 0) .ToDictionary(x => x.Id, x => x.Skills.OrderBy(skillId => skillId).ToArray()); var switchRows = switches .Select(ParseSwitchRow) .Where(x => x is not null) .Select(x => x!) .Where(x => x.SwitchType == cfg.Gameplay.SwitchAnimations.SwitchType) .Where(x => x.RaidId == cfg.World.RaidId) .Where(x => !x.Invalid && x.Weight > 0f && !string.IsNullOrWhiteSpace(x.Timeline)) .ToDictionary(x => x.Id); if (switchRows.Count == 0) throw new InvalidDataException("SwitchSpiritConfig.json has no usable rows for the configured raid/switch type."); uint[] IdsFor(uint templateId, bool entryOnly) { var prefix = cfg.Gameplay.SwitchAnimations.CommonTimelinePrefix; var rows = switchRows.Values.Where(x => !entryOnly || !x.LoginInvalid); var common = rows.Where(x => x.Timeline.StartsWith(prefix, StringComparison.Ordinal) && !x.ForbidFightSpiritIds.Contains(templateId) && (x.FightSpiritIds.Count == 0 && x.FirstSwitchSpiritIds.Count == 0 || x.FightSpiritIds.Contains(templateId) || x.FirstSwitchSpiritIds.Contains(templateId))); var unique = rows.Where(x => !x.Timeline.StartsWith(prefix, StringComparison.Ordinal) && (x.FightSpiritIds.Contains(templateId) || x.FirstSwitchSpiritIds.Contains(templateId))); return common.Concat(unique).Select(x => x.Id).Distinct().ToArray(); } var switchIdsBySpirit = characters.ToDictionary(x => x.TemplateId, x => IdsFor(x.TemplateId, false)); var entryIdsBySpirit = characters.ToDictionary(x => x.TemplateId, x => IdsFor(x.TemplateId, true)); foreach (var character in characters) { if (switchIdsBySpirit[character.TemplateId].Length == 0) throw new InvalidDataException($"No switch animations for character {character.TemplateId} ({character.Name})."); } var commonSwitchIds = switchRows.Values .Where(x => x.Timeline.StartsWith(cfg.Gameplay.SwitchAnimations.CommonTimelinePrefix, StringComparison.Ordinal)) .Select(x => x.Id).ToArray(); var commonEntryIds = switchRows.Values .Where(x => !x.LoginInvalid && x.Timeline.StartsWith(cfg.Gameplay.SwitchAnimations.CommonTimelinePrefix, StringComparison.Ordinal)) .Select(x => x.Id).ToArray(); var excludedUnlocks = cfg.Content.ExcludedUnlockSystems.ToHashSet(); var unlockSystemIds = unlockSystems .Select(x => TryUInt32(x, "Id", out var id) ? id : 0u) .Where(id => id != 0 && !excludedUnlocks.Contains(id)) .Distinct() .OrderBy(id => id) .ToArray(); // UrbanJobConfig: the authored career ladder. Each row is one rank of one career class — // 401 (Professional Hacker, Lv1) -> 402 (Advanced, Lv2) -> 403 (Ace, Lv3) via PreJob. // The client gates career content on the job *class* of any job present in AvailableJobs // (MainPhoneUtils.CheckJobCanShow reads JobClassIdList, e.g. EonBug's [11300004]), so a // single job id is enough to open the module — but job panels read the rank from the // highest registered job, so publish the whole authored chain up to its top rank. var urbanJobRows = urbanJobs .Select(ParseUrbanJobSource) .Where(x => x is not null) .Select(x => x!.Value) .ToArray(); var installedPhoneAppIds = mobileApps .Where(x => cfg.Content.IncludePhoneBottomApps || !TryBool(x, "IsBottom")) .Select(x => TryUInt32(x, "Id", out var id) ? id : 0u) .Where(id => id != 0) .Distinct() .OrderBy(id => id) .ToArray(); var selectableFashionIds = fashions .Where(x => TryBool(x, "IsShow") || TryBool(x, "IsGet") || TryBool(x, "IsDefault")) .Select(x => TryUInt32(x, "Id", out var id) ? id : 0u) .Where(id => id != 0) .Distinct() .OrderBy(id => id) .ToArray(); var defaultFashionIdsBySpirit = fashions .Where(x => TryBool(x, "IsDefault")) .Select(x => new { FashionId = TryUInt32(x, "Id", out var fashionId) ? fashionId : 0u, SpiritId = TryUInt32(x, "BelongSpiritId", out var spiritId) ? spiritId : 0u, }) .Where(x => x.FashionId != 0 && x.SpiritId != 0) .GroupBy(x => x.SpiritId) .ToDictionary(g => g.Key, g => g.Select(x => x.FashionId).Distinct().OrderBy(id => id).ToArray()); // Pose/emote actions (ActionItemConfig): the client keeps every action LOCKED until // its id appears in PlayerInteractionActionInfo.UnlockActionItemDict (or arrives via // SyncUnlockInteractionActionItems). Publish the full authored set so the two-person // ACTION panel (公主抱/牵手/拥抱/打招呼/...) is usable. var actionItemIds = ReadRecords(Path.Combine(root, "ActionItemConfig.json")) .Select(x => TryUInt32(x, "Id", out var id) ? id : 0u) .Where(id => id != 0) .Distinct() .OrderBy(id => id) .ToArray(); Console.WriteLine( $"[CLIENT-CONFIG] build={cfg.Client.Version} characters={characters.Length} switchRows={switchRows.Count} " + $"actionItems={actionItemIds.Length} tempSkillRows={tempSkillsBySpirit.Count} source={root}"); return new ClientData( characters, switchRows, switchIdsBySpirit, entryIdsBySpirit, commonSwitchIds, commonEntryIds, unlockSystemIds, installedPhoneAppIds, selectableFashionIds, defaultFashionIdsBySpirit, actionItemIds, urbanJobRows, tempSkillsBySpirit); } private static JsonElement[] ReadRecords(string path) { if (!File.Exists(path)) throw new FileNotFoundException($"Required client config is missing: {path}"); using var doc = JsonDocument.Parse(File.ReadAllText(path)); if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array) throw new InvalidDataException($"Client config does not contain a 'records' array: {path}"); return records.EnumerateArray().Select(x => x.Clone()).ToArray(); } private static UrbanJobRow? ParseUrbanJobSource(JsonElement value) { if (!TryUInt32(value, "Id", out var id)) return null; TryUInt32(value, "PreJob", out var preJob); TryUInt32(value, "Level", out var level); TryUInt32(value, "JobClass", out var jobClass); return new UrbanJobRow(id, preJob, level == 0 ? 1u : level, jobClass); } private static CharacterSource? ParseCharacterSource(JsonElement value) { if (!TryUInt32(value, "Id", out var id)) return null; var name = TryString(value, "Name") ?? string.Empty; TryUInt32(value, "SHeadIconID", out var iconId); // Authored urban job (UrbanJobConfig id). 0 = row has no job field; 100 = Jobless. TryUInt32(value, "DefaultUrbanJob", out var defaultUrbanJob); return new CharacterSource(id, name, iconId, defaultUrbanJob); } private static SwitchRow? ParseSwitchRow(JsonElement value) { if (!TryUInt32(value, "Id", out var id)) return null; var timeline = TryString(value, "TimeLine") ?? string.Empty; TryInt32(value, "SwitchType", out var switchType); TryUInt32(value, "RaidId", out var raidId); TrySingle(value, "Weight", out var weight); var invalid = TryBool(value, "Invalid"); var loginInvalid = TryBool(value, "LoginInvalid"); var position = ReadPosition(value, "Position", Profile.WorldSpawn); var facing = TrySingle(value, "TimeLineAngleY", out var angle) ? angle : Profile.WorldFacing; // UseZeroPosAndAngle=true 的行要求角色保持在玩家当前位置/朝向演出(不传送)。201 行里目前 // 全部是 false,但官方语义摆在这里:真出现 true 时若还按行 Position 落点就会把玩家强行拽走。 var useZero = TryBool(value, "UseZeroPosAndAngle"); return new SwitchRow( id, timeline, weight, switchType, raidId, invalid, loginInvalid, position, facing, useZero, ReadUInt32Array(value, "FightSpiritId"), ReadUInt32Array(value, "FirstSwitchSpiritId"), ReadUInt32Array(value, "ForbidFightSpiritId"), ReadUInt32Array(value, "AgentId"), TryString(value, "Description") ?? string.Empty, TryString(value, "TimeLineDescription") ?? string.Empty); } private static Vec3 ReadPosition(JsonElement value, string name, Vec3 fallback) { if (!value.TryGetProperty(name, out var item) || item.ValueKind != JsonValueKind.Array) return fallback; var values = item.EnumerateArray().Take(3).Select(x => x.TryGetSingle(out var n) ? n : float.NaN).ToArray(); return values.Length == 3 && values.All(float.IsFinite) ? new Vec3(values[0], values[1], values[2]) : fallback; } private static HashSet ReadUInt32Array(JsonElement value, string name) { if (!value.TryGetProperty(name, out var item) || item.ValueKind != JsonValueKind.Array) return new HashSet(); return item.EnumerateArray().Where(x => x.TryGetUInt32(out _)).Select(x => x.GetUInt32()).ToHashSet(); } private static bool TryUInt32(JsonElement value, string name, out uint result) { result = default; return value.TryGetProperty(name, out var item) && item.TryGetUInt32(out result); } private static bool TryInt32(JsonElement value, string name, out int result) { result = default; return value.TryGetProperty(name, out var item) && item.TryGetInt32(out result); } private static bool TrySingle(JsonElement value, string name, out float result) { result = default; return value.TryGetProperty(name, out var item) && item.TryGetSingle(out result); } private static string? TryString(JsonElement value, string name) => value.TryGetProperty(name, out var item) && item.ValueKind == JsonValueKind.String ? item.GetString() : null; private static bool TryBool(JsonElement value, string name) { if (!value.TryGetProperty(name, out var item)) return false; return item.ValueKind switch { JsonValueKind.True => true, JsonValueKind.False => false, _ => false, }; } private sealed record ClientData( CharacterCatalogEntry[] Characters, Dictionary SwitchRows, Dictionary SwitchIdsBySpirit, Dictionary EntryIdsBySpirit, uint[] CommonSwitchIds, uint[] CommonEntryIds, uint[] UnlockSystemIds, uint[] InstalledPhoneAppIds, uint[] SelectableFashionIds, Dictionary DefaultFashionIdsBySpirit, uint[] ActionItemIds, UrbanJobRow[] UrbanJobs, Dictionary TempSkillsBySpirit); private readonly record struct CharacterSource(uint TemplateId, string Name, uint IconId, uint DefaultUrbanJob); /// One rank of one career: 401 (Professional Hacker) -> 402 -> 403 via . internal readonly record struct UrbanJobRow(uint Id, uint PreJob, uint Level, uint JobClass); private sealed record SwitchRow( uint Id, string Timeline, float Weight, int SwitchType, uint RaidId, bool Invalid, bool LoginInvalid, Vec3 Position, float Facing, bool UseZeroPosAndAngle, HashSet FightSpiritIds, HashSet FirstSwitchSpiritIds, HashSet ForbidFightSpiritIds, HashSet AgentIds, // 面板「切人演出」按 **动画** 列清单时的显示名:Description = 地点(樱花大道), // TimelineDescription = 演出动作(莱卡娅送人下车)。两者都是作者手写的中文, // 201 行里绝大多数非空 ⇒ 比只显示 timeline 名(SwitchChar_common_01)好认得多。 string Description, string TimelineDescription); private sealed record SwitchAnimationCandidate( uint Id, string Timeline, float Weight, double AnchorDistance, Vec3 Position, float Facing, bool UseZeroPosAndAngle); // ------------------------------------------------------------------ // Racing / challenge loaders (R190, ported from the Lyco fork). // // All four are FAIL-SOFT: a missing json yields an empty table (the racing // feature then degrades to "no tracks / no AI"), because v2.1's own // ReadRecords throws on a missing file and racing is an additive feature — // it must never break world entry the way a required table would. // ------------------------------------------------------------------ private static readonly Lazy> RacingCompetitionGroupsCache = new(LoadRacingCompetitionGroups); private static readonly Lazy> RacingTracksCache = new(LoadRacingTracks); private static readonly Lazy ChallengesCache = new(LoadChallenges); private static readonly Lazy> RacingAiRowsCache = new(LoadRacingAiRows); /// RacingDriverCompetitionGroupConfig — one authored racing league group. internal static IReadOnlyDictionary RacingCompetitionGroups() => RacingCompetitionGroupsCache.Value; /// RacingDriverTrackInformationConfig — one authored race track. internal static IReadOnlyDictionary RacingTracks() => RacingTracksCache.Value; /// ChallengeConfig — one authored challenge (ChallengeType 2 = racing). internal static IReadOnlyList Challenges() => ChallengesCache.Value; /// RacingDriverRacingAIConfig — one authored racing opponent (cfg id → vehicle). internal static IReadOnlyDictionary RacingAiRows() => RacingAiRowsCache.Value; private static JsonElement[] ReadRecordsSafe(string root, string fileName) { try { return ReadRecords(Path.Combine(root, fileName)); } catch (Exception) { // Missing / malformed racing table = empty roster, never a crash. return []; } } private static Dictionary LoadRacingCompetitionGroups() { var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs); var result = new Dictionary(); foreach (var record in ReadRecordsSafe(root, "RacingDriverCompetitionGroupConfig.json")) { if (!TryUInt32(record, "Id", out var id) || id == 0) continue; // ContestGroup is a list of { TrackId, EventId } objects. var tracks = new List<(uint TrackId, uint EventId)>(); if (record.TryGetProperty("ContestGroup", out var group) && group.ValueKind == JsonValueKind.Array) { foreach (var item in group.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object) continue; TryUInt32(item, "TrackId", out var trackId); TryUInt32(item, "EventId", out var eventId); if (trackId != 0) tracks.Add((trackId, eventId)); } } // CarPointsEarned is a list of { Rank, Points } objects. var points = new Dictionary(); if (record.TryGetProperty("CarPointsEarned", out var earns) && earns.ValueKind == JsonValueKind.Array) { foreach (var item in earns.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object) continue; TryUInt32(item, "Rank", out var rank); TryUInt32(item, "Points", out var value); if (rank != 0) points[rank] = value; } } result[id] = new RacingCompetitionGroupRow( id, TryBool(record, "IsLocked"), TryUInt32(record, "UnlockPoint", out var unlockPoint) ? unlockPoint : 0u, TryUInt32(record, "UnlockStar", out var unlockStar) ? unlockStar : 0u, TryString(record, "Description") ?? string.Empty, tracks.ToArray(), points); } return result; } private static Dictionary LoadRacingTracks() { var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs); var result = new Dictionary(); foreach (var record in ReadRecordsSafe(root, "RacingDriverTrackInformationConfig.json")) { if (!TryUInt32(record, "Id", out var id) || id == 0) continue; result[id] = new RacingTrackRow( id, TryUInt32(record, "NumberOfLaps", out var laps) ? laps : 1u, TryUInt32(record, "MaxGamePeople", out var seats) ? seats : 0u, TryUInt32(record, "MaxGameTime", out var time) ? time : 0u, TryUInt32(record, "Difficulty", out var difficulty) ? difficulty : 0u, TryString(record, "ContestType") ?? string.Empty, ReadUInt32Array(record, "EventIds").OrderBy(x => x).ToArray(), ReadUInt32Array(record, "HighAIList").OrderBy(x => x).ToArray(), ReadUInt32Array(record, "MiddleAIList").OrderBy(x => x).ToArray(), ReadUInt32Array(record, "LowAIList").OrderBy(x => x).ToArray(), TryString(record, "RaidSpoonName") ?? string.Empty, TryString(record, "RacingPathName") ?? string.Empty, TryUInt32(record, "MapLocation", out var mapLocation) ? mapLocation : 0u); } return result; } private static Dictionary LoadRacingAiRows() { var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs); var result = new Dictionary(); foreach (var record in ReadRecordsSafe(root, "RacingDriverRacingAIConfig.json")) { if (!TryUInt32(record, "Id", out var id) || id == 0) continue; result[id] = new RacingAiRow( id, TryUInt32(record, "VehicleID", out var vehicleId) ? vehicleId : 0u, TryUInt32(record, "VehicleTaskID", out var taskId) ? taskId : 0u, TryUInt32(record, "SortScores", out var score) ? score : 0u, TryUInt32(record, "AgentQuoteID", out var quote) ? quote : 0u); } return result; } private static ChallengeDefinitionRow[] LoadChallenges() { var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs); var result = new List(); foreach (var record in ReadRecordsSafe(root, "ChallengeConfig.json")) { if (!TryUInt32(record, "Id", out var id) || id == 0) continue; result.Add(new ChallengeDefinitionRow( id, TryString(record, "Name") ?? string.Empty, TryUInt32(record, "ChallengeType", out var type) ? type : 0u, TryUInt32(record, "UrbanJobType", out var jobType) ? jobType : 0u, ReadUInt32Array(record, "RelatedTask").OrderBy(x => x).ToArray())); } return result.OrderBy(x => x.Id).ToArray(); } } // --------------------------------------------------------------------------- // Racing / challenge rows (R190, ported from the Lyco fork). // --------------------------------------------------------------------------- /// /// One authored racing league group (RacingDriverCompetitionGroupConfig): its unlock gate, the /// TrackId/EventId pairs inside it and the points awarded per finishing rank. /// internal sealed record RacingCompetitionGroupRow( uint Id, bool IsLocked, uint UnlockPoint, uint UnlockStar, string Description, (uint TrackId, uint EventId)[] Tracks, Dictionary PointsByRank); /// /// One authored challenge (ChallengeConfig.json). ChallengeType 2 == Racing; UrbanJobType 11300002 /// is the 赛车手 job class. RelatedTask is the task id the 挑战面板 hands to gTaskManager /// (challenge 1003 "王牌车神·城中之夜" → RelatedTask 60000147, raid 23300888). /// internal sealed record ChallengeDefinitionRow( uint Id, string Name, uint ChallengeType, uint UrbanJobType, uint[] RelatedTasks); /// /// One authored race track (RacingDriverTrackInformationConfig). /// MaxGamePeople == 0 marks a map-location placeholder row (the Id 1000+ block), not a playable track. /// Track 3 = "Ace Driver · Downtown Nights"(城中之夜), RaidSpoonName "23300888_RacingDriver". /// internal sealed record RacingTrackRow( uint Id, uint Laps, uint MaxPlayers, uint MaxGameTime, uint Difficulty, string ContestType, uint[] EventIds, uint[] HighAI, uint[] MiddleAI, uint[] LowAI, // The SCENE-spoon name ("_RacingDriver"); the TASK graph alias is a different string // (see GameRouter.RacingTaskGraph.cs RacingTaskSpoonVerifiedAliases). string RaidSpoonName, string RacingPathName, uint MapLocation); /// /// One authored racing opponent (RacingDriverRacingAIConfig). The client turns an AIVehicleInfos /// value (= Id here) into "which vehicle config must exist in the scene" (VehicleId) plus /// "which AI task drives it around the circuit" (VehicleTaskId). /// internal sealed record RacingAiRow( uint Id, uint VehicleId, uint VehicleTaskId, uint SortScores, uint AgentQuoteId); internal sealed record CharacterCatalogEntry(uint TemplateId, ulong UnitId, string Name, uint IconId, uint DefaultUrbanJob = 0); internal sealed record SwitchAnimationChoice( uint Id, string Timeline, float Weight, float AnchorDistance, int TimelinePool, int RowPool, string? PreviousTimeline, Vec3 Position, float Facing, bool UseZeroPosAndAngle); /// /// 一条**切人演出**(SwitchSpiritConfig 的一行)—— 面板「切人演出」卡的主下拉就是它。 /// /// ⛑ 的判据照 ClientConfigRepository.RandomSwitchAnimation 的注释: /// 「Generic server switching cannot satisfy authored companion-agent rows. Those rows carry AgentId /// entries that stock SwitchTeleport expects the server to spawn before landing.」 /// ⇒ AgentId 非空的行服务端这条链路补不出随从,**列得出来但播不了**。 /// 面板默认只列 ,另有「显示全部」开关做诊断(照捐赠端 m-switch 的 `tlKind`)。 /// internal sealed record SwitchShowRow( uint Id, string Timeline, string Description, string TimelineDescription, int SwitchType, uint RaidId, float Weight, bool Invalid, bool LoginInvalid, Vec3 Position, float Facing, bool UseZeroPosAndAngle, IReadOnlyList FightSpiritIds, IReadOnlyList FirstSwitchSpiritIds, IReadOnlyList ForbidFightSpiritIds, IReadOnlyList AgentIds, bool IsCommon) { /// /// 这条行能不能由服务端这条切人链路播出来。
/// ⛑ AgentId 非空的行服务端补不出随从(照 RandomSwitchAnimation 的注释: /// "Generic server switching cannot satisfy authored companion-agent rows")⇒ 列得出来但播不了。 ///
internal bool Playable => !Invalid && AgentIds.Count == 0; /// 下拉里的显示名:#id 地点 · 动作 · timeline。 internal string Label => $"#{Id} {(Description.Length > 0 ? Description : "(无地点)")}" + $"{(TimelineDescription.Length > 0 ? " · " + TimelineDescription : "")} · {Timeline}"; /// /// 这条演出哪些角色能演 —— 判据照 ClientConfigRepository.IdsFor: /// · FightSpiritId 与 FirstSwitchSpiritId 都空且是通用行 ⇒ 全 roster(扣掉 Forbid); /// · 否则 ⇒ 只能是 Fight ∪ First 里列出的角色(扣掉 Forbid); /// · 非通用行且两个清单都空 ⇒ 谁都演不了(空列表)。 /// internal IReadOnlyList EligibleSpiritIds(IReadOnlyList roster, IReadOnlyList forbidOverride) { // 参数化而非直接读 roster 表,是因为 ClientConfigRepository.Load 里 switchRows 的构建 // 早于 characters 完成,此处拿不到最终名单;由调用方(Bridge)传进来。 var named = FightSpiritIds.Concat(FirstSwitchSpiritIds).Distinct().ToHashSet(); var picked = (named.Count == 0 && IsCommon) ? roster.Except(ForbidFightSpiritIds.Concat(forbidOverride)) : named.Except(ForbidFightSpiritIds.Concat(forbidOverride)); return picked.OrderBy(x => x).ToArray(); } }