using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; using CapySR.Data.Excel; namespace CapySR.Data; // sources are listed in priority order and merged per row id: the first source to define a // row keeps it, later ones only fill gaps. beta dumps are trimmed extracts, so letting them // replace full prod rows would silently drop fields like StageType. public sealed partial class GameData { private static readonly JsonSerializerOptions NodeOptions = new() { PropertyNameCaseInsensitive = true, NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, }; private readonly string[] _sources; private GameData(string[] sources) => _sources = sources; public IReadOnlyList Sources => _sources; public IReadOnlyDictionary Avatars { get; private set; } = new Dictionary(); public IReadOnlyDictionary> SkillTrees { get; private set; } = new Dictionary>(); public IReadOnlyDictionary DefaultMazeBuffs { get; private set; } = new Dictionary(); public IReadOnlyList GlobalBuffs { get; private set; } = []; public IReadOnlyDictionary Stages { get; private set; } = new Dictionary(); public IReadOnlyDictionary Monsters { get; private set; } = new Dictionary(); public IReadOnlyDictionary Equipment { get; private set; } = new Dictionary(); public IReadOnlyDictionary EnhancedAvatars { get; private set; } = new Dictionary(); public IReadOnlyDictionary MazeBuffs { get; private set; } = new Dictionary(); // (avatar, buff) pairs that apply from the roster rather than the lineup public IReadOnlyList<(uint AvatarId, uint BuffId)> GlobalMazeBuffs { get; private set; } = []; public IReadOnlyDictionary Challenges { get; private set; } = new Dictionary(); public IReadOnlyDictionary ChallengeStoryExtras { get; private set; } = new Dictionary(); public IReadOnlyDictionary ChallengeBossExtras { get; private set; } = new Dictionary(); public IReadOnlyDictionary ChallengeTargets { get; private set; } = new Dictionary(); public IReadOnlyDictionary ChallengeGroups { get; private set; } = new Dictionary(); public IReadOnlyDictionary BattleTargets { get; private set; } = new Dictionary(); public IReadOnlyDictionary ChallengePeaks { get; private set; } = new Dictionary(); public IReadOnlyDictionary ChallengePeakBosses { get; private set; } = new Dictionary(); public IReadOnlyDictionary MapEntrances { get; private set; } = new Dictionary(); public IReadOnlyDictionary MazePlanes { get; private set; } = new Dictionary(); public IReadOnlyDictionary ChallengePeakGroups { get; private set; } = new Dictionary(); public IReadOnlyDictionary Scenes { get; private set; } = new Dictionary(); public IReadOnlyDictionary Anchors { get; private set; } = new Dictionary(); public IReadOnlyDictionary FloorSavedValues { get; private set; } = new Dictionary(); public IReadOnlyDictionary MainMissions { get; private set; } = new Dictionary(); public IReadOnlyDictionary Cocoons { get; private set; } = new Dictionary(); public IReadOnlyDictionary FarmElements { get; private set; } = new Dictionary(); public IReadOnlyDictionary PlaneEvents { get; private set; } = new Dictionary(); public IReadOnlyDictionary BattleColleges { get; private set; } = new Dictionary(); public IReadOnlyDictionary SpecialAvatars { get; private set; } = new Dictionary(); public IReadOnlyDictionary NpcMonsters { get; private set; } = new Dictionary(); public IReadOnlyDictionary Props { get; private set; } = new Dictionary(); public IReadOnlyDictionary HeadIcons { get; private set; } = new Dictionary(); public IReadOnlyDictionary StageInvasions { get; private set; } = new Dictionary(); public IReadOnlySet DestructiblePropIds { get; private set; } = new HashSet(); public IReadOnlyList TutorialIds { get; private set; } = []; public IReadOnlyList TutorialGuideIds { get; private set; } = []; // every relic / lightcone id the client can resolve a type for public IReadOnlySet KnownRelicIds { get; private set; } = new HashSet(); public IReadOnlySet KnownLightconeIds { get; private set; } = new HashSet(); // path avatar id -> the base id the client files it under (8002 -> 8001, 1224 -> 1001) public IReadOnlyDictionary BaseAvatarIds { get; private set; } = new Dictionary(); public IReadOnlySet MultiPathAvatarIds { get; private set; } = new HashSet(); public static GameData Load(IEnumerable sources, Action? log = null) { log ??= _ => { }; var roots = sources.Where(Directory.Exists).ToArray(); if (roots.Length == 0) { throw new DirectoryNotFoundException( "no game data sources found. Clone Dimbreath/turnbasedgamedata and point " + "Data:Sources at its ExcelOutput folder."); } var data = new GameData(roots); var stopwatch = Stopwatch.StartNew(); data.Avatars = data.Merge("AvatarConfig.json", r => UInt(r, "AvatarID")); data.DefaultMazeBuffs = data.Merge("AvatarDefaultMazeBuff.json", r => UInt(r, "ID"), required: false); data.Stages = data.Merge("StageConfig.json", r => UInt(r, "StageID")); data.Monsters = data.Merge("MonsterConfig.json", r => UInt(r, "MonsterID"), required: false); data.Equipment = data.Merge("EquipmentConfig.json", r => UInt(r, "EquipmentID"), required: false); data.SkillTrees = data // one row per (point, level) .Merge("AvatarSkillTreeConfig.json", r => (UInt(r, "PointID") * 100u) + (UInt(r, "Level") ?? 0)) .Values .GroupBy(p => p.AvatarID) .ToDictionary(g => g.Key, g => g.ToList()); data.GlobalBuffs = [.. data.Merge("AvatarGlobalBuffConfig.json", r => UInt(r, "AvatarID"), required: false).Values]; data.EnhancedAvatars = data .Merge("AvatarConfigEnhanced.json", r => UInt(r, "AvatarID"), required: false) .ToDictionary(kv => kv.Key, kv => kv.Value.EnhancedID == 0 ? 1u : kv.Value.EnhancedID); data.MazeBuffs = data.Merge("AvatarMazeBuff.json", r => UInt(r, "ID"), required: false); // prod splits the three modes into three files with one shape; the beta dump merges them var challenges = data.Merge("ChallengeMazeConfig.json", r => UInt(r, "ID"), required: false); foreach (var file in new[] { "ChallengeStoryMazeConfig.json", "ChallengeBossMazeConfig.json" }) { foreach (var (id, row) in data.Merge(file, r => UInt(r, "ID"), required: false)) { challenges.TryAdd(id, row); } } data.Challenges = challenges; data.ChallengeStoryExtras = data.Merge("ChallengeStoryMazeExtra.json", r => UInt(r, "ID"), required: false); data.ChallengeBossExtras = data.Merge("ChallengeBossMazeExtra.json", r => UInt(r, "ID"), required: false); data.ChallengeGroups = data.Merge("ChallengeGroupConfig.json", r => UInt(r, "GroupID"), required: false); var targets = data.Merge("ChallengeTargetConfig.json", r => UInt(r, "ID"), required: false); foreach (var file in new[] { "ChallengeStoryTargetConfig.json", "ChallengeBossTargetConfig.json" }) { foreach (var (id, row) in data.Merge(file, r => UInt(r, "ID"), required: false)) { targets.TryAdd(id, row); } } data.ChallengeTargets = targets; data.BattleTargets = data.Merge("BattleTargetConfig.json", r => UInt(r, "ID"), required: false); data.ChallengePeaks = data.Merge("ChallengePeakConfig.json", r => UInt(r, "ID"), required: false); data.ChallengePeakBosses = data.Merge("ChallengePeakBossConfig.json", r => UInt(r, "ID"), required: false); data.MapEntrances = data.Merge("MapEntrance.json", r => UInt(r, "ID"), required: false); data.ChallengePeakGroups = data.Merge("ChallengePeakGroupConfig.json", r => UInt(r, "ID"), required: false); data.MazePlanes = data.Merge("MazePlane.json", r => UInt(r, "PlaneID"), required: false); data.Scenes = data.Merge("res.json", r => UInt(r, "entryID"), required: false); data.Anchors = data.Merge("Anchor.json", r => UInt(r, "entryID"), required: false); data.FloorSavedValues = data.Merge("FloorSavedValuesConfig.json", r => UInt(r, "FloorID"), required: false); data.MainMissions = data.Merge("MainMission.json", r => UInt(r, "MainMissionID"), required: false); data.TutorialIds = [.. data.Merge("TutorialData.json", r => UInt(r, "TutorialID"), required: false).Keys]; data.TutorialGuideIds = [.. data.Merge("TutorialGuideGroup.json", r => UInt(r, "GroupID"), required: false).Keys]; data.KnownRelicIds = data.Merge("ItemConfigRelic.json", r => UInt(r, "ID"), required: false).Keys.ToHashSet(); data.KnownLightconeIds = data.Merge("ItemConfigEquipment.json", r => UInt(r, "ID"), required: false).Keys.ToHashSet(); data.Cocoons = data.Merge("CocoonConfig.json", r => (UInt(r, "ID") * 10) + (UInt(r, "WorldLevel") ?? 0), required: false); data.FarmElements = data.Merge("FarmElementConfig.json", r => (UInt(r, "ID") * 10) + (UInt(r, "WorldLevel") ?? 0), required: false); data.PlaneEvents = data.Merge("PlaneEvent.json", r => (UInt(r, "EventID") * 10) + (UInt(r, "WorldLevel") ?? 0), required: false); data.BattleColleges = data.Merge("BattleCollegeConfig.json", r => UInt(r, "ID"), required: false); data.SpecialAvatars = data.Merge("SpecialAvatar.json", r => UInt(r, "SpecialAvatarID"), required: false); data.NpcMonsters = data.Merge("NPCMonsterData.json", r => UInt(r, "ID"), required: false); data.Props = data.Merge("MazeProp.json", r => UInt(r, "ID"), required: false); data.HeadIcons = data.Merge("AvatarPlayerIcon.json", r => UInt(r, "ID"), required: false); data.StageInvasions = data.Merge("StageInvasionConfig.json", r => UInt(r, "StageID"), required: false); data.DestructiblePropIds = data.Props.Values.Where(p => p.IsDestructible).Select(p => p.ID).ToHashSet(); var multiPath = data.Merge("MultiplePathAvatarConfig.json", r => UInt(r, "AvatarID"), required: false); data.BaseAvatarIds = multiPath.ToDictionary(kv => kv.Key, kv => kv.Value.BaseAvatarID == 0 ? kv.Key : kv.Value.BaseAvatarID); data.MultiPathAvatarIds = multiPath.Keys.ToHashSet(); data.GlobalMazeBuffs = [.. data.MazeBuffs.Values .Where(b => b.IsGlobal) .Select(b => (b.OwnerAvatarId, b.ID)) .OrderBy(b => b.Item1)]; log($"game data loaded in {stopwatch.ElapsedMilliseconds} ms from {roots.Length} source(s): " + $"{data.Avatars.Count} avatars, {data.SkillTrees.Sum(t => t.Value.Count)} skill tree points, " + $"{data.Stages.Count} stages, {data.Monsters.Count} monsters, {data.Equipment.Count} lightcones, " + $"{data.Challenges.Count} challenge floors, {data.ChallengePeaks.Count(p => p.Value.IsPlayable)}/" + $"{data.ChallengePeaks.Count} playable AA stages, {data.Scenes.Count} scenes, " + $"{data.Cocoons.Count} calyx rows, {data.FarmElements.Count} farm rows, {data.PlaneEvents.Count} plane events, " + $"{data.MainMissions.Count} main missions, " + $"{data.TutorialIds.Count} tutorials / {data.TutorialGuideIds.Count} guides"); return data; } public AvatarExcel? GetAvatar(uint avatarId) => Avatars.GetValueOrDefault(avatarId); public StageExcel? GetStage(uint stageId) => Stages.GetValueOrDefault(stageId); public ChallengeMazeExcel? GetChallenge(uint challengeId) => Challenges.GetValueOrDefault(challengeId); public uint BaseAvatarId(uint avatarId) => BaseAvatarIds.TryGetValue(avatarId, out var baseId) ? baseId : avatarId switch { >= 8001 and <= 8010 => 8001, 1224 => 1001, _ => avatarId, }; // the row for this world level, else the closest lower one, else any public CocoonExcel? FindCocoon(uint cocoonId, uint worldLevel) { for (var level = (int)Math.Min(worldLevel, 9); level >= 0; level--) { if (Cocoons.TryGetValue((cocoonId * 10) + (uint)level, out var row) && row.Stages.Count > 0) { return row; } } return Cocoons.Values.FirstOrDefault(c => c.ID == cocoonId && c.Stages.Count > 0); } public FarmElementExcel? FindFarmElement(uint farmId, uint worldLevel) { for (var level = (int)Math.Min(worldLevel, 9); level >= 0; level--) { if (FarmElements.TryGetValue((farmId * 10) + (uint)level, out var row) && row.StageID != 0) { return row; } } return FarmElements.Values.FirstOrDefault(f => f.ID == farmId && f.StageID != 0); } // an overworld monster's event -> the stage it fights at this world level public uint ResolveStageForEvent(uint eventId, uint worldLevel) { if (eventId == 0) { return 0; } for (var level = (int)Math.Min(worldLevel, 9); level >= 0; level--) { if (PlaneEvents.TryGetValue((eventId * 10) + (uint)level, out var planeEvent) && planeEvent.StageID != 0) { return planeEvent.StageID; } } if (Stages.ContainsKey((eventId * 10) + worldLevel)) { return (eventId * 10) + worldLevel; } if (Stages.ContainsKey(eventId)) { return eventId; } return 0; } public bool IsFodderMonster(uint npcMonsterId) => NpcMonsters.TryGetValue(npcMonsterId, out var monster) && monster.IsFodder; // monster placement lives per plane, matched by the challenge's maze group public ScenePlacement? FindMonsterPlacement(uint planeId, uint groupId) { foreach (var scene in Scenes.Values.Where(s => s.PlaneID == planeId)) { foreach (var monster in scene.Monsters.Where(m => m.GroupId == groupId)) { return monster; } } return null; } public SceneResourceExcel? GetScene(uint entryId) => Scenes.GetValueOrDefault(entryId); // teleport ids are shared by every entry on a plane, so a point picked on the map is // often not listed under the entry the client asks to enter public SceneTeleport? FindTeleport(uint entryId, uint teleportId) { if (teleportId == 0) { return null; } var scene = GetScene(entryId); if (scene?.Teleports.FirstOrDefault(t => t.TeleportId == teleportId) is { } exact) { return exact; } var planeId = scene?.PlaneID ?? ResolveEntrance(entryId)?.Entrance.PlaneID ?? 0; return Scenes.Values .Where(s => s.PlaneID == planeId) .SelectMany(s => s.Teleports) .FirstOrDefault(t => t.TeleportId == teleportId); } public IEnumerable TeleportIdsForEntry(uint entryId) => TeleportIdsForPlane(GetScene(entryId)?.PlaneID ?? ResolveEntrance(entryId)?.Entrance.PlaneID ?? 0); // floor ids are the plane id with a three digit suffix public IEnumerable TeleportIdsForFloor(uint floorId) => TeleportIdsForPlane(floorId / 1000); private IEnumerable TeleportIdsForPlane(uint planeId) => Scenes.Values .Where(s => s.PlaneID == planeId) .SelectMany(s => s.Teleports) .Select(t => t.TeleportId) .Distinct(); public AnchorPoint? FindAnchor(uint entryId) => Anchors.TryGetValue(entryId, out var anchor) && anchor.Anchor.Count > 0 ? anchor.Anchor[0] : null; // an entrance points at a floor; the plane it belongs to carries the world id public (MapEntranceExcel Entrance, MazePlaneExcel? Plane)? ResolveEntrance(uint entranceId) { if (!MapEntrances.TryGetValue(entranceId, out var entrance)) { return null; } var plane = MazePlanes.GetValueOrDefault(entrance.PlaneID) ?? MazePlanes.Values.FirstOrDefault(p => p.FloorIDList.Contains(entrance.FloorID)); return (entrance, plane); } // AvatarPathData.point_id is the anchor index ("Point01" -> 1), not the raw point id public IEnumerable<(uint AnchorIndex, uint Level)> MaxedPathSkillTree(uint avatarId) { if (!SkillTrees.TryGetValue(avatarId, out var points)) { yield break; } foreach (var group in points.Where(p => p.AnchorIndex > 0).GroupBy(p => p.AnchorIndex)) { var best = group.MaxBy(p => p.Level)!; yield return (group.Key, best.MaxLevel > 0 ? best.MaxLevel : best.Level); } } public IEnumerable<(uint PointId, uint Level)> MaxedSkillTree(uint avatarId) { if (!SkillTrees.TryGetValue(avatarId, out var points)) { yield break; } foreach (var group in points.GroupBy(p => p.PointID)) { var best = group.MaxBy(p => p.Level)!; yield return (best.PointID, best.MaxLevel > 0 ? best.MaxLevel : best.Level); } } }