AI: Add .NET MAUI star map position editor

This commit is contained in:
2026-09-29 18:21:21 +00:00
parent 92e6722ad5
commit da15f82611
@@ -0,0 +1,261 @@
using System.Globalization;
namespace ZeroSpace.MapEditor.Services;
/// <summary>
/// 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.
/// </summary>
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<string> 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
{
/// <summary>World coordinate space used in the markdown files.</summary>
public const double WorldSize = 1000;
public static List<StarSystemDoc> LoadFolder(string folder)
{
var docs = new List<StarSystemDoc>();
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<string, List<string>>(StringComparer.OrdinalIgnoreCase);
var scalars = new Dictionary<string, string>(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<string>();
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;
}
/// <summary>
/// Writes x/y back into each dirty file's frontmatter, preserving
/// every other line byte-for-byte.
/// </summary>
public static void SavePositions(IEnumerable<StarSystemDoc> 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));
}
/// <summary>
/// Deterministic phyllotaxis (sunflower spiral) spread across the
/// world space, so placeholder nodes never cover each other.
/// </summary>
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<string, string> 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;
}
}