feat(init): server has been rewritten

This commit is contained in:
capyb2222 committed 2026-09-07 22:19:20 -07:00
1 parent 3647ed19c4
commit b87874f2fe
84 files changed
+1437031 -1

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\CapySR.Common\CapySR.Common.csproj" />
</ItemGroup>
</Project>
+109
View File
@@ -0,0 +1,109 @@
namespace CapySR.Data.Excel;
// Tables that decide what a fight is made of: where it is started from, which stage that
// resolves to, and who is lent to the player for it.
// Calyx. one row per (ID, WorldLevel); WorldLevel is absent from the level-0 row
public sealed class CocoonExcel
{
public uint ID { get; set; }
public uint WorldLevel { get; set; }
public uint StageID { get; set; }
public List<uint> StageIDList { get; set; } = [];
public uint StaminaCost { get; set; }
public uint MappingInfoID { get; set; }
public string CocoonType { get; set; } = string.Empty;
public IReadOnlyList<uint> Stages => StageIDList.Count > 0 ? StageIDList : StageID != 0 ? [StageID] : [];
}
// Stagnant Shadow / Cavern of Corrosion. same (ID, WorldLevel) shape as cocoons
public sealed class FarmElementExcel
{
public uint ID { get; set; }
public uint WorldLevel { get; set; }
public uint StageID { get; set; }
public uint StaminaCost { get; set; }
public uint MappingInfoID { get; set; }
}
// an overworld monster's EventID resolves to a stage through here, per world level
public sealed class PlaneEventExcel
{
public uint EventID { get; set; }
public uint WorldLevel { get; set; }
public uint StageID { get; set; }
}
public sealed class BattleCollegeExcel
{
public uint ID { get; set; }
public uint StageID { get; set; }
public List<uint> TrialAvatarList { get; set; } = [];
}
// the win conditions a battle reports progress against
public sealed class BattleTargetExcel
{
public uint ID { get; set; }
public string Type { get; set; } = string.Empty;
public uint TargetParam { get; set; }
}
// PF floors with an invading monster: the stage carries an extra buff 3034000 + id
public sealed class StageInvasionExcel
{
public uint StageID { get; set; }
public uint InvasionID { get; set; }
}
// trial characters lent to a stage: battle college, story fights, ...
public sealed class SpecialAvatarExcel
{
public uint SpecialAvatarID { get; set; }
public uint AvatarID { get; set; }
public uint PlayerID { get; set; }
public uint Level { get; set; }
public uint Promotion { get; set; }
public uint EquipmentID { get; set; }
public uint EquipmentLevel { get; set; }
public uint EquipmentPromotion { get; set; }
public uint EquipmentRank { get; set; }
public bool IsUseWorldLevel { get; set; }
}
public sealed class NpcMonsterExcel
{
public uint ID { get; set; }
public string Rank { get; set; } = string.Empty;
// techniques that one-shot fodder (Acheron, Phainon, ...) skip the fight for these
public bool IsFodder => Rank is "Minion" or "MinionLv2";
}
+191
View File
@@ -0,0 +1,191 @@
namespace CapySR.Data.Excel;
// MOC / PF / AS floors. id ranges: <20000 MOC, 20000-30000 PF, >30000 AS.
// dimbreath keeps them in three files with one shape; the beta dump merges them.
public sealed class ChallengeMazeExcel
{
public uint ID { get; set; }
public uint GroupID { get; set; }
public uint Floor { get; set; }
public uint MapEntranceID { get; set; }
public uint MapEntranceID2 { get; set; }
public uint MazeGroupID1 { get; set; }
public uint? MazeGroupID2 { get; set; }
public uint MazeBuffID { get; set; }
public uint ChallengeCountDown { get; set; }
public uint StageNum { get; set; }
public List<uint> ChallengeTargetID { get; set; } = [];
public List<uint> NpcMonsterIDList1 { get; set; } = [];
public List<uint> NpcMonsterIDList2 { get; set; } = [];
public List<uint> EventIDList1 { get; set; } = [];
public List<uint> EventIDList2 { get; set; } = [];
public ChallengeKind Kind => ID switch
{
> 30000 => ChallengeKind.ApocalypticShadow,
> 20000 => ChallengeKind.PureFiction,
_ => ChallengeKind.MemoryOfChaos,
};
public bool HasSecondNode => MazeGroupID2 is > 0 && NpcMonsterIDList2.Count > 0 && EventIDList2.Count > 0;
// StageNum is missing from the trimmed beta rows; fall back to what the node data says
public int NodeCount => StageNum > 0 ? (int)Math.Min(StageNum, 2) : HasSecondNode ? 2 : 1;
}
public enum ChallengeKind
{
MemoryOfChaos,
PureFiction,
ApocalypticShadow,
}
// Anomaly Arbitration. MapEntranceID/MazeGroupID/NpcMonsterIDList only exist in the beta dump.
public sealed class ChallengePeakExcel
{
public uint ID { get; set; }
public uint MapEntranceID { get; set; }
public uint MazeGroupID { get; set; }
public List<uint> NpcMonsterIDList { get; set; } = [];
public List<uint> EventIDList { get; set; } = [];
public List<uint> TagList { get; set; } = [];
public List<uint> NormalTargetList { get; set; } = [];
public List<string> DamageType { get; set; } = [];
public bool IsPlayable => MapEntranceID > 0 && NpcMonsterIDList.Count > 0 && EventIDList.Count > 0;
}
public sealed class ChallengePeakBossExcel
{
public uint ID { get; set; }
public List<uint> BuffList { get; set; } = [];
public List<uint> HardTagList { get; set; } = [];
public List<uint> HardEventIDList { get; set; } = [];
public uint HardTarget { get; set; }
}
// PF extras: the score targets the battle reports against
public sealed class ChallengeStoryExtraExcel
{
public uint ID { get; set; }
public uint TurnLimit { get; set; }
public List<uint> BattleTargetID { get; set; } = [];
public uint ClearScore { get; set; }
}
public sealed class ChallengeBossExtraExcel
{
public uint ID { get; set; }
public uint MonsterID1 { get; set; }
public uint MonsterID2 { get; set; }
}
// star conditions for MOC (ROUNDS_LEFT / DEAD_AVATAR) and PF/AS (TOTAL_SCORE)
public sealed class ChallengeTargetExcel
{
public uint ID { get; set; }
public string ChallengeTargetType { get; set; } = string.Empty;
public uint ChallengeTargetParam1 { get; set; }
}
public sealed class ChallengeGroupExcel
{
public uint GroupID { get; set; }
public string ChallengeGroupType { get; set; } = string.Empty;
public uint RewardLineGroupID { get; set; }
}
public sealed class MapEntranceExcel
{
public uint ID { get; set; }
public uint PlaneID { get; set; }
public uint FloorID { get; set; }
}
public sealed class MazePlaneExcel
{
public uint PlaneID { get; set; }
public uint WorldID { get; set; }
public uint ChallengePlaneID { get; set; }
public List<uint> FloorIDList { get; set; } = [];
}
public sealed class ChallengePeakGroupExcel
{
public uint ID { get; set; }
public uint BossLevelID { get; set; }
public List<uint> PreLevelIDList { get; set; } = [];
public IEnumerable<uint> AllStages => BossLevelID > 0 ? PreLevelIDList.Append(BossLevelID) : PreLevelIDList;
}
public sealed class MainMissionExcel
{
public uint MainMissionID { get; set; }
}
public sealed class TutorialExcel
{
public uint TutorialID { get; set; }
}
public sealed class TutorialGuideGroupExcel
{
public uint GroupID { get; set; }
}
// maps an item id to its main/sub type. the client NPEs on an item it cannot type.
public sealed class ItemConfigExcel
{
public uint ID { get; set; }
}
public sealed class MultiPathAvatarExcel
{
public uint AvatarID { get; set; }
public uint BaseAvatarID { get; set; }
public string Gender { get; set; } = string.Empty;
}
+310
View File
@@ -0,0 +1,310 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CapySR.Data.Excel;
// dimbreath wraps scalars as {"Value": n}; the zig servers' dumps flatten them to bare numbers
[JsonConverter(typeof(ExcelValueConverter))]
public readonly record struct ExcelValue(long Value)
{
public static implicit operator long(ExcelValue v) => v.Value;
public uint AsUInt32() => (uint)Value;
}
internal sealed class ExcelValueConverter : JsonConverter<ExcelValue>
{
public override ExcelValue Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.Number:
return new ExcelValue(reader.TryGetInt64(out var n) ? n : (long)reader.GetDouble());
case JsonTokenType.String:
return new ExcelValue(long.TryParse(reader.GetString(), out var parsed) ? parsed : 0);
case JsonTokenType.StartObject:
long value = 0;
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
var name = reader.GetString();
reader.Read();
if (name is "Value" or "value" && reader.TokenType == JsonTokenType.Number)
{
value = reader.TryGetInt64(out var v) ? v : (long)reader.GetDouble();
}
else
{
reader.Skip();
}
}
return new ExcelValue(value);
default:
reader.Skip();
return default;
}
}
public override void Write(Utf8JsonWriter writer, ExcelValue value, JsonSerializerOptions options) =>
writer.WriteNumberValue(value.Value);
}
public sealed class AvatarExcel
{
public uint AvatarID { get; set; }
public string Rarity { get; set; } = string.Empty;
public string DamageType { get; set; } = string.Empty;
public string AvatarBaseType { get; set; } = string.Empty;
public ExcelValue SPNeed { get; set; }
public List<uint> RankIDList { get; set; } = [];
public bool Release { get; set; }
public uint SpMax => SPNeed.AsUInt32();
}
public sealed class AvatarSkillTreeExcel
{
public uint PointID { get; set; }
public uint Level { get; set; }
public uint AvatarID { get; set; }
public uint MaxLevel { get; set; }
// "Point01"
public string AnchorType { get; set; } = string.Empty;
// AvatarPathData.point_id is the anchor index, not the raw point id
public uint AnchorIndex
{
get
{
var start = AnchorType.Length;
while (start > 0 && char.IsAsciiDigit(AnchorType[start - 1]))
{
start--;
}
return start < AnchorType.Length && uint.TryParse(AnchorType.AsSpan(start), out var value)
? value
: 0;
}
}
}
// ID is the avatar; DefaultMazeBuffIDList[0] is the buff the client expects when
// that avatar starts a battle, with SkillIndex passed as a dynamic value
public sealed class AvatarDefaultMazeBuffExcel
{
public uint ID { get; set; }
public uint SkillIndex { get; set; }
public List<uint> DefaultMazeBuffIDList { get; set; } = [];
public uint BuffId => DefaultMazeBuffIDList.Count > 0 ? DefaultMazeBuffIDList[0] : 0;
}
public sealed class AvatarGlobalBuffExcel
{
public uint AvatarID { get; set; }
}
// one profile portrait per character
public sealed class HeadIconExcel
{
public uint ID { get; set; }
public uint AvatarID { get; set; }
}
// the ten avatars with an enhanced technique state
public sealed class AvatarConfigEnhancedExcel
{
public uint AvatarID { get; set; }
public uint EnhancedID { get; set; }
}
public sealed class AvatarMazeBuffExcel
{
public uint ID { get; set; }
public string ModifierName { get; set; } = string.Empty;
// ADV_GlobalSkill_Maze_* buffs apply even when the avatar is not in the lineup
public bool IsGlobal => ModifierName.StartsWith("ADV_GlobalSkill_Maze", StringComparison.Ordinal);
public uint OwnerAvatarId => ID / 100;
}
public sealed class EquipmentExcel
{
public uint EquipmentID { get; set; }
// "CombatPowerLightconeRarity3"
public string Rarity { get; set; } = string.Empty;
public uint MaxPromotion { get; set; }
public uint MaxRank { get; set; }
public bool Release { get; set; }
}
public sealed class MonsterExcel
{
public uint MonsterID { get; set; }
}
public sealed class StageExcel
{
public uint StageID { get; set; }
public string StageType { get; set; } = string.Empty;
public uint Level { get; set; }
public uint HardLevelGroup { get; set; }
public uint EliteGroup { get; set; }
public bool Release { get; set; }
[JsonConverter(typeof(MonsterWaveConverter))]
public List<List<uint>> MonsterList { get; set; } = [];
[JsonConverter(typeof(StageConfigDataConverter))]
public Dictionary<string, string> StageConfigData { get; set; } = [];
// SpecialAvatar ids lent to the player for this fight
public List<uint> TrialAvatarList { get; set; } = [];
}
// dimbreath: [{ "Monster0": id, ... }] per wave. trimmed dumps: [[id, id, ...]] per wave.
internal sealed class MonsterWaveConverter : JsonConverter<List<List<uint>>>
{
public override List<List<uint>> Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options)
{
var waves = new List<List<uint>>();
if (reader.TokenType != JsonTokenType.StartArray)
{
reader.Skip();
return waves;
}
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType == JsonTokenType.StartArray)
{
var flat = new List<uint>();
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType == JsonTokenType.Number)
{
flat.Add(reader.GetUInt32());
}
else
{
reader.Skip();
}
}
waves.Add(flat);
continue;
}
if (reader.TokenType != JsonTokenType.StartObject)
{
reader.Skip();
continue;
}
var wave = new SortedDictionary<string, uint>(StringComparer.Ordinal);
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
var name = reader.GetString()!;
reader.Read();
if (reader.TokenType == JsonTokenType.Number)
{
wave[name] = reader.GetUInt32();
}
else
{
reader.Skip();
}
}
waves.Add([.. wave.Values]);
}
return waves;
}
public override void Write(Utf8JsonWriter writer, List<List<uint>> value, JsonSerializerOptions options) =>
throw new NotSupportedException();
}
// entries look like { "<obf>": "_Wave", "<obf>": "1" } - the key names are obfuscated
// and reshuffle every version, so key off the value that starts with '_'
internal sealed class StageConfigDataConverter : JsonConverter<Dictionary<string, string>>
{
public override Dictionary<string, string> Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
if (reader.TokenType != JsonTokenType.StartArray)
{
reader.Skip();
return result;
}
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
reader.Skip();
continue;
}
var values = new List<string>(2);
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
reader.Read();
values.Add(reader.TokenType == JsonTokenType.String ? reader.GetString()! : string.Empty);
}
var key = values.FirstOrDefault(v => v.StartsWith('_'));
if (key is not null)
{
result[key] = values.FirstOrDefault(v => !v.StartsWith('_')) ?? string.Empty;
}
}
return result;
}
public override void Write(Utf8JsonWriter writer, Dictionary<string, string> value, JsonSerializerOptions options) =>
throw new NotSupportedException();
}
+118
View File
@@ -0,0 +1,118 @@
namespace CapySR.Data.Excel;
// res.json / Anchor.json ship with the zig servers, not with the ExcelOutput dump
public sealed class SceneResourceExcel
{
public uint EntryID { get; set; }
public uint PlaneID { get; set; }
public List<ScenePlacement> Monsters { get; set; } = [];
public List<ScenePropPlacement> Props { get; set; } = [];
public List<SceneTeleport> Teleports { get; set; } = [];
}
public sealed class ScenePlacement
{
public uint GroupId { get; set; }
public uint InstId { get; set; }
public uint MonsterId { get; set; }
public uint EventId { get; set; }
public ResVector Pos { get; set; } = new();
public ResVector Rot { get; set; } = new();
}
public sealed class ScenePropPlacement
{
public uint GroupId { get; set; }
public uint InstId { get; set; }
public uint PropId { get; set; }
public uint PropState { get; set; }
public ResVector Pos { get; set; } = new();
public ResVector Rot { get; set; } = new();
}
public sealed class SceneTeleport
{
public uint TeleportId { get; set; }
public ResVector Pos { get; set; } = new();
public ResVector Rot { get; set; } = new();
}
public sealed class AnchorExcel
{
public uint EntryID { get; set; }
public List<AnchorPoint> Anchor { get; set; } = [];
}
public sealed class AnchorPoint
{
public uint ID { get; set; }
public ResVector Pos { get; set; } = new();
public ResVector Rot { get; set; } = new();
}
public sealed class ResVector
{
public int X { get; set; }
public int Y { get; set; }
public int Z { get; set; }
}
public sealed class MazePropExcel
{
public uint ID { get; set; }
public string PropType { get; set; } = string.Empty;
public string ConfigEntityPath { get; set; } = string.Empty;
public string JsonPath { get; set; } = string.Empty;
public bool IsDestructible => PropType is "PROP_DESTRUCT" or "PROP_NO_REWARD_DESTRUCT";
// space anchors and sustenance anchors
public bool IsSpring => PropType == "PROP_SPRING";
// the orbs that refund technique points / heal when smashed; only the entity name tells
public bool IsMpRecover => IsDestructible && (Mentions("MPBox") || Mentions("MPRecover"));
public bool IsHpRecover => IsDestructible && (Mentions("HPBox") || Mentions("HPRecover"));
private bool Mentions(string token) =>
ConfigEntityPath.Contains(token, StringComparison.OrdinalIgnoreCase) ||
JsonPath.Contains(token, StringComparison.OrdinalIgnoreCase);
}
public sealed class FloorSavedValuesExcel
{
public uint FloorID { get; set; }
public List<FloorSavedValue> SavedValues { get; set; } = [];
}
public sealed class FloorSavedValue
{
public string Name { get; set; } = string.Empty;
public int MaxValue { get; set; }
}
+117
View File
@@ -0,0 +1,117 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace CapySR.Data;
public sealed partial class GameData
{
// Rows merge field by field across sources, not wholesale. Neither source is a superset:
// pearl's StageConfig rows drop StageType, while its ChallengePeakConfig carries
// MapEntranceID/NpcMonsterIDList that Dimbreath has no column for at all. Earlier sources
// win on any field they define; later ones only fill in what is missing.
private Dictionary<uint, T> Merge<T>(string fileName, Func<JsonObject, uint?> key, bool required = true)
{
var merged = new Dictionary<uint, JsonObject>();
var found = false;
foreach (var root in _sources)
{
var path = Path.Combine(root, fileName);
if (!File.Exists(path))
{
continue;
}
found = true;
foreach (var row in ReadRows(path))
{
if (key(row) is not { } id)
{
continue;
}
if (merged.TryGetValue(id, out var existing))
{
FillMissing(existing, row);
}
else
{
merged[id] = row;
}
}
}
if (!found && required)
{
throw new FileNotFoundException(
$"no source provides {fileName} (looked in {string.Join(", ", _sources)})");
}
var result = new Dictionary<uint, T>(merged.Count);
foreach (var (id, row) in merged)
{
if (row.Deserialize<T>(NodeOptions) is { } value)
{
result[id] = value;
}
}
return result;
}
private static void FillMissing(JsonObject target, JsonObject extra)
{
foreach (var (name, value) in extra)
{
if (!target.TryGetPropertyValue(name, out var existing) || existing is null)
{
target[name] = value?.DeepClone();
}
}
}
private static List<JsonObject> ReadRows(string path)
{
using var stream = File.OpenRead(path);
var node = JsonNode.Parse(stream);
// dimbreath writes a bare array; the zig servers wrap it as {"avatar_config": [...]}
var array = node as JsonArray;
if (array is null && node is JsonObject wrapper)
{
foreach (var (_, value) in wrapper)
{
if (value is JsonArray inner)
{
array = inner;
break;
}
}
}
if (array is null)
{
return [];
}
var rows = new List<JsonObject>(array.Count);
foreach (var item in array)
{
if (item is JsonObject row)
{
rows.Add(row);
}
}
return rows;
}
private static uint? UInt(JsonObject row, string name) =>
row.TryGetPropertyValue(name, out var node) && node is not null && node.GetValueKind() is JsonValueKind.Number
? node.GetValue<uint>()
: null;
}
+414
View File
@@ -0,0 +1,414 @@
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);
}
}
}
+218
View File
@@ -0,0 +1,218 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CapySR.Data.SrTools;
// freesr-data.json, as written by srtools.neonteam.dev
public sealed class SrToolsData
{
public Dictionary<uint, SrAvatar> Avatars { get; set; } = [];
public List<SrRelic> Relics { get; set; } = [];
public List<SrLightcone> Lightcones { get; set; } = [];
[JsonPropertyName("battle_config")]
public SrBattleConfig BattleConfig { get; set; } = new();
public static SrToolsData Empty { get; } = new();
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
public const string FileName = "freesr-data.json";
public static SrToolsData Load(string path) =>
File.Exists(path)
? JsonSerializer.Deserialize<SrToolsData>(File.ReadAllText(path), Options) ?? new SrToolsData()
: new SrToolsData();
// robinsr and srtools users drop it at the repo root; we also accept data/
public static string ResolvePath(string root)
{
var atRoot = Path.Combine(root, FileName);
var inData = Path.Combine(root, "data", FileName);
return File.Exists(atRoot) || !File.Exists(inData) ? atRoot : inData;
}
public SrLightcone? LightconeFor(uint avatarId) => Lightcones.FirstOrDefault(l => l.EquipAvatar == avatarId);
public IEnumerable<SrRelic> RelicsFor(uint avatarId) => Relics.Where(r => r.EquipAvatar == avatarId);
}
public sealed class SrAvatar
{
[JsonPropertyName("avatar_id")]
public uint AvatarId { get; set; }
public uint Level { get; set; } = 80;
public uint Promotion { get; set; } = 6;
public SrAvatarData Data { get; set; } = new();
// the buff ids the avatar enters battle with
[JsonPropertyName("techniques")]
public List<uint> Techniques { get; set; } = [];
[JsonPropertyName("sp_value")]
public uint? SpValue { get; set; }
[JsonPropertyName("sp_max")]
public uint? SpMax { get; set; }
[JsonPropertyName("enhanced_id")]
public uint? EnhancedId { get; set; }
}
public sealed class SrAvatarData
{
public uint Rank { get; set; }
// point id -> level
public Dictionary<uint, uint> Skills { get; set; } = [];
[JsonPropertyName("skills_by_anchor_type")]
public Dictionary<uint, uint> SkillsByAnchorType { get; set; } = [];
}
public sealed class SrRelic
{
public uint Level { get; set; }
[JsonPropertyName("relic_id")]
public uint RelicId { get; set; }
[JsonPropertyName("main_affix_id")]
public uint MainAffixId { get; set; }
[JsonPropertyName("sub_affixes")]
public List<SrSubAffix> SubAffixes { get; set; } = [];
[JsonPropertyName("internal_uid")]
public uint InternalUid { get; set; }
[JsonPropertyName("equip_avatar")]
public uint EquipAvatar { get; set; }
public uint Slot => RelicId % 10;
// srtools numbers from 0, but 0 means "no item" on the wire
public uint UniqueId => InternalUid + 1;
}
public sealed class SrSubAffix
{
[JsonPropertyName("sub_affix_id")]
public uint SubAffixId { get; set; }
public uint Count { get; set; }
public uint Step { get; set; }
}
public sealed class SrLightcone
{
public uint Level { get; set; }
[JsonPropertyName("item_id")]
public uint ItemId { get; set; }
[JsonPropertyName("equip_avatar")]
public uint EquipAvatar { get; set; }
public uint Rank { get; set; }
public uint Promotion { get; set; }
[JsonPropertyName("internal_uid")]
public uint InternalUid { get; set; }
// lightcones live in a separate unique-id range from relics
public uint UniqueId => 3001 + InternalUid;
}
public sealed class SrBattleConfig
{
[JsonPropertyName("battle_type")]
public string BattleType { get; set; } = "Default";
[JsonPropertyName("stage_id")]
public uint StageId { get; set; }
[JsonPropertyName("cycle_count")]
public uint CycleCount { get; set; } = 30;
public List<List<SrMonster>> Monsters { get; set; } = [];
public List<SrBlessing> Blessings { get; set; } = [];
[JsonPropertyName("path_resonance_id")]
public uint PathResonanceId { get; set; }
[JsonPropertyName("custom_stats")]
public List<SrSubAffix> CustomStats { get; set; } = [];
[JsonPropertyName("custom_battle_lineup")]
public Dictionary<uint, uint>? CustomLineup { get; set; }
public BattleKind Kind => BattleType.ToUpperInvariant() switch
{
"MOC" or "1" => BattleKind.Moc,
"PF" or "2" => BattleKind.PureFiction,
"SU" or "3" => BattleKind.SimulatedUniverse,
"AS" or "4" => BattleKind.ApocalypticShadow,
"AA" or "5" => BattleKind.AnomalyArbitration,
_ => BattleKind.Default,
};
}
public sealed class SrMonster
{
public uint Level { get; set; } = 95;
[JsonPropertyName("monster_id")]
public uint MonsterId { get; set; }
[JsonPropertyName("max_hp")]
public uint MaxHp { get; set; }
// srtools can ask for the same monster several times in one wave
public uint Amount { get; set; } = 1;
}
public sealed class SrBlessing
{
public uint Id { get; set; }
public uint Level { get; set; } = 1;
[JsonPropertyName("dynamic_key")]
public SrDynamicValue? DynamicKey { get; set; }
[JsonPropertyName("dynamic_values")]
public List<SrDynamicValue> DynamicValues { get; set; } = [];
}
public sealed class SrDynamicValue
{
public string Key { get; set; } = string.Empty;
public float Value { get; set; }
}
public enum BattleKind
{
Default,
Moc,
PureFiction,
SimulatedUniverse,
ApocalypticShadow,
AnomalyArbitration,
}