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;
+ }
+}