diff --git a/tools/AetherDb/AetherDb.csproj b/tools/AetherDb/AetherDb.csproj
new file mode 100644
index 000000000..2287148e9
--- /dev/null
+++ b/tools/AetherDb/AetherDb.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ net10.0
+ disable
+ disable
+ AetherDbTool
+ $(NoWarn);CS1701;CS1702
+
+
+
+
+
diff --git a/tools/AetherDb/Directory.Build.props b/tools/AetherDb/Directory.Build.props
deleted file mode 100644
index 1a7b942e9..000000000
--- a/tools/AetherDb/Directory.Build.props
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- disable
- disable
-
- false
-
-
-
-
-
-
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
new file mode 100644
index 000000000..df12739c3
--- /dev/null
+++ b/tools/AetherDb/Program.cs
@@ -0,0 +1,136 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using MessagePack;
+
+// Commands over the game database, run with: dotnet run --project tools/AetherDb --
+public static class Program
+{
+ public static int Main(string[] args)
+ {
+ var command = args.FirstOrDefault() ?? "help";
+ switch (command)
+ {
+ case "doctor": return Doctor();
+ case "census": return Census();
+ case "station-fit": return StationFit();
+ case "migrate-products": return MigrateProducts(args.Contains("apply"));
+ default:
+ Console.WriteLine("commands: doctor, census, station-fit, migrate-products [apply]");
+ return 1;
+ }
+ }
+
+ // Every entry in the database deserializes, and what types it holds.
+ private static int Doctor()
+ {
+ var db = AetherDb.Open();
+ var bytes = File.ReadAllBytes(Path.Combine(db.Root, "GameData", "AetherDB.msgpack"));
+ var entries = MessagePackSerializer.Deserialize(bytes);
+ var nulls = entries.Select((e, i) => (e, i)).Where(x => x.e == null).Select(x => x.i).ToArray();
+ Console.WriteLine($"{bytes.Length} bytes, {entries.Length} entries, {db.Cache.AllEntries.Count()} through the cache");
+ Console.WriteLine(nulls.Length == 0 ? "no entries failed to deserialize" : $"FAILED at indices: {string.Join(", ", nulls)}");
+ foreach (var group in entries.Where(e => e != null).GroupBy(e => e.GetType().Name).OrderBy(g => g.Key))
+ Console.WriteLine($" {group.Key}: {group.Count()}");
+ return nulls.Length;
+ }
+
+ // Designs by kind and manufacturer, designs no product sells, and products with authored role quality.
+ private static int Census()
+ {
+ var db = AetherDb.Open();
+ var items = db.Cache.GetAll().ToArray();
+ var products = db.Cache.GetAll().ToArray();
+
+ var kinds = new Dictionary>();
+ foreach (var item in items)
+ {
+ var kind = item is HullData hull ? $"Hull/{hull.HullType}"
+ : item is DockingBayData ? "DockingBay"
+ : item is CargoBayData ? "CargoBay"
+ : item is WeaponItemData weapon ? $"Weapon/{weapon.HardpointType}"
+ : item.HardpointType.ToString();
+ if (!kinds.TryGetValue(kind, out var makers)) kinds[kind] = makers = new List();
+ makers.Add(db.Cache.Get(item.Manufacturer)?.ShortName ?? "(none)");
+ }
+
+ Console.WriteLine($"{items.Length} designs, {products.Length} products\n");
+ foreach (var kind in kinds.OrderBy(k => k.Key))
+ Console.WriteLine($"{kind.Key,-22} {kind.Value.Count,3} " + string.Join(", ",
+ kind.Value.GroupBy(m => m).OrderByDescending(g => g.Count()).Select(g => $"{g.Key}:{g.Count()}")));
+
+ var orphans = items.Where(i => !products.Any(p => p.Design == i.ID)).ToArray();
+ Console.WriteLine($"\n{orphans.Length} designs no product sells, so they cannot spawn:");
+ foreach (var item in orphans) Console.WriteLine($" {item.Name}");
+
+ var authored = products.Where(p => p.Roles != null && p.Roles.Count > 0).ToArray();
+ Console.WriteLine($"\n{authored.Length} products carry role quality:");
+ foreach (var product in authored)
+ Console.WriteLine($" {product.Name,-28} by {db.Cache.Get(product.Manufacturer)?.ShortName ?? "(none)",-12} " +
+ string.Join(", ", product.Roles.Select(r => $"{r.Role} {r.Mean:0.00}±{r.StandardDeviation:0.00}")));
+ if (authored.Length == 0) Console.WriteLine(" none yet: add roles to a design, then set each product's means");
+ return 0;
+ }
+
+ // Some docking bay fits every station hull, which LoadoutGenerator.GenerateStationLoadout requires.
+ // Worst case: hardpoint gear is equipped first, leaving only the non-hardpoint interior free.
+ private static int StationFit()
+ {
+ var db = AetherDb.Open();
+ var bays = db.Cache.GetAll().ToArray();
+ var failures = 0;
+ foreach (var hull in db.Cache.GetAll().Where(h => h.HullType == HullType.Station))
+ {
+ var worstCase = new Shape(hull.Shape.Width, hull.Shape.Height);
+ foreach (var v in hull.InteriorCells.Coordinates) worstCase[v] = true;
+ foreach (var hardpoint in hull.Hardpoints)
+ foreach (var v in hardpoint.Shape.Coordinates)
+ worstCase[hardpoint.Position + v] = false;
+
+ var fitting = bays.Where(b => b.Shape.FitsWithin(worstCase, out _, out _)).Select(b => b.Name).ToArray();
+ if (fitting.Length == 0) failures++;
+ Console.WriteLine($"{hull.Name}: {(fitting.Length == 0 ? "NO docking bay fits" : string.Join(", ", fitting))}");
+ }
+ return failures;
+ }
+
+ // Mints a product for every design that still carries a manufacturer, copying its name and description.
+ private static int MigrateProducts(bool apply)
+ {
+ var db = AetherDb.Open();
+ var products = db.Cache.GetAll().ToList();
+ var designs = db.Cache.GetAll()
+ .Where(d => d.Manufacturer != Guid.Empty)
+ .Where(d => !products.Any(p => p.Design == d.ID && p.Manufacturer == d.Manufacturer))
+ .ToArray();
+
+ Console.WriteLine($"{products.Count} products exist; {designs.Length} designs need one\n");
+ foreach (var design in designs)
+ {
+ var product = new FactionProductData
+ {
+ Name = design.Name,
+ Description = design.Description,
+ Design = design.ID,
+ Manufacturer = design.Manufacturer,
+ // A design authored before roles existed has none; its product gets rows when roles are added
+ Roles = (design.Roles ?? new List()).Select(r => new ProductRole { Role = r.Name }).ToList()
+ };
+ Console.WriteLine($" {product.Name,-28} by {db.Cache.Get(product.Manufacturer)?.ShortName ?? "(unknown)"}");
+ if (apply) db.Cache.Add(product);
+ }
+
+ if (apply && designs.Length > 0)
+ {
+ db.Save();
+ Console.WriteLine($"\nSaved {designs.Length} products to AetherDB.msgpack");
+ }
+ else if (!apply) Console.WriteLine("\nDry run. Pass \"apply\" to write these to the database.");
+ return 0;
+ }
+}
diff --git a/tools/AetherDb/scripts/item-census.cs b/tools/AetherDb/scripts/item-census.cs
deleted file mode 100644
index 22dfbda76..000000000
--- a/tools/AetherDb/scripts/item-census.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-// Counts equippable items by kind and manufacturer, to show how item variety is spread across the database.
-using System;
-using System.Linq;
-
-var db = AetherDb.Open();
-string Maker(Guid id) => db.Cache.Get(id)?.ShortName ?? "(none)";
-string Kind(EquippableItemData item) => item switch
-{
- HullData hull => $"Hull/{hull.HullType}",
- DockingBayData _ => "DockingBay",
- CargoBayData _ => "CargoBay",
- WeaponItemData weapon => $"Weapon/{weapon.HardpointType}",
- _ => item.HardpointType.ToString()
-};
-
-var items = db.Cache.GetAll().ToArray();
-Console.WriteLine($"{items.Length} equippable items from {items.Select(i => i.Manufacturer).Distinct().Count()} manufacturers\n");
-foreach (var kind in items.GroupBy(Kind).OrderBy(g => g.Key))
- Console.WriteLine($"{kind.Key,-22} {kind.Count(),3} " +
- string.Join(", ", kind.GroupBy(i => Maker(i.Manufacturer)).OrderByDescending(g => g.Count()).Select(g => $"{g.Key}:{g.Count()}")));
-
-Console.WriteLine("\nSame kind and footprint from more than one manufacturer (candidate duplicates):");
-foreach (var group in items.GroupBy(i => (Kind(i), i.Shape.Coordinates.Length)).Where(g => g.Select(i => i.Manufacturer).Distinct().Count() > 1))
- Console.WriteLine($" {group.Key.Item1} size {group.Key.Length}: " + string.Join(" | ", group.Select(i => $"{i.Name} ({Maker(i.Manufacturer)})")));
diff --git a/tools/AetherDb/scripts/migrate-products.cs b/tools/AetherDb/scripts/migrate-products.cs
deleted file mode 100644
index 75938dee0..000000000
--- a/tools/AetherDb/scripts/migrate-products.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-// Mints a faction product for every design that still carries a manufacturer, so branded variants exist for
-// what the database already had. Copies the design's name and description as the brand's, and adds a default
-// quality distribution row per role. Dry run unless passed "apply".
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-var db = AetherDb.Open();
-var apply = args.Any(a => a == "apply");
-var products = db.Cache.GetAll().ToList();
-var designs = db.Cache.GetAll()
- .Where(d => d.Manufacturer != Guid.Empty)
- .Where(d => !products.Any(p => p.Design == d.ID && p.Manufacturer == d.Manufacturer))
- .ToArray();
-
-Console.WriteLine($"{products.Count} products exist; {designs.Length} designs need one\n");
-foreach (var design in designs)
-{
- var maker = db.Cache.Get(design.Manufacturer);
- var product = new FactionProductData
- {
- Name = design.Name,
- Description = design.Description,
- Design = design.ID,
- Manufacturer = design.Manufacturer,
- // A design authored before roles existed has none; its product gets rows when roles are added to it.
- Roles = (design.Roles ?? new List()).Select(r => new ProductRole { Role = r.Name }).ToList()
- };
- Console.WriteLine($" {product.Name,-28} by {maker?.ShortName ?? "(unknown)",-12} roles: " +
- (product.Roles.Count == 0 ? "none" : string.Join(", ", product.Roles.Select(q => q.Role))));
- if (apply) db.Cache.Add(product);
-}
-
-if (apply && designs.Length > 0)
-{
- db.Save();
- Console.WriteLine($"\nSaved {designs.Length} products to AetherDB.msgpack");
-}
-else if (!apply)
- Console.WriteLine("\nDry run. Pass \"apply\" to write these to the database.");
diff --git a/tools/AetherDb/scripts/station-fit.cs b/tools/AetherDb/scripts/station-fit.cs
deleted file mode 100644
index fd1323daf..000000000
--- a/tools/AetherDb/scripts/station-fit.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-// Checks that some docking bay fits every station hull, which LoadoutGenerator.GenerateStationLoadout requires.
-// Exits with the number of station hulls that cannot take any docking bay.
-using System;
-using System.Linq;
-
-var db = AetherDb.Open();
-var bays = db.Cache.GetAll().ToArray();
-var failures = 0;
-foreach (var hull in db.Cache.GetAll().Where(h => h.HullType == HullType.Station))
-{
- // Hardpoint gear is equipped before the docking bay, so the worst case leaves only the non-hardpoint interior free
- var worstCase = new Shape(hull.Shape.Width, hull.Shape.Height);
- foreach (var v in hull.InteriorCells.Coordinates) worstCase[v] = true;
- foreach (var hardpoint in hull.Hardpoints)
- foreach (var v in hardpoint.Shape.Coordinates)
- worstCase[hardpoint.Position + v] = false;
-
- var fitting = bays.Where(b => b.Shape.FitsWithin(worstCase, out _, out _)).Select(b => b.Name).ToArray();
- if (fitting.Length == 0) failures++;
- Console.WriteLine($"{hull.Name}: {(fitting.Length == 0 ? "NO docking bay fits" : string.Join(", ", fitting))}");
-}
-return failures;