Files
ZeroSpace/ZeroSpace/ZeroSpace.Build/Program.cs
T

806 lines
25 KiB
C#

using System.Globalization;
using System.Text;
var solutionRoot = FindSolutionRoot();
var docsRoot = args.Length > 0
? Path.GetFullPath(args[0])
: Path.GetFullPath(Path.Combine(solutionRoot, "..", "zs.docs"));
var modelRoot = Path.Combine(solutionRoot, "ZeroSpace.Model");
var outputPath = Path.Combine(modelRoot, "Units.g.cs");
if (!Directory.Exists(docsRoot))
{
throw new DirectoryNotFoundException($"Could not find docs directory: {docsRoot}");
}
if (!Directory.Exists(modelRoot))
{
throw new DirectoryNotFoundException($"Could not find model project directory: {modelRoot}");
}
var units = new List<UnitSource>();
var skipped = new List<string>();
foreach (var path in Directory.EnumerateFiles(docsRoot, "*.md", SearchOption.AllDirectories).Order(StringComparer.OrdinalIgnoreCase))
{
var frontMatter = ReadFrontMatter(path);
if (frontMatter is null)
{
continue;
}
if (!IsUnit(frontMatter))
{
continue;
}
if (!TryReadUnit(path, frontMatter, out var unit, out var reason))
{
skipped.Add($"{Path.GetFileName(path)} ({reason})");
continue;
}
units.Add(unit);
}
units.Sort((left, right) =>
{
var faction = string.Compare(left.Faction, right.Faction, StringComparison.OrdinalIgnoreCase);
if (faction != 0)
{
return faction;
}
var tier = left.Tier.CompareTo(right.Tier);
return tier != 0
? tier
: string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase);
});
File.WriteAllText(outputPath, GenerateUnits(units), Encoding.UTF8);
Console.WriteLine($"Generated {units.Count} units: {Path.GetRelativePath(solutionRoot, outputPath)}");
if (skipped.Count > 0)
{
Console.WriteLine($"Skipped {skipped.Count} incomplete unit files:");
foreach (var entry in skipped.Order(StringComparer.OrdinalIgnoreCase))
{
Console.WriteLine($"- {entry}");
}
}
var starSystems = new List<StarSystemSource>();
var systemMasteries = new List<SystemMasterySource>();
var skippedDocs = new List<string>();
var usedIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var path in Directory.EnumerateFiles(docsRoot, "*.md", SearchOption.AllDirectories).Order(StringComparer.OrdinalIgnoreCase))
{
var (frontMatter, body) = ReadDocument(path);
if (frontMatter is null || !frontMatter.TryGetValue("category", out var categoryValue))
{
continue;
}
if (string.Equals(categoryValue.Scalar, "StarSystem", StringComparison.OrdinalIgnoreCase))
{
if (TryReadStarSystem(path, frontMatter, body, usedIds, out var system, out var reason))
{
starSystems.Add(system);
}
else
{
skippedDocs.Add($"{Path.GetFileName(path)} ({reason})");
}
}
else if (string.Equals(categoryValue.Scalar, "SystemMastery", StringComparison.OrdinalIgnoreCase))
{
if (TryReadSystemMastery(path, frontMatter, body, usedIds, out var mastery, out var reason))
{
systemMasteries.Add(mastery);
}
else
{
skippedDocs.Add($"{Path.GetFileName(path)} ({reason})");
}
}
}
starSystems.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
systemMasteries.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
var starSystemsPath = Path.Combine(modelRoot, "StarSystems.g.cs");
var systemMasteriesPath = Path.Combine(modelRoot, "SystemMasteries.g.cs");
File.WriteAllText(starSystemsPath, GenerateStarSystems(starSystems), Encoding.UTF8);
File.WriteAllText(systemMasteriesPath, GenerateSystemMasteries(systemMasteries), Encoding.UTF8);
Console.WriteLine($"Generated {starSystems.Count} star systems: {Path.GetRelativePath(solutionRoot, starSystemsPath)}");
Console.WriteLine($"Generated {systemMasteries.Count} system masteries: {Path.GetRelativePath(solutionRoot, systemMasteriesPath)}");
if (skippedDocs.Count > 0)
{
Console.WriteLine($"Skipped {skippedDocs.Count} incomplete docs:");
foreach (var entry in skippedDocs.Order(StringComparer.OrdinalIgnoreCase))
{
Console.WriteLine($"- {entry}");
}
}
static string FindSolutionRoot()
{
var candidates = new[] { Environment.CurrentDirectory, AppContext.BaseDirectory };
foreach (var candidate in candidates)
{
var directory = new DirectoryInfo(candidate);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "ZeroSpace.sln")))
{
return directory.FullName;
}
if (File.Exists(Path.Combine(directory.FullName, "ZeroSpace", "ZeroSpace.sln")))
{
return Path.Combine(directory.FullName, "ZeroSpace");
}
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException("Could not find ZeroSpace.sln in the current directory or its parents.");
}
static Dictionary<string, FrontMatterValue>? ReadFrontMatter(string path)
{
var lines = File.ReadAllLines(path);
if (lines.Length == 0 || lines[0].Trim() != "---")
{
return null;
}
var values = new Dictionary<string, FrontMatterValue>(StringComparer.OrdinalIgnoreCase);
string? listKey = null;
for (var i = 1; i < lines.Length; i++)
{
var line = lines[i];
if (line.Trim() == "---")
{
return values;
}
if (listKey is not null && line.StartsWith(" - ", StringComparison.Ordinal))
{
values[listKey].List.Add(CleanValue(line[4..]));
continue;
}
listKey = null;
var separatorIndex = line.IndexOf(':');
if (separatorIndex < 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
var rawValue = line[(separatorIndex + 1)..].Trim();
if (rawValue.Length == 0)
{
values[key] = new FrontMatterValue(null);
listKey = key;
continue;
}
values[key] = new FrontMatterValue(CleanValue(rawValue));
}
return null;
}
static bool IsUnit(IReadOnlyDictionary<string, FrontMatterValue> frontMatter)
{
return frontMatter.TryGetValue("category", out var category)
&& string.Equals(category.Scalar, "Unit", StringComparison.OrdinalIgnoreCase);
}
static bool TryReadUnit(
string path,
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
out UnitSource unit,
out string reason)
{
unit = default!;
if (!TryGetRequiredString(frontMatter, "Faction", out var faction, out reason) ||
!TryGetRequiredInt(frontMatter, "hexite", out var hexite, out reason) ||
!TryGetRequiredInt(frontMatter, "Flux", out var flux, out reason) ||
!TryGetRequiredInt(frontMatter, "supply", out var supply, out reason) ||
!TryGetRequiredInt(frontMatter, "productionTime", out var productionTime, out reason) ||
!TryGetRequiredInt(frontMatter, "health", out var health, out reason) ||
!TryGetRequiredInt(frontMatter, "Energy", out var energy, out reason) ||
!TryGetRequiredInt(frontMatter, "Shields", out var shields, out reason) ||
!TryGetRequiredInt(frontMatter, "Armor rating", out var armorRating, out reason) ||
!TryGetRequiredInt(frontMatter, "movementSpeed", out var movementSpeed, out reason) ||
!TryGetRequiredInt(frontMatter, "damagePerSecond", out var damagePerSecond, out reason) ||
!TryGetRequiredInt(frontMatter, "attackRange", out var attackRange, out reason) ||
!TryGetRequiredInt(frontMatter, "Tier", out var tier, out reason) ||
!TryGetRequiredInt(frontMatter, "buildAtSameTime", out var buildAtSameTime, out reason))
{
return false;
}
var id = ToIdentifier(Path.GetFileNameWithoutExtension(path));
var hotkey = TryGetOptionalString(frontMatter, "Hotkey");
var attributes = frontMatter.TryGetValue("attributes", out var attributeValue)
? attributeValue.List.Where(static value => !string.IsNullOrWhiteSpace(value)).ToArray()
: [];
var limit = TryGetOptionalInt(frontMatter, "limit");
unit = new UnitSource(
id,
Path.GetFileNameWithoutExtension(path),
hexite,
flux,
supply,
productionTime,
health,
energy,
shields,
armorRating,
movementSpeed,
damagePerSecond,
attackRange,
attributes,
tier,
faction,
hotkey,
buildAtSameTime,
limit);
reason = string.Empty;
return true;
}
static bool TryGetRequiredString(
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
string key,
out string value,
out string reason)
{
if (!frontMatter.TryGetValue(key, out var frontMatterValue) ||
string.IsNullOrWhiteSpace(frontMatterValue.Scalar))
{
value = string.Empty;
reason = $"missing {key}";
return false;
}
value = frontMatterValue.Scalar;
reason = string.Empty;
return true;
}
static string? TryGetOptionalString(IReadOnlyDictionary<string, FrontMatterValue> frontMatter, string key)
{
return frontMatter.TryGetValue(key, out var frontMatterValue) &&
!string.IsNullOrWhiteSpace(frontMatterValue.Scalar)
? frontMatterValue.Scalar
: null;
}
static bool TryGetRequiredInt(
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
string key,
out int value,
out string reason)
{
if (!frontMatter.TryGetValue(key, out var frontMatterValue) ||
string.IsNullOrWhiteSpace(frontMatterValue.Scalar))
{
value = default;
reason = $"missing {key}";
return false;
}
if (!int.TryParse(frontMatterValue.Scalar, NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
{
reason = $"invalid {key}";
return false;
}
reason = string.Empty;
return true;
}
static int? TryGetOptionalInt(IReadOnlyDictionary<string, FrontMatterValue> frontMatter, string key)
{
if (!frontMatter.TryGetValue(key, out var frontMatterValue) ||
string.IsNullOrWhiteSpace(frontMatterValue.Scalar))
{
return null;
}
return int.TryParse(frontMatterValue.Scalar, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: null;
}
static string CleanValue(string value)
{
value = value.Trim();
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
{
value = value[1..^1];
}
if (value.StartsWith("[[", StringComparison.Ordinal) && value.EndsWith("]]", StringComparison.Ordinal))
{
value = value[2..^2];
}
return value;
}
static string ToIdentifier(string name)
{
var builder = new StringBuilder();
var capitalizeNext = true;
foreach (var character in name)
{
if (char.IsLetterOrDigit(character))
{
builder.Append(capitalizeNext ? char.ToUpperInvariant(character) : character);
capitalizeNext = false;
}
else
{
capitalizeNext = true;
}
}
if (builder.Length == 0)
{
return "Unit";
}
if (char.IsDigit(builder[0]))
{
builder.Insert(0, 'U');
}
return builder.ToString();
}
static string GenerateUnits(IReadOnlyList<UnitSource> units)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("namespace ZeroSpace.Model;");
builder.AppendLine();
builder.AppendLine("public static class Units");
builder.AppendLine("{");
foreach (var unit in units)
{
builder.AppendLine($" public static readonly UnitData {unit.Id} = new(");
builder.AppendLine($" Id: {Literal(unit.Id)},");
builder.AppendLine($" Name: {Literal(unit.Name)},");
builder.AppendLine($" Hexite: {unit.Hexite.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" Flux: {unit.Flux.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" Supply: {unit.Supply.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" ProductionTime: {unit.ProductionTime.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" Health: {unit.Health.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" Energy: {unit.Energy.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" Shields: {unit.Shields.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" ArmorRating: {unit.ArmorRating.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" MovementSpeed: {unit.MovementSpeed.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" DamagePerSecond: {unit.DamagePerSecond.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" AttackRange: {unit.AttackRange.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" Attributes: [{string.Join(", ", unit.Attributes.Select(Literal))}],");
builder.AppendLine($" Tier: {unit.Tier.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" Faction: {Literal(unit.Faction)},");
builder.AppendLine($" Hotkey: {Literal(unit.Hotkey)},");
builder.AppendLine($" BuildAtSameTime: {unit.BuildAtSameTime.ToString(CultureInfo.InvariantCulture)},");
builder.AppendLine($" Limit: {unit.Limit?.ToString(CultureInfo.InvariantCulture) ?? "null"});");
builder.AppendLine();
}
builder.AppendLine(" public static IReadOnlyList<UnitData> All { get; } =");
builder.AppendLine(" [");
foreach (var unit in units)
{
builder.AppendLine($" {unit.Id},");
}
builder.AppendLine(" ];");
builder.AppendLine("}");
return builder.ToString();
}
static string Literal(string? value)
{
if (value is null)
{
return "null";
}
var builder = new StringBuilder("\"");
foreach (var character in value)
{
builder.Append(character switch
{
'\\' => "\\\\",
'"' => "\\\"",
'\n' => "\\n",
'\r' => "\\r",
'\t' => "\\t",
_ => character.ToString(),
});
}
builder.Append('"');
return builder.ToString();
}
static (Dictionary<string, FrontMatterValue>? FrontMatter, string Body) ReadDocument(string path)
{
var lines = File.ReadAllLines(path);
if (lines.Length == 0 || lines[0].Trim() != "---")
{
return (null, string.Empty);
}
var values = new Dictionary<string, FrontMatterValue>(StringComparer.OrdinalIgnoreCase);
string? listKey = null;
for (var i = 1; i < lines.Length; i++)
{
var line = lines[i];
if (line.Trim() == "---")
{
var body = string.Join('\n', lines, i + 1, lines.Length - i - 1).Trim();
return (values, body);
}
if (listKey is not null && line.StartsWith(" - ", StringComparison.Ordinal))
{
values[listKey].List.Add(CleanValue(line[4..]));
continue;
}
listKey = null;
var separatorIndex = line.IndexOf(':');
if (separatorIndex < 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
var rawValue = line[(separatorIndex + 1)..].Trim();
if (rawValue.Length == 0)
{
values[key] = new FrontMatterValue(null);
listKey = key;
continue;
}
values[key] = new FrontMatterValue(CleanValue(rawValue));
}
return (null, string.Empty);
}
static bool IsTrue(IReadOnlyDictionary<string, FrontMatterValue> frontMatter, string key)
{
return frontMatter.TryGetValue(key, out var value)
&& string.Equals(value.Scalar, "true", StringComparison.OrdinalIgnoreCase);
}
static string ToUniqueIdentifier(string name, HashSet<string> usedIds)
{
var id = ToIdentifier(name);
if (usedIds.Add(id))
{
return id;
}
var suffix = 2;
while (!usedIds.Add($"{id}{suffix}"))
{
suffix++;
}
return $"{id}{suffix}";
}
static bool TryReadStarSystem(
string path,
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
string body,
HashSet<string> usedIds,
out StarSystemSource system,
out string reason)
{
system = default!;
var kind = string.Empty;
var kindCount = 0;
if (IsTrue(frontMatter, "isSideSystem"))
{
kind = "Side";
kindCount++;
}
if (IsTrue(frontMatter, "isHomeworldSystem"))
{
kind = "Homeworld";
kindCount++;
}
if (IsTrue(frontMatter, "isCoreSystem"))
{
kind = "Core";
kindCount++;
}
if (kindCount != 1)
{
reason = "expected exactly one system kind flag";
return false;
}
var name = Path.GetFileNameWithoutExtension(path);
var connectedSystems = frontMatter.TryGetValue("connectedSystems", out var connectedValue)
? connectedValue.List.Where(static value => !string.IsNullOrWhiteSpace(value)).ToArray()
: [];
system = new StarSystemSource(
ToUniqueIdentifier(name, usedIds),
name,
kind,
IsTrue(frontMatter, "higherInfluenceYield"),
IsTrue(frontMatter, "higherCreditYield"),
IsTrue(frontMatter, "higherEnergyYield"),
IsTrue(frontMatter, "higherChanceForItems"),
TryGetOptionalString(frontMatter, "occupationBuff"),
TryGetOptionalString(frontMatter, "systemMastery"),
connectedSystems,
GetDouble(frontMatter, "x"),
GetDouble(frontMatter, "y"),
body);
reason = string.Empty;
return true;
}
static bool TryReadSystemMastery(
string path,
IReadOnlyDictionary<string, FrontMatterValue> frontMatter,
string body,
HashSet<string> usedIds,
out SystemMasterySource mastery,
out string reason)
{
mastery = default!;
var levels = new string[5];
for (var level = 1; level <= 5; level++)
{
if (!TryGetRequiredString(frontMatter, $"level_{level}", out var text, out reason))
{
return false;
}
levels[level - 1] = text;
}
var availableSystems = frontMatter.TryGetValue("availableSystems", out var availableValue)
? availableValue.List.Where(static value => !string.IsNullOrWhiteSpace(value)).ToArray()
: [];
var stats = new List<(string Key, double Value)>();
foreach (var (key, value) in frontMatter)
{
if (IsMasteryField(key))
{
continue;
}
if (string.IsNullOrWhiteSpace(value.Scalar) ||
!double.TryParse(value.Scalar, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var number))
{
Console.WriteLine($"Warning: {Path.GetFileName(path)} has non-numeric stat '{key}', skipping it.");
continue;
}
stats.Add((key, number));
}
var name = Path.GetFileNameWithoutExtension(path);
mastery = new SystemMasterySource(
ToUniqueIdentifier(name, usedIds),
name,
levels,
availableSystems,
stats,
body);
reason = string.Empty;
return true;
}
static bool IsMasteryField(string key)
{
if (string.Equals(key, "category", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "availableSystems", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return key.Length == 7 &&
key.StartsWith("level_", StringComparison.OrdinalIgnoreCase) &&
key[6] is >= '1' and <= '5';
}
static string GenerateStarSystems(IReadOnlyList<StarSystemSource> systems)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("namespace ZeroSpace.Model;");
builder.AppendLine();
builder.AppendLine("public static class StarSystems");
builder.AppendLine("{");
foreach (var system in systems)
{
builder.AppendLine($" public static readonly StarSystemData {system.Id} = new(");
builder.AppendLine($" Id: {Literal(system.Id)},");
builder.AppendLine($" Name: {Literal(system.Name)},");
builder.AppendLine($" Kind: StarSystemKind.{system.Kind},");
builder.AppendLine($" HigherInfluenceYield: {BoolLiteral(system.HigherInfluenceYield)},");
builder.AppendLine($" HigherCreditYield: {BoolLiteral(system.HigherCreditYield)},");
builder.AppendLine($" HigherEnergyYield: {BoolLiteral(system.HigherEnergyYield)},");
builder.AppendLine($" HigherChanceForItems: {BoolLiteral(system.HigherChanceForItems)},");
builder.AppendLine($" OccupationBuff: {Literal(system.OccupationBuff)},");
builder.AppendLine($" SystemMastery: {Literal(system.SystemMastery)},");
builder.AppendLine($" ConnectedSystems: [{string.Join(", ", system.ConnectedSystems.Select(Literal))}],");
builder.AppendLine($" X: {system.X.ToString("R", CultureInfo.InvariantCulture)},");
builder.AppendLine($" Y: {system.Y.ToString("R", CultureInfo.InvariantCulture)},");
builder.AppendLine($" Body: {Literal(system.Body)});");
builder.AppendLine();
}
builder.AppendLine(" public static IReadOnlyList<StarSystemData> All { get; } =");
builder.AppendLine(" [");
foreach (var system in systems)
{
builder.AppendLine($" {system.Id},");
}
builder.AppendLine(" ];");
builder.AppendLine();
builder.AppendLine(" public static IReadOnlyDictionary<string, StarSystemData> ByName { get; } =");
builder.AppendLine(" All.ToDictionary(static system => system.Name, StringComparer.OrdinalIgnoreCase);");
builder.AppendLine("}");
return builder.ToString();
}
static string GenerateSystemMasteries(IReadOnlyList<SystemMasterySource> masteries)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("namespace ZeroSpace.Model;");
builder.AppendLine();
builder.AppendLine("public static class SystemMasteries");
builder.AppendLine("{");
foreach (var mastery in masteries)
{
builder.AppendLine($" public static readonly SystemMasteryData {mastery.Id} = new(");
builder.AppendLine($" Id: {Literal(mastery.Id)},");
builder.AppendLine($" Name: {Literal(mastery.Name)},");
builder.AppendLine($" Levels: [{string.Join(", ", mastery.Levels.Select(Literal))}],");
builder.AppendLine($" AvailableSystems: [{string.Join(", ", mastery.AvailableSystems.Select(Literal))}],");
builder.AppendLine(" Stats: new Dictionary<string, double>");
builder.AppendLine(" {");
foreach (var (key, value) in mastery.Stats)
{
builder.AppendLine($" [{Literal(key)}] = {value.ToString("R", CultureInfo.InvariantCulture)},");
}
builder.AppendLine(" },");
builder.AppendLine($" Body: {Literal(mastery.Body)});");
builder.AppendLine();
}
builder.AppendLine(" public static IReadOnlyList<SystemMasteryData> All { get; } =");
builder.AppendLine(" [");
foreach (var mastery in masteries)
{
builder.AppendLine($" {mastery.Id},");
}
builder.AppendLine(" ];");
builder.AppendLine();
builder.AppendLine(" public static IReadOnlyDictionary<string, SystemMasteryData> ByName { get; } =");
builder.AppendLine(" All.ToDictionary(static mastery => mastery.Name, StringComparer.OrdinalIgnoreCase);");
builder.AppendLine("}");
return builder.ToString();
}
static string BoolLiteral(bool value)
{
return value ? "true" : "false";
}
static double GetDouble(IReadOnlyDictionary<string, FrontMatterValue> frontMatter, string key)
{
if (frontMatter.TryGetValue(key, out var value) &&
double.TryParse(value.Scalar, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var number))
{
return number;
}
return 0;
}
internal sealed class FrontMatterValue(string? scalar)
{
public string? Scalar { get; } = scalar;
public List<string> List { get; } = [];
}
internal sealed record UnitSource(
string Id,
string Name,
int Hexite,
int Flux,
int Supply,
int ProductionTime,
int Health,
int Energy,
int Shields,
int ArmorRating,
int MovementSpeed,
int DamagePerSecond,
int AttackRange,
IReadOnlyList<string> Attributes,
int Tier,
string Faction,
string? Hotkey,
int BuildAtSameTime,
int? Limit);
internal sealed record StarSystemSource(
string Id,
string Name,
string Kind,
bool HigherInfluenceYield,
bool HigherCreditYield,
bool HigherEnergyYield,
bool HigherChanceForItems,
string? OccupationBuff,
string? SystemMastery,
IReadOnlyList<string> ConnectedSystems,
double X,
double Y,
string Body);
internal sealed record SystemMasterySource(
string Id,
string Name,
IReadOnlyList<string> Levels,
IReadOnlyList<string> AvailableSystems,
IReadOnlyList<(string Key, double Value)> Stats,
string Body);