Files
CapySR/src/CapySR.Data/GameData.cs
T

414 lines
20 KiB
C#
Raw Normal View History

2026-09-07 21:28:11 -07:00
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<string> Sources => _sources;
public IReadOnlyDictionary<uint, AvatarExcel> Avatars { get; private set; } = new Dictionary<uint, AvatarExcel>();
public IReadOnlyDictionary<uint, List<AvatarSkillTreeExcel>> SkillTrees { get; private set; } =
new Dictionary<uint, List<AvatarSkillTreeExcel>>();
public IReadOnlyDictionary<uint, AvatarDefaultMazeBuffExcel> DefaultMazeBuffs { get; private set; } =
new Dictionary<uint, AvatarDefaultMazeBuffExcel>();
public IReadOnlyList<AvatarGlobalBuffExcel> GlobalBuffs { get; private set; } = [];
public IReadOnlyDictionary<uint, StageExcel> Stages { get; private set; } = new Dictionary<uint, StageExcel>();
public IReadOnlyDictionary<uint, MonsterExcel> Monsters { get; private set; } = new Dictionary<uint, MonsterExcel>();
public IReadOnlyDictionary<uint, EquipmentExcel> Equipment { get; private set; } = new Dictionary<uint, EquipmentExcel>();
public IReadOnlyDictionary<uint, uint> EnhancedAvatars { get; private set; } = new Dictionary<uint, uint>();
public IReadOnlyDictionary<uint, AvatarMazeBuffExcel> MazeBuffs { get; private set; } =
new Dictionary<uint, AvatarMazeBuffExcel>();
// (avatar, buff) pairs that apply from the roster rather than the lineup
public IReadOnlyList<(uint AvatarId, uint BuffId)> GlobalMazeBuffs { get; private set; } = [];
public IReadOnlyDictionary<uint, ChallengeMazeExcel> Challenges { get; private set; } =
new Dictionary<uint, ChallengeMazeExcel>();
public IReadOnlyDictionary<uint, ChallengeStoryExtraExcel> ChallengeStoryExtras { get; private set; } =
new Dictionary<uint, ChallengeStoryExtraExcel>();
public IReadOnlyDictionary<uint, ChallengeBossExtraExcel> ChallengeBossExtras { get; private set; } =
new Dictionary<uint, ChallengeBossExtraExcel>();
public IReadOnlyDictionary<uint, ChallengeTargetExcel> ChallengeTargets { get; private set; } =
new Dictionary<uint, ChallengeTargetExcel>();
public IReadOnlyDictionary<uint, ChallengeGroupExcel> ChallengeGroups { get; private set; } =
new Dictionary<uint, ChallengeGroupExcel>();
public IReadOnlyDictionary<uint, BattleTargetExcel> BattleTargets { get; private set; } =
new Dictionary<uint, BattleTargetExcel>();
public IReadOnlyDictionary<uint, ChallengePeakExcel> ChallengePeaks { get; private set; } =
new Dictionary<uint, ChallengePeakExcel>();
public IReadOnlyDictionary<uint, ChallengePeakBossExcel> ChallengePeakBosses { get; private set; } =
new Dictionary<uint, ChallengePeakBossExcel>();
public IReadOnlyDictionary<uint, MapEntranceExcel> MapEntrances { get; private set; } =
new Dictionary<uint, MapEntranceExcel>();
public IReadOnlyDictionary<uint, MazePlaneExcel> MazePlanes { get; private set; } =
new Dictionary<uint, MazePlaneExcel>();
public IReadOnlyDictionary<uint, ChallengePeakGroupExcel> ChallengePeakGroups { get; private set; } =
new Dictionary<uint, ChallengePeakGroupExcel>();
public IReadOnlyDictionary<uint, SceneResourceExcel> Scenes { get; private set; } =
new Dictionary<uint, SceneResourceExcel>();
public IReadOnlyDictionary<uint, AnchorExcel> Anchors { get; private set; } =
new Dictionary<uint, AnchorExcel>();
public IReadOnlyDictionary<uint, FloorSavedValuesExcel> FloorSavedValues { get; private set; } =
new Dictionary<uint, FloorSavedValuesExcel>();
public IReadOnlyDictionary<uint, MainMissionExcel> MainMissions { get; private set; } =
new Dictionary<uint, MainMissionExcel>();
public IReadOnlyDictionary<uint, CocoonExcel> Cocoons { get; private set; } = new Dictionary<uint, CocoonExcel>();
public IReadOnlyDictionary<uint, FarmElementExcel> FarmElements { get; private set; } =
new Dictionary<uint, FarmElementExcel>();
public IReadOnlyDictionary<uint, PlaneEventExcel> PlaneEvents { get; private set; } =
new Dictionary<uint, PlaneEventExcel>();
public IReadOnlyDictionary<uint, BattleCollegeExcel> BattleColleges { get; private set; } =
new Dictionary<uint, BattleCollegeExcel>();
public IReadOnlyDictionary<uint, SpecialAvatarExcel> SpecialAvatars { get; private set; } =
new Dictionary<uint, SpecialAvatarExcel>();
public IReadOnlyDictionary<uint, NpcMonsterExcel> NpcMonsters { get; private set; } =
new Dictionary<uint, NpcMonsterExcel>();
public IReadOnlyDictionary<uint, MazePropExcel> Props { get; private set; } = new Dictionary<uint, MazePropExcel>();
public IReadOnlyDictionary<uint, HeadIconExcel> HeadIcons { get; private set; } = new Dictionary<uint, HeadIconExcel>();
public IReadOnlyDictionary<uint, StageInvasionExcel> StageInvasions { get; private set; } = new Dictionary<uint, StageInvasionExcel>();
public IReadOnlySet<uint> DestructiblePropIds { get; private set; } = new HashSet<uint>();
public IReadOnlyList<uint> TutorialIds { get; private set; } = [];
public IReadOnlyList<uint> TutorialGuideIds { get; private set; } = [];
// every relic / lightcone id the client can resolve a type for
public IReadOnlySet<uint> KnownRelicIds { get; private set; } = new HashSet<uint>();
public IReadOnlySet<uint> KnownLightconeIds { get; private set; } = new HashSet<uint>();
// path avatar id -> the base id the client files it under (8002 -> 8001, 1224 -> 1001)
public IReadOnlyDictionary<uint, uint> BaseAvatarIds { get; private set; } = new Dictionary<uint, uint>();
public IReadOnlySet<uint> MultiPathAvatarIds { get; private set; } = new HashSet<uint>();
public static GameData Load(IEnumerable<string> sources, Action<string>? 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<AvatarExcel>("AvatarConfig.json", r => UInt(r, "AvatarID"));
data.DefaultMazeBuffs = data.Merge<AvatarDefaultMazeBuffExcel>("AvatarDefaultMazeBuff.json", r => UInt(r, "ID"), required: false);
data.Stages = data.Merge<StageExcel>("StageConfig.json", r => UInt(r, "StageID"));
data.Monsters = data.Merge<MonsterExcel>("MonsterConfig.json", r => UInt(r, "MonsterID"), required: false);
data.Equipment = data.Merge<EquipmentExcel>("EquipmentConfig.json", r => UInt(r, "EquipmentID"), required: false);
data.SkillTrees = data
// one row per (point, level)
.Merge<AvatarSkillTreeExcel>("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<AvatarGlobalBuffExcel>("AvatarGlobalBuffConfig.json", r => UInt(r, "AvatarID"), required: false).Values];
data.EnhancedAvatars = data
.Merge<AvatarConfigEnhancedExcel>("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<AvatarMazeBuffExcel>("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<ChallengeMazeExcel>("ChallengeMazeConfig.json", r => UInt(r, "ID"), required: false);
foreach (var file in new[] { "ChallengeStoryMazeConfig.json", "ChallengeBossMazeConfig.json" })
{
foreach (var (id, row) in data.Merge<ChallengeMazeExcel>(file, r => UInt(r, "ID"), required: false))
{
challenges.TryAdd(id, row);
}
}
data.Challenges = challenges;
data.ChallengeStoryExtras = data.Merge<ChallengeStoryExtraExcel>("ChallengeStoryMazeExtra.json", r => UInt(r, "ID"), required: false);
data.ChallengeBossExtras = data.Merge<ChallengeBossExtraExcel>("ChallengeBossMazeExtra.json", r => UInt(r, "ID"), required: false);
data.ChallengeGroups = data.Merge<ChallengeGroupExcel>("ChallengeGroupConfig.json", r => UInt(r, "GroupID"), required: false);
var targets = data.Merge<ChallengeTargetExcel>("ChallengeTargetConfig.json", r => UInt(r, "ID"), required: false);
foreach (var file in new[] { "ChallengeStoryTargetConfig.json", "ChallengeBossTargetConfig.json" })
{
foreach (var (id, row) in data.Merge<ChallengeTargetExcel>(file, r => UInt(r, "ID"), required: false))
{
targets.TryAdd(id, row);
}
}
data.ChallengeTargets = targets;
data.BattleTargets = data.Merge<BattleTargetExcel>("BattleTargetConfig.json", r => UInt(r, "ID"), required: false);
data.ChallengePeaks = data.Merge<ChallengePeakExcel>("ChallengePeakConfig.json", r => UInt(r, "ID"), required: false);
data.ChallengePeakBosses = data.Merge<ChallengePeakBossExcel>("ChallengePeakBossConfig.json", r => UInt(r, "ID"), required: false);
data.MapEntrances = data.Merge<MapEntranceExcel>("MapEntrance.json", r => UInt(r, "ID"), required: false);
data.ChallengePeakGroups = data.Merge<ChallengePeakGroupExcel>("ChallengePeakGroupConfig.json", r => UInt(r, "ID"), required: false);
data.MazePlanes = data.Merge<MazePlaneExcel>("MazePlane.json", r => UInt(r, "PlaneID"), required: false);
data.Scenes = data.Merge<SceneResourceExcel>("res.json", r => UInt(r, "entryID"), required: false);
data.Anchors = data.Merge<AnchorExcel>("Anchor.json", r => UInt(r, "entryID"), required: false);
data.FloorSavedValues = data.Merge<FloorSavedValuesExcel>("FloorSavedValuesConfig.json", r => UInt(r, "FloorID"), required: false);
data.MainMissions = data.Merge<MainMissionExcel>("MainMission.json", r => UInt(r, "MainMissionID"), required: false);
data.TutorialIds = [.. data.Merge<TutorialExcel>("TutorialData.json", r => UInt(r, "TutorialID"), required: false).Keys];
data.TutorialGuideIds = [.. data.Merge<TutorialGuideGroupExcel>("TutorialGuideGroup.json", r => UInt(r, "GroupID"), required: false).Keys];
data.KnownRelicIds = data.Merge<ItemConfigExcel>("ItemConfigRelic.json", r => UInt(r, "ID"), required: false).Keys.ToHashSet();
data.KnownLightconeIds = data.Merge<ItemConfigExcel>("ItemConfigEquipment.json", r => UInt(r, "ID"), required: false).Keys.ToHashSet();
data.Cocoons = data.Merge<CocoonExcel>("CocoonConfig.json", r => (UInt(r, "ID") * 10) + (UInt(r, "WorldLevel") ?? 0), required: false);
data.FarmElements = data.Merge<FarmElementExcel>("FarmElementConfig.json", r => (UInt(r, "ID") * 10) + (UInt(r, "WorldLevel") ?? 0), required: false);
data.PlaneEvents = data.Merge<PlaneEventExcel>("PlaneEvent.json", r => (UInt(r, "EventID") * 10) + (UInt(r, "WorldLevel") ?? 0), required: false);
data.BattleColleges = data.Merge<BattleCollegeExcel>("BattleCollegeConfig.json", r => UInt(r, "ID"), required: false);
data.SpecialAvatars = data.Merge<SpecialAvatarExcel>("SpecialAvatar.json", r => UInt(r, "SpecialAvatarID"), required: false);
data.NpcMonsters = data.Merge<NpcMonsterExcel>("NPCMonsterData.json", r => UInt(r, "ID"), required: false);
data.Props = data.Merge<MazePropExcel>("MazeProp.json", r => UInt(r, "ID"), required: false);
data.HeadIcons = data.Merge<HeadIconExcel>("AvatarPlayerIcon.json", r => UInt(r, "ID"), required: false);
data.StageInvasions = data.Merge<StageInvasionExcel>("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<MultiPathAvatarExcel>("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<uint> 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<uint> TeleportIdsForFloor(uint floorId) => TeleportIdsForPlane(floorId / 1000);
private IEnumerable<uint> 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);
}
}
}