Eight factions pointed at one boss hull GUID that no longer resolves, shared wreckage from the database loss. PlaceFactionsMain gives a boss zone to every faction carrying a BossHull, so each dangling link claimed a chokepoint that could never spawn a boss. AetherDb.Open no longer registers the name file store. CultCache subscribes Add to a store EntryAdded, MultiFileBackingStore.PullAll re-emits what it loads, and Add pushes to that type store, whose Push writes a file: opening the cache rewrote all twelve name files, 4.4 MB, on every command. They were byte identical, and it only surfaced as an error because Unity had one of them memory mapped. Name files now load only on request, and the scope doc records the underlying smell. Adds commands: factions, clear-boss-hulls, save, loadout (runs the real LoadoutGenerator against every hull), hardpoint-fit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
335 lines
16 KiB
C#
335 lines
16 KiB
C#
/* 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;
|
|
using Unity.Mathematics;
|
|
using Random = Unity.Mathematics.Random;
|
|
|
|
// Commands over the game database, run with: dotnet run --project tools/AetherDb -- <command>
|
|
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 "hardpoint-fit": return HardpointFit();
|
|
case "loadout": return Loadout(args.Skip(1).FirstOrDefault());
|
|
case "save": return Save();
|
|
case "factions": return Factions();
|
|
case "clear-boss-hulls": return ClearBossHulls(args.Contains("apply"));
|
|
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<DatabaseEntry[]>(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<EquippableItemData>().ToArray();
|
|
var products = db.Cache.GetAll<FactionProductData>().ToArray();
|
|
|
|
var kinds = new Dictionary<string, List<string>>();
|
|
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<string>();
|
|
makers.Add(db.Cache.Get<Faction>(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<Faction>(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.
|
|
// Models the rule the game applies: an interior cell is usable when no gear occupies it, empty hardpoint
|
|
// cells included. Reports both the empty hull and the case where every hardpoint is already filled, because
|
|
// generation equips hardpoint gear first and only the first of those is guaranteed.
|
|
private static int StationFit()
|
|
{
|
|
var db = AetherDb.Open();
|
|
var bays = db.Cache.GetAll<DockingBayData>().ToArray();
|
|
var failures = 0;
|
|
foreach (var hull in db.Cache.GetAll<HullData>().Where(h => h.HullType == HullType.Station))
|
|
{
|
|
var empty = new Shape(hull.Shape.Width, hull.Shape.Height);
|
|
foreach (var v in hull.InteriorCells.Coordinates) empty[v] = true;
|
|
|
|
var hardpointsFilled = new Shape(hull.Shape.Width, hull.Shape.Height);
|
|
foreach (var v in hull.InteriorCells.Coordinates) hardpointsFilled[v] = true;
|
|
foreach (var hardpoint in hull.Hardpoints)
|
|
foreach (var v in hardpoint.Shape.Coordinates)
|
|
hardpointsFilled[hardpoint.Position + v] = false;
|
|
|
|
var onEmpty = bays.Where(b => b.Shape.FitsWithin(empty, out _, out _)).Select(b => b.Name).ToArray();
|
|
var onFilled = bays.Where(b => b.Shape.FitsWithin(hardpointsFilled, out _, out _)).Select(b => b.Name).ToArray();
|
|
if (onEmpty.Length == 0) failures++;
|
|
Console.WriteLine($"{hull.Name}: empty hull fits [{string.Join(", ", onEmpty)}], every hardpoint filled fits [{string.Join(", ", onFilled)}]");
|
|
if (onEmpty.Length > 0 && onFilled.Length == 0)
|
|
Console.WriteLine(" depends on hardpoint gear leaving cells free; generation can still fail here");
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
// Every hardpoint on every hull, and which designs can fill it: LoadoutGenerator requires a matching
|
|
// HardpointType, a shape that fits the hardpoint, and exactly as many cells as the hardpoint has. A
|
|
// hardpoint no product can fill is gear the game cannot place.
|
|
private static int HardpointFit()
|
|
{
|
|
var db = AetherDb.Open();
|
|
var gear = db.Cache.GetAll<GearData>().ToArray();
|
|
var products = db.Cache.GetAll<FactionProductData>().ToArray();
|
|
var unfillable = 0;
|
|
foreach (var hull in db.Cache.GetAll<HullData>().OrderBy(h => h.HullType).ThenBy(h => h.Name))
|
|
{
|
|
Console.WriteLine($"\n{hull.Name} ({hull.HullType}):");
|
|
foreach (var hardpoint in hull.Hardpoints.OrderByDescending(h => h.Shape.Coordinates.Length))
|
|
{
|
|
var cells = hardpoint.Shape.Coordinates.Length;
|
|
var matches = gear.Where(g =>
|
|
g.HardpointType == hardpoint.Type &&
|
|
g.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) &&
|
|
g.Shape.Coordinates.Length == cells).ToArray();
|
|
var sold = matches.Where(m => products.Any(p => p.Design == m.ID)).ToArray();
|
|
if (sold.Length == 0) unfillable++;
|
|
Console.WriteLine($" {hardpoint.Type,-14} {cells,2} cells: {matches.Length} designs match, {sold.Length} sold" +
|
|
(matches.Length == 0 ? " <- no design of that size" : sold.Length == 0 ? " <- designs exist but no product sells them" : ""));
|
|
foreach (var match in matches)
|
|
Console.WriteLine($" {match.Name,-28} {match.Shape.Coordinates.Length} cells" +
|
|
(products.Any(p => p.Design == match.ID) ? "" : " (unsold)"));
|
|
}
|
|
}
|
|
Console.WriteLine($"\n{unfillable} hardpoints no product can fill");
|
|
return unfillable;
|
|
}
|
|
|
|
// Clears boss hull links that resolve to nothing. Galaxy.PlaceFactionsMain gives a boss zone to every faction
|
|
// carrying a BossHull, so a dangling link claims a chokepoint that can never spawn a boss. Dry run unless
|
|
// passed "apply".
|
|
private static int ClearBossHulls(bool apply)
|
|
{
|
|
var db = AetherDb.Open();
|
|
var dangling = db.Cache.GetAll<Faction>()
|
|
.Where(f => f.BossHull != Guid.Empty && db.Cache.Get<HullData>(f.BossHull) == null)
|
|
.OrderBy(f => f.Name)
|
|
.ToArray();
|
|
|
|
Console.WriteLine($"{dangling.Length} factions point at a boss hull that does not exist:");
|
|
foreach (var faction in dangling)
|
|
{
|
|
Console.WriteLine($" {faction.Name,-26} {faction.BossHull}");
|
|
if (apply) faction.BossHull = Guid.Empty;
|
|
}
|
|
|
|
if (apply && dangling.Length > 0)
|
|
{
|
|
db.Save();
|
|
Console.WriteLine($"\nCleared {dangling.Length} boss hull links in AetherDB.msgpack");
|
|
}
|
|
else if (!apply && dangling.Length > 0) Console.WriteLine("\nDry run. Pass \"apply\" to clear them.");
|
|
return 0;
|
|
}
|
|
|
|
// Each faction's generation-critical links, read through the loaded cache so the multi-file NameFile store
|
|
// counts. Galaxy.GenerateNames dereferences the geoname file without a guard, so a dangling link there stops
|
|
// galaxy generation outright.
|
|
private static int Factions()
|
|
{
|
|
// Name files are not loaded: reporting a geoname link needs only the link's target id, and loading that
|
|
// store rewrites it. A dangling geoname therefore reads as "not loaded" here rather than as DANGLING.
|
|
var db = AetherDb.Open();
|
|
var products = db.Cache.GetAll<FactionProductData>().ToArray();
|
|
Console.WriteLine($"{"faction",-26} {"short",-13} {"geonames",-22} {"boss hull",-14} {"influence",-9} products");
|
|
var broken = 0;
|
|
foreach (var faction in db.Cache.GetAll<Faction>().OrderBy(f => f.Name))
|
|
{
|
|
var geonames = faction.GeonameFile == Guid.Empty ? "UNSET" : "set";
|
|
var boss = faction.BossHull == Guid.Empty
|
|
? "none"
|
|
: db.Cache.Get<HullData>(faction.BossHull)?.Name ?? "DANGLING";
|
|
if (geonames == "UNSET" || boss == "DANGLING") broken++;
|
|
Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {geonames,-22} {boss,-14} {faction.InfluenceDistance,-9} " +
|
|
products.Count(p => p.Manufacturer == faction.ID));
|
|
}
|
|
Console.WriteLine($"\n{broken} factions carry a link that would break generation");
|
|
return broken;
|
|
}
|
|
|
|
// What the saved run actually holds per zone. A zone carrying orbits but no orbital entities was packed by a
|
|
// generation run that failed partway, and is reused on revisit rather than regenerated, so a fixed generator
|
|
// does not repopulate it.
|
|
private static int Save()
|
|
{
|
|
var db = AetherDb.Open();
|
|
var path = Path.Combine(db.Root, "GameData", "PlayerSettings.msgpack");
|
|
if (!File.Exists(path))
|
|
{
|
|
Console.WriteLine($"no save at {path}");
|
|
return 0;
|
|
}
|
|
|
|
PlayerSettings settings;
|
|
try
|
|
{
|
|
settings = MessagePackSerializer.Deserialize<PlayerSettings>(File.ReadAllBytes(path));
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Console.WriteLine($"save does not deserialize: {e.GetType().Name}: {e.Message}");
|
|
return 1;
|
|
}
|
|
|
|
var run = settings.SavedRun;
|
|
if (run?.Zones == null)
|
|
{
|
|
Console.WriteLine($"save holds no run (player {settings.Name}, tutorial passed: {settings.TutorialPassed})");
|
|
return 0;
|
|
}
|
|
|
|
Console.WriteLine($"player {settings.Name}, {run.Zones.Length} zones, current zone {run.CurrentZone}\n");
|
|
var suspect = 0;
|
|
for (var i = 0; i < run.Zones.Length; i++)
|
|
{
|
|
var zone = run.Zones[i];
|
|
if (zone.Contents == null) continue;
|
|
var stations = zone.Contents.Entities.Count(e => e is OrbitalEntityPack);
|
|
var ships = zone.Contents.Entities.Count(e => e is ShipPack);
|
|
var owner = zone.Owner >= 0 && zone.Owner < run.Factions.Length
|
|
? db.Cache.Get<Faction>(run.Factions[zone.Owner])?.ShortName ?? "(unknown)"
|
|
: "(none)";
|
|
var packedEmpty = zone.Contents.Orbits.Count > 0 && stations == 0;
|
|
if (packedEmpty) suspect++;
|
|
Console.WriteLine($" [{i,3}] {zone.Name,-18} owner {owner,-12} orbits {zone.Contents.Orbits.Count,3} " +
|
|
$"stations {stations,2} ships {ships,2}{(packedEmpty ? " <- packed with orbits but no stations" : "")}");
|
|
}
|
|
|
|
Console.WriteLine($"\n{suspect} visited zones packed with orbits but no stations");
|
|
return 0;
|
|
}
|
|
|
|
// Runs the real LoadoutGenerator against every hull, so a generation failure reproduces here instead of on a
|
|
// flight to a populated sector. No galaxy, so this covers placement, roles and products but NOT availability
|
|
// filtering or distance weighting, which need a real galaxy. Seeded: a failure repeats.
|
|
private static int Loadout(string seedArgument)
|
|
{
|
|
var db = AetherDb.Open();
|
|
var seed = uint.TryParse(seedArgument, out var parsed) ? parsed : 1u;
|
|
var settings = new GameplaySettings
|
|
{
|
|
DefaultEntitySettings = new EntitySettings(),
|
|
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
|
|
QualityPriceModifier = new ExponentialLerp()
|
|
};
|
|
var log = new List<string>();
|
|
var itemManager = new ItemManager(db.Cache, settings, log.Add);
|
|
var failures = 0;
|
|
|
|
foreach (var hull in db.Cache.GetAll<HullData>().OrderBy(h => h.HullType).ThenBy(h => h.Name))
|
|
{
|
|
var random = new Random(seed);
|
|
var generator = new LoadoutGenerator(ref random, itemManager, null, null, null, .5f);
|
|
log.Clear();
|
|
string outcome;
|
|
try
|
|
{
|
|
var pack = hull.HullType switch
|
|
{
|
|
HullType.Ship => generator.GenerateShipLoadout(candidate => candidate.ID == hull.ID),
|
|
HullType.Turret => generator.GenerateTurretLoadout(),
|
|
_ => generator.GenerateStationLoadout()
|
|
};
|
|
outcome = pack == null ? "NO LOADOUT (nothing suitable found)" : "ok";
|
|
if (pack == null) failures++;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
outcome = $"THREW {e.GetType().Name}: {e.Message}";
|
|
failures++;
|
|
}
|
|
|
|
Console.WriteLine($"{hull.Name,-12} ({hull.HullType,-7}) {outcome}");
|
|
foreach (var line in log.Distinct()) Console.WriteLine($" {line}");
|
|
}
|
|
|
|
Console.WriteLine($"\n{failures} hulls failed to generate a loadout (seed {seed})");
|
|
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<FactionProductData>().ToList();
|
|
var designs = db.Cache.GetAll<CraftedItemData>()
|
|
.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<ItemRole>()).Select(r => new ProductRole { Role = r.Name }).ToList()
|
|
};
|
|
Console.WriteLine($" {product.Name,-28} by {db.Cache.Get<Faction>(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;
|
|
}
|
|
}
|