diff --git a/GameData/AetherDB.msgpack b/GameData/AetherDB.msgpack index 9347cabbb..c61b7845e 100644 --- a/GameData/AetherDB.msgpack +++ b/GameData/AetherDB.msgpack @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7d40c602ea1942158f706add32ed13cff21c15374460831e2a6499d519761e2 +oid sha256:8963a68670ae8e8e0ac411c96de7a39322802293b91f53f4d3f50717b06429aa size 46150 diff --git a/docs/three-gates-scope.md b/docs/three-gates-scope.md index f7abbf3f8..67a59f679 100644 --- a/docs/three-gates-scope.md +++ b/docs/three-gates-scope.md @@ -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 diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index c31fcf6ee..9d7bc3e39 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -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(); diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index df12739c3..d4e8df34e 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -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 -- 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().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().ToArray(); + var products = db.Cache.GetAll().ToArray(); + var unfillable = 0; + foreach (var hull in db.Cache.GetAll().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() + .Where(f => f.BossHull != Guid.Empty && db.Cache.Get(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().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().OrderBy(f => f.Name)) + { + var geonames = faction.GeonameFile == Guid.Empty ? "UNSET" : "set"; + var boss = faction.BossHull == Guid.Empty + ? "none" + : db.Cache.Get(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(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(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(); + var itemManager = new ItemManager(db.Cache, settings, log.Add); + var failures = 0; + + foreach (var hull in db.Cache.GetAll().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) {