Ship S2 fixes: structural anchors are not mounts, mod presets refused, one bad mod never bricks the boot

- Validate: a hardpoint's anchor role is fixed by its type (thruster-emitter, radiator-mesh, else articulation); ShipModPlan's
  duplicate role check is deleted.
- Loadouts.Commit refuses a preset naming a mod design (mod-hull:/mod-ship:), so the shipped catalog never references a mod.
- Compose quarantines each package: a bad package is excluded and named, a collision excludes every package involved; the
  game boots on the rest (or the shipped catalog) and the main menu lists the exclusions.
- RequireDesigns refuses a save with a null item or lot instead of throwing NullReferenceException.
- Tests: per structural role, muzzle id ties, every slot/zone/lot in RequireDesigns, listing order, exclusion cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 08:30:52 +02:00
commit 54917bb39d
13 changed files with 408 additions and 92 deletions

View file

@ -64,11 +64,14 @@ public static class Loadouts
// The only writer of presets. It opens its own short-lived cache over the catalog file, writable, and disposes it
// after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the
// file; a cache that already has the catalog open sees the preset only once it reloads.
// Throws and writes nothing when the file is missing (capture never creates a catalog), when a preset of this name
// Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a preset of this name
// exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional
// on the record at KeyOf(Name), so a preset changed on disk since this open is not clobbered: returns false then.
public static bool Commit(string catalogPath, Loadout loadout, bool replace)
{
var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray();
if (modDesigns.Length > 0)
throw new InvalidOperationException($"Preset '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a preset.");
if (!File.Exists(catalogPath))
throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a preset capture never creates one.");
using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true);

View file

@ -96,13 +96,22 @@ public static class RunSave
// built; the save is never edited to fit.
public static void RequireDesigns(CultCache cache, SavedGame game)
{
var named = (game.Zones ?? Array.Empty<CultRecordRef<SavedZone>>())
.SelectMany(zone => cache.Get(zone)?.Contents?.Entities ?? new List<EntityPack>())
.SelectMany(EntitySerializer.Items).Select(item => item.Data.Key)
.Concat(Lots(cache).Lots.Values.Select(lot => lot.Design.Key))
.Where(key => key.IsSet()).Distinct()
.Where(key => cache.Get<ItemData>(key) == null)
.Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray();
string[] named;
try
{
named = (game.Zones ?? Array.Empty<CultRecordRef<SavedZone>>())
.SelectMany(zone => cache.Get(zone)?.Contents?.Entities ?? new List<EntityPack>())
.SelectMany(EntitySerializer.Items).Select(item => item.Data.Key)
.Concat(Lots(cache).Lots.Values.Select(lot => lot.Design.Key))
.Where(key => key.IsSet()).Distinct()
.Where(key => cache.Get<ItemData>(key) == null)
.Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray();
}
catch (NullReferenceException error)
{
// A save with a null entity, item or lot where one is required is malformed, and refused like a missing design.
throw new InvalidOperationException("This run's save is malformed: an entity, item or lot is missing where one is required. Start a new game.", error);
}
if (named.Length == 0) return;
const string modHull = "mod-hull:";
var mods = named.Where(key => key.StartsWith(modHull, StringComparison.Ordinal)).Select(key => key.Substring(modHull.Length)).ToArray();

View file

@ -219,8 +219,15 @@ public static class ShipAuthoringStore
if (!occupiedHardpointCells.Add((x, y)))
throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} overlaps another hardpoint.");
}
if (!anchors.Any(anchor => anchor.Id == hardpoint.Transform))
var mountAnchor = anchors.FirstOrDefault(anchor => anchor.Id == hardpoint.Transform);
if (mountAnchor == null)
throw new InvalidOperationException($"{ship.Id}: hardpoint {hardpoint.Transform} has no model anchor.");
// A mount's anchor plays the mount, so its role is fixed by the hardpoint type; the structural roles (map-icon,
// hull-collider, shield, tractor, weapon-muzzle) name other nodes and never double as a mount.
var mountRole = hardpoint.Type == HardpointType.Thruster ? "thruster-emitter" :
hardpoint.Type == HardpointType.Radiator ? "radiator-mesh" : "articulation";
if (mountAnchor.Role != mountRole)
throw new InvalidOperationException($"{ship.Id}: {hardpoint.Type} hardpoint {hardpoint.Transform} needs a {mountRole} anchor of that id.");
}
foreach (var anchor in anchors.Where(anchor => !string.IsNullOrEmpty(anchor.ParentId)))
if (!mounts.Contains(anchor.ParentId))

View file

@ -21,7 +21,31 @@ public static class ShipModCatalog
public static CultRecordKey HullKey(string id) => new CultRecordKey("mod-hull:" + id);
public static CultRecordKey AuthoringKey(string id) => new CultRecordKey("mod-ship:" + id);
public static int Compose(string shippedCatalog, string outputCatalog, string modsRoot)
// A key the mods own. The shipped catalog and its authored records never reference one: a mod may be uninstalled.
public static bool IsModKey(CultRecordKey key) =>
key.IsSet() && (key.Value.StartsWith("mod-hull:", StringComparison.Ordinal) || key.Value.StartsWith("mod-ship:", StringComparison.Ordinal));
// A package left out of the composition, named by its directory, with every reason it was left out.
public sealed class Exclusion
{
public string Package;
public string Reason;
public override string ToString() => $"{Package}: {Reason}";
}
public sealed class Composition
{
// Ship IDs of the packages composed into the derived catalog, in directory order.
public string[] Included;
// Packages that failed on their own or collided with the catalog or with each other, in directory order.
public Exclusion[] Excluded;
}
// One bad package never stops the rest. Each package is read and validated on its own; one that fails is excluded and
// named. A collision (with the shipped catalog, or with another package's hull name) excludes every package involved,
// since neither has a claim to the name. The derived catalog holds the shipped catalog plus the included packages.
// Unsafe inputs and outputs (a missing catalog, a derived file that would replace a source) still throw: nothing is composed.
public static Composition Compose(string shippedCatalog, string outputCatalog, string modsRoot)
{
var source = Path.GetFullPath(shippedCatalog);
var output = Path.GetFullPath(outputCatalog);
@ -33,13 +57,21 @@ public static class ShipModCatalog
if (output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("The derived catalog cannot replace a mod source or asset.");
var packages = new List<Package>();
var reasons = new SortedDictionary<string, List<string>>(StringComparer.Ordinal);
void Exclude(string package, string reason)
{
if (!reasons.TryGetValue(package, out var list)) reasons[package] = list = new List<string>();
list.Add(reason);
}
var packages = new List<(string Directory, Package Package)>();
foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal))
{
var path = Path.Combine(directory, "ship.cc");
if (!File.Exists(path)) continue;
// ReadPackage pins each ID to its directory name, so IDs within one mods root are already unique.
packages.Add(ReadPackage(path));
var name = Path.GetFileName(directory);
// A package can fail in any way a hostile or broken file can, so its failure is quarantined whatever it is.
try { packages.Add((name, ReadPackage(path))); }
catch (Exception error) { Exclude(name, error.Message); }
}
Directory.CreateDirectory(Path.GetDirectoryName(output));
@ -51,33 +83,48 @@ public static class ShipModCatalog
{
Directory.CreateDirectory(workspace);
File.Copy(source, temporary);
Composition composition;
using (var cache = AetheriaStores.Open(temporary, catalogWritable: true))
{
foreach (var package in packages)
// Collisions are judged against the shipped records alone, before any package is written, so the
// verdict does not depend on directory order. CultCache indexes names per concrete type and lets the
// last writer win, and GetByName over a base type throws on a match across subtypes: a mod hull named
// like any shipped item, or like another mod's hull, would make name lookups wrong or throw.
var keys = new HashSet<CultRecordKey>(cache.AllStoredDocuments.Select(record => record.Key));
var itemNames = new HashSet<string>(cache.GetAll<ItemData>().Select(item => item.Name), StringComparer.Ordinal);
var shipIds = new HashSet<string>(cache.GetAll<ShipAuthoring>().Select(existing => existing.Id), StringComparer.Ordinal);
foreach (var (directory, package) in packages)
{
var (hull, ship) = (package.Hull, package.Visual);
var hullKey = HullKey(ship.Id);
var authoringKey = AuthoringKey(ship.Id);
if (cache.AllStoredDocuments.Any(record => record.Key.Equals(hullKey) || record.Key.Equals(authoringKey)))
throw new InvalidOperationException($"{ship.Id}: a mod key collides with an existing catalog record.");
// CultCache indexes names per concrete type and lets the last writer win, and GetByName over a base
// type throws on a match across subtypes. A mod hull named like any shipped item, or another mod's
// hull (upserted earlier in this loop), would make name lookups wrong or throw.
if (cache.GetAll<ItemData>().Any(item => string.Equals(item.Name, hull.Name, StringComparison.Ordinal)))
throw new InvalidOperationException($"{ship.Id}: hull name '{hull.Name}' collides with an existing catalog item.");
if (cache.GetAll<ShipAuthoring>().Any(existing => string.Equals(existing.Id, ship.Id, StringComparison.Ordinal)))
throw new InvalidOperationException($"{ship.Id}: ship ID collides with an existing catalog ship authoring record.");
// Verbatim: the mod store's own two records, under the keys it already holds them at.
ShipAuthoringStore.Write(cache, hull, ship);
if (keys.Contains(HullKey(ship.Id)) || keys.Contains(AuthoringKey(ship.Id)))
Exclude(directory, $"a mod key collides with an existing catalog record.");
if (itemNames.Contains(hull.Name))
Exclude(directory, $"hull name '{hull.Name}' collides with an existing catalog item.");
if (shipIds.Contains(ship.Id))
Exclude(directory, $"ship ID collides with an existing catalog ship authoring record.");
}
foreach (var claim in packages.GroupBy(package => package.Package.Hull.Name, StringComparer.Ordinal).Where(group => group.Count() > 1))
foreach (var (directory, package) in claim)
Exclude(directory, $"hull name '{claim.Key}' is also claimed by " +
string.Join(", ", claim.Where(other => other.Directory != directory).Select(other => other.Directory)) + ".");
var included = packages.Where(package => !reasons.ContainsKey(package.Directory)).Select(package => package.Package).ToArray();
foreach (var package in included)
// Verbatim: the mod store's own two records, under the keys it already holds them at.
ShipAuthoringStore.Write(cache, package.Hull, package.Visual);
// Shipped hulls too: the derived catalog must not hold a hull that names two bodies.
foreach (var hull in cache.GetAll<HullData>())
ShipAuthoringStore.RequireOneBody(hull, hull.Name);
cache.FlushAsync().GetAwaiter().GetResult();
composition = new Composition
{
Included = included.Select(package => package.Visual.Id).ToArray(),
Excluded = reasons.Select(entry => new Exclusion { Package = entry.Key, Reason = string.Join("; ", entry.Value) }).ToArray()
};
}
if (File.Exists(output)) File.Replace(temporary, output, null);
else File.Move(temporary, output);
return packages.Count;
return composition;
}
finally
{
@ -103,16 +150,16 @@ public static class ShipModCatalog
return Bind(hull, ship, directory, Path.Combine(directory, "ship.cc"));
}
// The one game-side choice of catalog: the shipped file when the mods root holds no package, otherwise the derived
// file, composed afresh from the shipped one and the packages. Recomposed every call, so the derived file is never
// stale and never an authority.
public static string ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot)
// The one game-side choice of catalog: the shipped file when no package is installed or none survives composition,
// otherwise the derived file, composed afresh from the shipped one and the surviving packages. Recomposed every
// call, so the derived file is never stale and never an authority. Excluded packages come back named, for the menu.
public static (string Catalog, Exclusion[] Excluded) ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot)
{
if (!Directory.Exists(modsRoot) ||
!Directory.GetDirectories(modsRoot).Any(directory => File.Exists(Path.Combine(directory, "ship.cc"))))
return shippedCatalog;
Compose(shippedCatalog, derivedCatalog, modsRoot);
return derivedCatalog;
return (shippedCatalog, Array.Empty<Exclusion>());
var composition = Compose(shippedCatalog, derivedCatalog, modsRoot);
return (composition.Included.Length == 0 ? shippedCatalog : derivedCatalog, composition.Excluded);
}
private static Package Bind(HullData hull, ShipAuthoring ship, string directory, string path)

View file

@ -25,18 +25,15 @@ public sealed class ShipModPlan
ShipAuthoringStore.Validate(hull, visual);
var hardpoints = hull.Hardpoints ?? new List<HardpointData>();
var byMount = hardpoints.ToDictionary(hardpoint => hardpoint.Transform, StringComparer.Ordinal);
var role = visual.Anchors.ToDictionary(anchor => anchor.Id, anchor => anchor.Role, StringComparer.Ordinal);
string Only(string wanted) => visual.Anchors.Single(anchor => anchor.Role == wanted).Id;
foreach (var anchor in visual.Anchors.Where(anchor => anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh"))
{
var wanted = anchor.Role == "thruster-emitter" ? HardpointType.Thruster : HardpointType.Radiator;
if (!byMount.TryGetValue(anchor.Id, out var mount) || mount.Type != wanted)
if (!byMount.ContainsKey(anchor.Id))
throw new InvalidOperationException($"{visual.Id}: {anchor.Role} anchor {anchor.Id} must be a {wanted} hardpoint's mount.");
}
string[] Mounts(HardpointType type, string wantedRole) => hardpoints.Where(hardpoint => hardpoint.Type == type).Select(hardpoint =>
role[hardpoint.Transform] == wantedRole ? hardpoint.Transform :
throw new InvalidOperationException($"{visual.Id}: {type} hardpoint {hardpoint.Transform} needs a {wantedRole} anchor of that id.")).ToArray();
string[] Mounts(HardpointType type) => hardpoints.Where(hardpoint => hardpoint.Type == type).Select(hardpoint => hardpoint.Transform).ToArray();
var muzzles = visual.Anchors.Where(anchor => anchor.Role == "weapon-muzzle")
.OrderBy(anchor => anchor.Order).ThenBy(anchor => anchor.Id, StringComparer.Ordinal).ToArray();
@ -57,8 +54,8 @@ public sealed class ShipModPlan
Shield = Only("shield"),
Tractor = Only("tractor"),
Equipment = hardpoints.Select(hardpoint => hardpoint.Transform).ToArray(),
Thrusters = Mounts(HardpointType.Thruster, "thruster-emitter"),
Radiators = Mounts(HardpointType.Radiator, "radiator-mesh"),
Thrusters = Mounts(HardpointType.Thruster),
Radiators = Mounts(HardpointType.Radiator),
Weapons = weapons
};
}