297 lines
9.1 KiB
C#
297 lines
9.1 KiB
C#
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 int SavePositions(IEnumerable<StarSystemDoc> docs)
|
|
{
|
|
var count = 0;
|
|
var errors = new List<Exception>();
|
|
var items = docs.Where(static d => d.IsDirty)
|
|
.Select(static d => (Doc: d, TargetX: d.X, TargetY: d.Y))
|
|
.ToList();
|
|
|
|
foreach (var item in items)
|
|
{
|
|
try
|
|
{
|
|
SavePosition(item.Doc.FilePath, item.TargetX, item.TargetY);
|
|
if (item.Doc.X == item.TargetX && item.Doc.Y == item.TargetY)
|
|
{
|
|
item.Doc.IsDirty = false;
|
|
}
|
|
|
|
count++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors.Add(new IOException($"Failed to save '{item.Doc.Name}': {ex.Message}", ex));
|
|
}
|
|
}
|
|
|
|
if (errors.Count == 1)
|
|
{
|
|
throw errors[0];
|
|
}
|
|
|
|
if (errors.Count > 1)
|
|
{
|
|
throw new AggregateException(errors);
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
public static void SavePosition(StarSystemDoc doc)
|
|
{
|
|
SavePosition(doc.FilePath, doc.X, doc.Y);
|
|
}
|
|
|
|
public static void SavePosition(string filePath, double x, double y)
|
|
{
|
|
var text = File.ReadAllText(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: {x.ToString("F1", CultureInfo.InvariantCulture)}";
|
|
var yLine = $"y: {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(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;
|
|
}
|
|
} |