Clear dangling boss hull links, and stop the tool writing on read

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>
This commit is contained in:
Meta Vens 2026-09-12 02:05:33 +02:00
commit 285b57713f
4 changed files with 230 additions and 13 deletions

BIN
GameData/AetherDB.msgpack (Stored with Git LFS)

Binary file not shown.

View file

@ -58,6 +58,15 @@ AI share it, so AI accuracy is no longer superhuman by construction.
- **Deferred Unity-physics surfaces:** ship collision (`HullCollider`), loot
pickup (`ShieldManager`), `TractorBeam`, `Mine`. Not blockers.
**Known ownership smell in CultCache: loading writes.** `AddBackingStore`
subscribes `Add` to a store's `EntryAdded`; `MultiFileBackingStore.PullAll`
re-emits every entry it loads; `Add` pushes to the store registered for that
type, and that `Push` writes a file. So a cache with a second store rewrites
that store's files on open, and the game does this to `GameData/NameFile` at
every startup. Nothing distinguishes "loaded this" from "changed this".
Whoever next owns persistence should separate the two; until then, readers
must not register a writable store they only mean to read.
**Invariant: game simulation runs independently of Unity.** `ServerShared`
cannot reference UnityEngine (`Aetheria.Shared.Unity.asmdef` sets
`noEngineReferences`), and `Aetheria.Shared/Aetheria.Shared.csproj` builds it

View file

@ -1,15 +1,20 @@
using System;
using System.IO;
// Opens GameData/AetherDB.msgpack through the game's own CultCache and data types, for inspection and editing scripts.
// Close the editor's Database Tools before saving from a script, or its next Save will overwrite the script's changes.
// Opens GameData/AetherDB.msgpack through the game's own CultCache and data types, for inspection and editing.
// Close the editor's Database Tools before saving, or its next Save will overwrite what was written here.
//
// Reads do not write. That needs saying because CultCache makes it easy to get wrong: AddBackingStore subscribes
// Add to a store's EntryAdded, MultiFileBackingStore.PullAll re-emits every entry it loads, and Add pushes to the
// store registered for that type, whose Push writes a file. So registering a second store makes merely opening the
// cache rewrite its files. Name files are therefore loaded only when a command asks for them.
public sealed class AetherDb
{
public string Root { get; }
public CultCache Cache { get; }
private readonly SingleFileMessagePackBackingStore _store;
private AetherDb(string root)
private AetherDb(string root, bool withNameFiles)
{
Root = root;
RegisterResolver.Register();
@ -17,12 +22,16 @@ public sealed class AetherDb
_store = new SingleFileMessagePackBackingStore(Path.Combine(gameData, "AetherDB.msgpack"));
Cache = new CultCache();
Cache.AddBackingStore(_store);
Cache.AddBackingStore(new MultiFileMessagePackBackingStore(gameData), typeof(NameFile));
if (withNameFiles) Cache.AddBackingStore(new MultiFileMessagePackBackingStore(gameData), typeof(NameFile));
Cache.PullAllBackingStores();
}
// With no root given, walks up from the working directory to the repository
public static AetherDb Open(string root = null) => new AetherDb(root ?? FindRoot());
public static AetherDb Open(string root = null) => new AetherDb(root ?? FindRoot(), false);
// Galaxy generation needs the name files; loading them rewrites them, so only ask when they are needed,
// and not while the editor holds them open.
public static AetherDb OpenWithNameFiles(string root = null) => new AetherDb(root ?? FindRoot(), true);
// Writes every entry back to AetherDB.msgpack, as the editor's Save button does
public void Save() => _store.PushAll();

View file

@ -7,6 +7,8 @@ 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
@ -19,6 +21,11 @@ public static class Program
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]");
@ -78,7 +85,9 @@ public static class Program
}
// 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.
// 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();
@ -86,19 +95,209 @@ public static class Program
var failures = 0;
foreach (var hull in db.Cache.GetAll<HullData>().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;
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)
worstCase[hardpoint.Position + v] = false;
hardpointsFilled[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))}");
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)
{