feat(init): server has been rewritten
This commit is contained in:
1 parent
3647ed19c4
commit
b87874f2fe
84 files changed
+1437031
-1
No files matched your search
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CapySR.Common\CapySR.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user