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(); var skipped = new List(); 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(); var systemMasteries = new List(); var skippedDocs = new List(); var usedIds = new HashSet(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? ReadFrontMatter(string path) { var lines = File.ReadAllLines(path); if (lines.Length == 0 || lines[0].Trim() != "---") { return null; } var values = new Dictionary(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 frontMatter) { return frontMatter.TryGetValue("category", out var category) && string.Equals(category.Scalar, "Unit", StringComparison.OrdinalIgnoreCase); } static bool TryReadUnit( string path, IReadOnlyDictionary 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 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 frontMatter, string key) { return frontMatter.TryGetValue(key, out var frontMatterValue) && !string.IsNullOrWhiteSpace(frontMatterValue.Scalar) ? frontMatterValue.Scalar : null; } static bool TryGetRequiredInt( IReadOnlyDictionary 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 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 units) { var builder = new StringBuilder(); builder.AppendLine("// "); 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 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? 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(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 frontMatter, string key) { return frontMatter.TryGetValue(key, out var value) && string.Equals(value.Scalar, "true", StringComparison.OrdinalIgnoreCase); } static string ToUniqueIdentifier(string name, HashSet 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 frontMatter, string body, HashSet 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 frontMatter, string body, HashSet 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 systems) { var builder = new StringBuilder(); builder.AppendLine("// "); 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 All { get; } ="); builder.AppendLine(" ["); foreach (var system in systems) { builder.AppendLine($" {system.Id},"); } builder.AppendLine(" ];"); builder.AppendLine(); builder.AppendLine(" public static IReadOnlyDictionary ByName { get; } ="); builder.AppendLine(" All.ToDictionary(static system => system.Name, StringComparer.OrdinalIgnoreCase);"); builder.AppendLine("}"); return builder.ToString(); } static string GenerateSystemMasteries(IReadOnlyList masteries) { var builder = new StringBuilder(); builder.AppendLine("// "); 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"); 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 All { get; } ="); builder.AppendLine(" ["); foreach (var mastery in masteries) { builder.AppendLine($" {mastery.Id},"); } builder.AppendLine(" ];"); builder.AppendLine(); builder.AppendLine(" public static IReadOnlyDictionary 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 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 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 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 ConnectedSystems, double X, double Y, string Body); internal sealed record SystemMasterySource( string Id, string Name, IReadOnlyList Levels, IReadOnlyList AvailableSystems, IReadOnlyList<(string Key, double Value)> Stats, string Body);