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; internal static IReadOnlyList UnlockSystemIds() => Data.UnlockSystemIds; 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); 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)); }) .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); } 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 roster = cfg.Content.Roster; var excluded = roster.ExcludedTemplateIds.ToHashSet(); var characters = fightSpirits .Select(ParseCharacterSource) .Where(x => x.HasValue) .Select(x => x.GetValueOrDefault()) .Where(x => x.TemplateId >= roster.TemplateIdMin && x.TemplateId < roster.TemplateIdMaxExclusive) .Where(x => !excluded.Contains(x.TemplateId)) .Where(x => x.IconId > 0 && x.IconId != roster.GenericPlaceholderIconId) .OrderBy(x => x.TemplateId) .Select((x, index) => new CharacterCatalogEntry( x.TemplateId, roster.UnitIdBase + (ulong)index, x.Name, x.IconId)) .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}."); 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(); 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 .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()); Console.WriteLine( $"[CLIENT-CONFIG] build={cfg.Client.Version} characters={characters.Length} switchRows={switchRows.Count} " + $"source={root}"); return new ClientData( characters, switchRows, switchIdsBySpirit, entryIdsBySpirit, commonSwitchIds, commonEntryIds, unlockSystemIds, installedPhoneAppIds, selectableFashionIds, defaultFashionIdsBySpirit); } 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 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); return new CharacterSource(id, name, iconId); } 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; return new SwitchRow( id, timeline, weight, switchType, raidId, invalid, loginInvalid, position, facing, ReadUInt32Array(value, "FightSpiritId"), ReadUInt32Array(value, "FirstSwitchSpiritId"), ReadUInt32Array(value, "ForbidFightSpiritId"), ReadUInt32Array(value, "AgentId")); } 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); private readonly record struct CharacterSource(uint TemplateId, string Name, uint IconId); private sealed record SwitchRow( uint Id, string Timeline, float Weight, int SwitchType, uint RaidId, bool Invalid, bool LoginInvalid, Vec3 Position, float Facing, HashSet FightSpiritIds, HashSet FirstSwitchSpiritIds, HashSet ForbidFightSpiritIds, HashSet AgentIds); private sealed record SwitchAnimationCandidate(uint Id, string Timeline, float Weight, double AnchorDistance); } internal sealed record CharacterCatalogEntry(uint TemplateId, ulong UnitId, string Name, uint IconId); internal sealed record SwitchAnimationChoice( uint Id, string Timeline, float Weight, float AnchorDistance, int TimelinePool, int RowPool, string? PreviousTimeline);