From da15f82611506ef05dc450eb72a0f5cc87cb2d0b Mon Sep 17 00:00:00 2001 From: Jonathan McCaffrey Date: Sun, 27 Sep 2026 15:08:54 +0000 Subject: [PATCH] AI: Add .NET MAUI star map position editor --- .../Services/StarSystemDocStore.cs | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 ZeroSpace/ZeroSpace.MapEditor/Services/StarSystemDocStore.cs diff --git a/ZeroSpace/ZeroSpace.MapEditor/Services/StarSystemDocStore.cs b/ZeroSpace/ZeroSpace.MapEditor/Services/StarSystemDocStore.cs new file mode 100644 index 0000000..d08f193 --- /dev/null +++ b/ZeroSpace/ZeroSpace.MapEditor/Services/StarSystemDocStore.cs @@ -0,0 +1,261 @@ +using System.Globalization; + +namespace ZeroSpace.MapEditor.Services; + +/// +/// One StarSystem markdown document: the editable map data plus the +/// read-only details shown in the inspector. Has no MAUI dependencies +/// so it can be tested outside the app. +/// +public sealed class StarSystemDoc +{ + public string FilePath { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Kind { get; set; } = string.Empty; + public bool HigherInfluenceYield { get; set; } + public bool HigherCreditYield { get; set; } + public bool HigherEnergyYield { get; set; } + public bool HigherChanceForItems { get; set; } + public string? OccupationBuff { get; set; } + public string? SystemMastery { get; set; } + public List ConnectedSystems { get; } = new(); + public double X { get; set; } + public double Y { get; set; } + public bool HasPosition { get; set; } + public bool IsDirty { get; set; } +} + +public static class StarSystemDocStore +{ + /// World coordinate space used in the markdown files. + public const double WorldSize = 1000; + + public static List LoadFolder(string folder) + { + var docs = new List(); + foreach (var path in Directory.GetFiles(folder, "*.md").OrderBy(static p => p, StringComparer.OrdinalIgnoreCase)) + { + var doc = LoadFile(path); + if (doc is not null) + { + docs.Add(doc); + } + } + + // Any file missing x/y gets a deterministic spread-out placeholder. + var missing = docs.Where(static d => !d.HasPosition).ToList(); + for (var i = 0; i < missing.Count; i++) + { + var (x, y) = DefaultPosition(i, missing.Count); + missing[i].X = x; + missing[i].Y = y; + missing[i].IsDirty = true; + } + + return docs; + } + + public static StarSystemDoc? LoadFile(string path) + { + var lines = File.ReadAllLines(path); + if (lines.Length < 2 || lines[0].Trim() != "---") + { + return null; + } + + var values = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var scalars = new Dictionary(StringComparer.OrdinalIgnoreCase); + string? listKey = null; + var closed = false; + + for (var i = 1; i < lines.Length; i++) + { + var line = lines[i]; + if (line.Trim() == "---") + { + closed = true; + break; + } + + if (listKey is not null && line.StartsWith(" - ", StringComparison.Ordinal)) + { + values[listKey].Add(CleanValue(line[4..])); + continue; + } + + listKey = null; + var separator = line.IndexOf(':'); + if (separator < 0) + { + continue; + } + + var key = line[..separator].Trim(); + var raw = line[(separator + 1)..].Trim(); + if (raw.Length == 0) + { + values[key] = new List(); + listKey = key; + } + else + { + scalars[key] = CleanValue(raw); + } + } + + if (!closed) + { + return null; + } + + if (!scalars.TryGetValue("category", out var category) || + !string.Equals(category, "StarSystem", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var doc = new StarSystemDoc + { + FilePath = path, + Name = Path.GetFileNameWithoutExtension(path), + OccupationBuff = scalars.TryGetValue("occupationBuff", out var buff) ? buff : null, + SystemMastery = scalars.TryGetValue("systemMastery", out var mastery) ? mastery : null, + }; + + if (IsTrue(scalars, "isSideSystem")) doc.Kind = "Side"; + if (IsTrue(scalars, "isHomeworldSystem")) doc.Kind = "Homeworld"; + if (IsTrue(scalars, "isCoreSystem")) doc.Kind = "Core"; + + doc.HigherInfluenceYield = IsTrue(scalars, "higherInfluenceYield"); + doc.HigherCreditYield = IsTrue(scalars, "higherCreditYield"); + doc.HigherEnergyYield = IsTrue(scalars, "higherEnergyYield"); + doc.HigherChanceForItems = IsTrue(scalars, "higherChanceForItems"); + + if (values.TryGetValue("connectedSystems", out var connected)) + { + doc.ConnectedSystems.AddRange(connected.Where(static c => !string.IsNullOrWhiteSpace(c))); + } + + if (scalars.TryGetValue("x", out var xRaw) && TryParseDouble(xRaw, out var x) && + scalars.TryGetValue("y", out var yRaw) && TryParseDouble(yRaw, out var y)) + { + doc.X = x; + doc.Y = y; + doc.HasPosition = true; + } + + return doc; + } + + /// + /// Writes x/y back into each dirty file's frontmatter, preserving + /// every other line byte-for-byte. + /// + public static void SavePositions(IEnumerable docs) + { + foreach (var doc in docs.Where(static d => d.IsDirty)) + { + SavePosition(doc); + doc.IsDirty = false; + } + } + + public static void SavePosition(StarSystemDoc doc) + { + var text = File.ReadAllText(doc.FilePath); + var newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None).ToList(); + if (lines.Count < 2 || lines[0].Trim() != "---") + { + return; + } + + var closeIndex = lines.FindIndex(1, static line => line.Trim() == "---"); + if (closeIndex < 0) + { + return; + } + + var xLine = $"x: {doc.X.ToString("F1", CultureInfo.InvariantCulture)}"; + var yLine = $"y: {doc.Y.ToString("F1", CultureInfo.InvariantCulture)}"; + var foundX = false; + var foundY = false; + + for (var i = 1; i < closeIndex; i++) + { + var key = KeyOf(lines[i]); + if (string.Equals(key, "x", StringComparison.OrdinalIgnoreCase)) + { + lines[i] = xLine; + foundX = true; + } + else if (string.Equals(key, "y", StringComparison.OrdinalIgnoreCase)) + { + lines[i] = yLine; + foundY = true; + } + } + + if (!foundX) + { + lines.Insert(closeIndex, xLine); + closeIndex++; + } + + if (!foundY) + { + lines.Insert(closeIndex, yLine); + } + + File.WriteAllText(doc.FilePath, string.Join(newline, lines)); + } + + /// + /// Deterministic phyllotaxis (sunflower spiral) spread across the + /// world space, so placeholder nodes never cover each other. + /// + public static (double X, double Y) DefaultPosition(int index, int count) + { + var goldenAngle = Math.PI * (3 - Math.Sqrt(5)); + var radius = (WorldSize / 2 - 30) * Math.Sqrt(index / (double)Math.Max(count, 1)); + var theta = index * goldenAngle; + return (WorldSize / 2 + radius * Math.Cos(theta), + WorldSize / 2 + radius * Math.Sin(theta)); + } + + private static string? KeyOf(string line) + { + var separator = line.IndexOf(':'); + return separator < 0 ? null : line[..separator].Trim(); + } + + private static bool IsTrue(Dictionary scalars, string key) + { + return scalars.TryGetValue(key, out var value) && + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + + private static bool TryParseDouble(string raw, out double value) + { + return double.TryParse(raw.Trim('"', '\'', ' '), + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, out value); + } + + internal 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; + } +}