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, "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").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}"); } } static string FindSolutionRoot() { var directory = new DirectoryInfo(Environment.CurrentDirectory); while (directory is not null) { if (directory.EnumerateFiles("ZS.sln").Any()) { return directory.FullName; } directory = directory.Parent; } throw new DirectoryNotFoundException("Could not find ZS.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 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) { return value is null ? "null" : "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; } 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);