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:
parent
7eebd21d29
commit
54917bb39d
13 changed files with 408 additions and 92 deletions
|
|
@ -42,6 +42,9 @@ public class ActionGameManager : MonoBehaviour
|
|||
|
||||
private static CultCache _cultCache;
|
||||
|
||||
// Mod packages left out of this boot (a bad package, or a name collision), for the main menu to name.
|
||||
public static ShipModCatalog.Exclusion[] ModExclusions { get; private set; } = Array.Empty<ShipModCatalog.Exclusion>();
|
||||
|
||||
private static string CatalogPath => Path.Combine(GameDataDirectory.FullName, "Aetheria.cc");
|
||||
private static string ModsPath => Path.Combine(GameDataDirectory.FullName, "Mods");
|
||||
// Disposable: composed at every boot from the shipped catalog and the mod packages, never authored.
|
||||
|
|
@ -57,8 +60,10 @@ public class ActionGameManager : MonoBehaviour
|
|||
// inside this cache; reopening would replace the catalog instances the galaxy and live entities hold.
|
||||
// The catalog is read-only in every build; capturepreset writes through its own cache (Loadouts.Commit) to the
|
||||
// shipped file. With mod packages installed the game reads the derived catalog instead.
|
||||
var (catalog, excluded) = ShipModCatalog.ResolveCatalog(CatalogPath, DerivedCatalogPath, ModsPath);
|
||||
ModExclusions = excluded;
|
||||
_cultCache = AetheriaStores.Open(
|
||||
ShipModCatalog.ResolveCatalog(CatalogPath, DerivedCatalogPath, ModsPath),
|
||||
catalog,
|
||||
runPath: Path.Combine(GameDataDirectory.FullName, "run.cc"),
|
||||
playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc"));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ public class MainMenu : MonoBehaviour
|
|||
_fadeFromRight = fromRight;
|
||||
}
|
||||
|
||||
private static bool _exclusionsShown;
|
||||
|
||||
private void ShowMain()
|
||||
{
|
||||
_nextMenu.panel.Clear();
|
||||
|
|
@ -97,6 +99,11 @@ public class MainMenu : MonoBehaviour
|
|||
{
|
||||
// A run exists only as the run store's SavedGame global.
|
||||
var cache = ActionGameManager.CultCache;
|
||||
if (ActionGameManager.ModExclusions.Length > 0 && !_exclusionsShown)
|
||||
{
|
||||
_exclusionsShown = true;
|
||||
Refuse("Some mods were not loaded", string.Join("\n", ActionGameManager.ModExclusions.Select(exclusion => exclusion.ToString())));
|
||||
}
|
||||
var saved = cache.GetGlobal<SavedGame>();
|
||||
if (saved == null && cache.AllStoredDocuments.Any(stored => RunSave.IsRunRecord(stored.Descriptor.DocumentType)))
|
||||
Debug.Log("run store has no SavedGame; Continue disabled");
|
||||
|
|
|
|||
|
|
@ -122,6 +122,29 @@ public sealed class LoadoutTests : IDisposable
|
|||
Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout"));
|
||||
}
|
||||
|
||||
// The shipped catalog never references a mod: a mod may be uninstalled, and a preset naming one would dangle. The
|
||||
// refusal is at the only writer, so no caller (the editor command included) can get one into the file.
|
||||
[Theory]
|
||||
[InlineData("hull", "mod-hull:mod.skiff")]
|
||||
[InlineData("slot", "mod-hull:mod.skiff")]
|
||||
[InlineData("slot", "mod-ship:mod.skiff")]
|
||||
public void APresetNamingAModDesignIsRefusedAndNothingIsWritten(string where, string key)
|
||||
{
|
||||
var mod = new CultRecordRef<EquippableItemData>(new CultRecordKey(key));
|
||||
using var cache = Open();
|
||||
var loadout = HandBuilt(cache);
|
||||
if (where == "hull") loadout.Hull = new CultRecordRef<HullData>(new CultRecordKey(key));
|
||||
else loadout.Slots[1].Design = mod;
|
||||
var before = Hash(Catalog);
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(Catalog, loadout, replace: false));
|
||||
|
||||
Assert.Contains("names mod designs", error.Message);
|
||||
Assert.Contains(key, error.Message);
|
||||
Assert.Equal(before, Hash(Catalog));
|
||||
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
|
||||
}
|
||||
|
||||
// Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates.
|
||||
[Fact]
|
||||
public void SameNameCaptureReplacesOnlyWhenAsked()
|
||||
|
|
|
|||
|
|
@ -341,6 +341,117 @@ public sealed class RunSaveTests : IDisposable
|
|||
Assert.Equal(before, File.ReadAllBytes(Run));
|
||||
}
|
||||
|
||||
// --- RequireDesigns: what it walks, what it lists ---------------------------------------------------------------
|
||||
|
||||
// Commits a run whose zones the caller builds, then returns RequireDesigns' refusal message, or null when it passes.
|
||||
// `present` is a gear item ("Lamp", not a hull) the catalog holds; unit(design, lot) mints an item naming `design`
|
||||
// whose lot names `lot`.
|
||||
private string DesignsRefused(Func<CultRecordRef<ItemData>, Func<CultRecordRef<ItemData>, CultRecordRef<ItemData>, EquippableItem>, SavedZone[]> build)
|
||||
{
|
||||
CultRecordRef<ItemData> present;
|
||||
using (var setup = AetheriaStores.Open(Catalog, catalogWritable: true))
|
||||
{
|
||||
var lamp = setup.GetAll<GearData>().SingleOrDefault(gear => gear.Name == "Lamp") ??
|
||||
setup.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape() });
|
||||
present = new CultRecordRef<ItemData>(setup.RefOf(lamp).Key);
|
||||
setup.FlushAsync().Wait();
|
||||
}
|
||||
using var cache = Open();
|
||||
var ledger = new ProvenanceLedger();
|
||||
EquippableItem Unit(CultRecordRef<ItemData> design, CultRecordRef<ItemData> lot)
|
||||
{
|
||||
var number = ledger.NextLot++;
|
||||
ledger.Lots[number] = new Lot { Design = lot, Origin = new Attributed() };
|
||||
return new EquippableItem { Data = design, Lot = number };
|
||||
}
|
||||
ledger.NextLot = 1;
|
||||
RunSave.Commit(cache, Game(cache), build(present, Unit), ledger);
|
||||
try { RunSave.RequireDesigns(cache, cache.GetGlobal<SavedGame>()); return null; }
|
||||
catch (InvalidOperationException refusal) { return refusal.Message; }
|
||||
}
|
||||
|
||||
private static CultRecordRef<ItemData> Key(string key) => new CultRecordRef<ItemData>(new CultRecordKey(key));
|
||||
|
||||
private static SavedZone ZoneOf(params EntityPack[] packs) => new SavedZone
|
||||
{
|
||||
Name = "Zone", AdjacentZones = Array.Empty<int>(), Factions = Array.Empty<int>(), Owner = -1,
|
||||
Contents = new ZonePack { Entities = packs.ToList() }
|
||||
};
|
||||
|
||||
// Whatever the slot, a design missing from the catalog is found, and a design the catalog holds (a gear item, not
|
||||
// only a hull) is not: the gate walks every unit an entity carries, recursively, and resolves any item design.
|
||||
[Theory]
|
||||
[InlineData("hull")]
|
||||
[InlineData("equipment")]
|
||||
[InlineData("cargo bay")]
|
||||
[InlineData("docking bay")]
|
||||
[InlineData("cargo contents")]
|
||||
[InlineData("docking contents")]
|
||||
[InlineData("child hull")]
|
||||
[InlineData("child equipment")]
|
||||
public void RequireDesignsFindsAMissingDesignInEverySlotOfAnEntity(string slot)
|
||||
{
|
||||
string Refusal(bool missing) => DesignsRefused((present, unit) =>
|
||||
{
|
||||
EquippableItem Slot(string where) => unit(missing && where == slot ? Key("hull:gone") : present, present);
|
||||
var child = BarePack(Slot("child hull"));
|
||||
child.Equipment = new[] { (new int2(0, 0), Slot("child equipment")) };
|
||||
var pack = BarePack(Slot("hull"), new EntityPack[] { child });
|
||||
pack.Equipment = new[] { (new int2(0, 0), Slot("equipment")) };
|
||||
pack.CargoBays = new[] { (new int2(0, 0), Slot("cargo bay")) };
|
||||
pack.DockingBays = new[] { (new int2(0, 0), Slot("docking bay")) };
|
||||
pack.CargoContents = new[] { new (int2, ItemInstance)[] { (new int2(0, 0), Slot("cargo contents")) } };
|
||||
pack.DockingBayContents = new[] { new (int2, ItemInstance)[] { (new int2(0, 0), Slot("docking contents")) } };
|
||||
return new[] { ZoneOf(pack) };
|
||||
});
|
||||
|
||||
Assert.Null(Refusal(missing: false));
|
||||
Assert.Contains("missing other designs: hull:gone. Reinstall", Refusal(missing: true));
|
||||
}
|
||||
|
||||
// Every zone and every lot is read, not the first: each names its own missing design.
|
||||
[Fact]
|
||||
public void RequireDesignsReadsEveryZoneAndEveryLot()
|
||||
{
|
||||
var refusal = DesignsRefused((present, unit) => new[]
|
||||
{
|
||||
ZoneOf(BarePack(unit(Key("hull:zone-a"), present))),
|
||||
ZoneOf(BarePack(unit(present, present))),
|
||||
ZoneOf(BarePack(unit(Key("hull:zone-b"), present)), BarePack(unit(present, Key("hull:lot-a")))),
|
||||
ZoneOf(BarePack(unit(present, present)), BarePack(unit(present, Key("hull:lot-b"))))
|
||||
});
|
||||
|
||||
Assert.Contains("missing other designs: hull:lot-a, hull:lot-b, hull:zone-a, hull:zone-b. Reinstall", refusal);
|
||||
}
|
||||
|
||||
// Each missing design is listed once however many units name it, ordinally, mod ships apart from the rest.
|
||||
[Fact]
|
||||
public void RequireDesignsListsEachMissingDesignOnceInStableOrder()
|
||||
{
|
||||
var refusal = DesignsRefused((present, unit) => new[]
|
||||
{
|
||||
ZoneOf(BarePack(unit(Key("hull:b"), Key("mod-hull:z"))), BarePack(unit(Key("mod-hull:z"), Key("hull:b")))),
|
||||
ZoneOf(BarePack(unit(Key("hull:B"), Key("mod-hull:m"))), BarePack(unit(Key("hull:a"), Key("hull:b"))))
|
||||
});
|
||||
|
||||
Assert.Equal("This run names designs the catalog no longer holds; missing mod ships: m, z; " +
|
||||
"missing other designs: hull:B, hull:a, hull:b. Reinstall them, or start a new game.", refusal);
|
||||
}
|
||||
|
||||
// A save with a null where the walk needs an item is malformed: refused with a message, never a NullReferenceException.
|
||||
[Fact]
|
||||
public void RequireDesignsRefusesAMalformedSaveInsteadOfThrowingNull()
|
||||
{
|
||||
var refusal = DesignsRefused((present, unit) =>
|
||||
{
|
||||
var pack = BarePack(unit(present, present));
|
||||
pack.Equipment = new[] { (new int2(0, 0), (EquippableItem) null) };
|
||||
return new[] { ZoneOf(pack) };
|
||||
});
|
||||
|
||||
Assert.Contains("save is malformed", refusal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARunNamingAMissingShippedDesignIsRefusedToo()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -230,6 +230,51 @@ public sealed class ShipAuthoringTests
|
|||
Assert.Contains("records must be stored under", Assert.Throws<InvalidOperationException>(() => ShipAuthoringStore.Load(wrongVisualKey)).Message);
|
||||
}
|
||||
|
||||
// A mount's anchor plays the mount, so a structural anchor (the hull collider, the shield, a muzzle...) is never one.
|
||||
// Interim ruling until an `equipment` role exists: a mount that is not a thruster or radiator is an articulation anchor.
|
||||
[Theory]
|
||||
[InlineData("map-icon", "map")]
|
||||
[InlineData("hull-collider", "collider")]
|
||||
[InlineData("shield", "shield")]
|
||||
[InlineData("tractor", "tractor")]
|
||||
[InlineData("weapon-muzzle", "muzzle")]
|
||||
[InlineData("thruster-emitter", "emitter")]
|
||||
[InlineData("radiator-mesh", "mesh")]
|
||||
public void AStructuralAnchorCannotBeAnEquipmentMount(string role, string anchorId)
|
||||
{
|
||||
var ship = Fixture();
|
||||
// The extra anchors exist so the role-count and parent checks pass; the Tool hardpoint then claims the anchor.
|
||||
ship.Visual.Anchors.Add(new ShipAnchor { Id = "muzzle", Role = "weapon-muzzle", ModelNodeId = "muzzle", ParentId = "thruster.port" });
|
||||
ship.Visual.Anchors.Add(new ShipAnchor { Id = "emitter", Role = "thruster-emitter", ModelNodeId = "emitter" });
|
||||
ship.Visual.Anchors.Add(new ShipAnchor { Id = "mesh", Role = "radiator-mesh", ModelNodeId = "mesh" });
|
||||
ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Tool, Position = new int2(0, 0), Shape = new Shape(), Transform = anchorId });
|
||||
|
||||
Assert.Contains($"Tool hardpoint {anchorId} needs a articulation anchor of that id", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
|
||||
ship = Fixture();
|
||||
ship.Visual.Anchors.Add(new ShipAnchor { Id = "tool", Role = "articulation", ModelNodeId = "tool" });
|
||||
ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Tool, Position = new int2(0, 0), Shape = new Shape(), Transform = "tool" });
|
||||
ship.Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryHardpointTypeHasOneAnchorRoleForItsMount()
|
||||
{
|
||||
foreach (HardpointType type in Enum.GetValues(typeof(HardpointType)))
|
||||
{
|
||||
var wanted = type == HardpointType.Thruster ? "thruster-emitter" : type == HardpointType.Radiator ? "radiator-mesh" : "articulation";
|
||||
foreach (var role in new[] { "articulation", "thruster-emitter", "radiator-mesh" })
|
||||
{
|
||||
var ship = Fixture();
|
||||
ship.Hull.Hardpoints.Clear();
|
||||
ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "thruster.port");
|
||||
ship.Hull.Hardpoints.Add(new HardpointData { Type = type, Position = new int2(0, 0), Shape = new Shape(), Transform = "mount" });
|
||||
ship.Visual.Anchors.Add(new ShipAnchor { Id = "mount", Role = role, ModelNodeId = "mount" });
|
||||
if (role == wanted) ship.Validate();
|
||||
else Assert.Contains($"{type} hardpoint mount needs a {wanted} anchor of that id", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static ShipParts Fixture()
|
||||
{
|
||||
var shape = new Shape(2, 2);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
Directory.CreateDirectory(Path.Combine(Mods, "notes"));
|
||||
var shippedBefore = Hash(Shipped);
|
||||
|
||||
Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods));
|
||||
Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length);
|
||||
|
||||
Assert.Equal(shippedBefore, Hash(Shipped));
|
||||
using var cache = AetheriaStores.Open(Derived);
|
||||
|
|
@ -114,7 +114,7 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
var json = JObject.Parse(Encoding.UTF8.GetString(glb, 20, (int)BitConverter.ToUInt32(glb, 12)));
|
||||
Assert.Equal(0, (int)json["scene"]);
|
||||
Assert.Equal(Enumerable.Range(0, ShipFixture.Nodes.Length), json["scenes"][0]["nodes"].Select(node => (int)node));
|
||||
Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods));
|
||||
Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length);
|
||||
|
||||
var export = Environment.GetEnvironmentVariable("AETHERIA_SHIP_FIXTURE_DIR");
|
||||
if (string.IsNullOrEmpty(export)) return;
|
||||
|
|
@ -127,42 +127,81 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
public void ComposeCountsEveryPackageAndReplacesAnExistingDerivedCatalog()
|
||||
{
|
||||
WritePackage("mod.skiff");
|
||||
Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods));
|
||||
Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length);
|
||||
WritePackage("mod.barge", hullName: "Barge");
|
||||
|
||||
Assert.Equal(2, ShipModCatalog.Compose(Shipped, Derived, Mods));
|
||||
Assert.Equal(2, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length);
|
||||
|
||||
using var cache = AetheriaStores.Open(Derived);
|
||||
Assert.Equal(new[] { "Barge", "Skiff", "Wasp" }, cache.GetAll<HullData>().Select(hull => hull.Name).OrderBy(name => name));
|
||||
AssertOnlyTheDerivedFile();
|
||||
}
|
||||
|
||||
// One bad package never stops the game: it is left out and named, and the rest are composed.
|
||||
[Fact]
|
||||
public void AFailedComposeLeavesThePreviousDerivedFileAndNoTemporaryFile()
|
||||
public void ABadPackageIsExcludedAndNamedWhileTheGoodOnesCompose()
|
||||
{
|
||||
WritePackage("mod.skiff");
|
||||
ShipModCatalog.Compose(Shipped, Derived, Mods);
|
||||
var before = File.ReadAllBytes(Derived);
|
||||
WritePackage("mod.good", hullName: "Good");
|
||||
WritePackage("mod.nomodel", hullName: "Nomodel", nodes: FixtureNodes.Where(node => node != "shield").ToArray());
|
||||
WritePackage("mod.nofile", hullName: "Nofile");
|
||||
File.Delete(Path.Combine(Mods, "mod.nofile", "skiff.glb"));
|
||||
Directory.CreateDirectory(Path.Combine(Mods, "mod.garbage"));
|
||||
File.WriteAllText(Path.Combine(Mods, "mod.garbage", "ship.cc"), "not a ship file");
|
||||
|
||||
WritePackage("mod.barge", hullName: "Wasp");
|
||||
Assert.Contains("collides with an existing catalog item", Assert.Throws<InvalidOperationException>(() =>
|
||||
ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
|
||||
Assert.Equal(before, File.ReadAllBytes(Derived));
|
||||
var composition = ShipModCatalog.Compose(Shipped, Derived, Mods);
|
||||
|
||||
Assert.Equal(new[] { "mod.good" }, composition.Included);
|
||||
Assert.Equal(new[] { "mod.garbage", "mod.nofile", "mod.nomodel" }, composition.Excluded.Select(exclusion => exclusion.Package));
|
||||
Assert.Contains("model has no node with aetheria.id=shield", composition.Excluded.Single(e => e.Package == "mod.nomodel").Reason);
|
||||
Assert.Contains("must name an existing GLB", composition.Excluded.Single(e => e.Package == "mod.nofile").Reason);
|
||||
Assert.Contains("mod.nofile: ", composition.Excluded.Single(e => e.Package == "mod.nofile").ToString());
|
||||
using (var cache = AetheriaStores.Open(Derived))
|
||||
Assert.Equal(new[] { "Good", "Wasp" }, cache.GetAll<HullData>().Select(hull => hull.Name).OrderBy(name => name));
|
||||
AssertOnlyTheDerivedFile();
|
||||
}
|
||||
|
||||
Directory.Delete(Path.Combine(Mods, "mod.barge"), true);
|
||||
WritePackage("mod.barge", hullName: "Barge", nodes: FixtureNodes.Where(node => node != "shield").ToArray());
|
||||
Assert.Contains("model has no node with aetheria.id=shield", Assert.Throws<InvalidOperationException>(() =>
|
||||
ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
|
||||
Assert.Equal(before, File.ReadAllBytes(Derived));
|
||||
// A name collision leaves out every package that claims the name; the verdict does not depend on directory order.
|
||||
[Fact]
|
||||
public void ACollisionExcludesEveryPackageInvolvedAndNoneOfTheRest()
|
||||
{
|
||||
WritePackage("mod.a", hullName: "Skiff");
|
||||
WritePackage("mod.b", hullName: "Skiff");
|
||||
WritePackage("mod.c", hullName: "Skiff");
|
||||
WritePackage("mod.d", hullName: "Wasp");
|
||||
WritePackage("mod.e", hullName: "Fine");
|
||||
|
||||
var composition = ShipModCatalog.Compose(Shipped, Derived, Mods);
|
||||
|
||||
Assert.Equal(new[] { "mod.e" }, composition.Included);
|
||||
Assert.Equal(new[] { "mod.a", "mod.b", "mod.c", "mod.d" }, composition.Excluded.Select(exclusion => exclusion.Package));
|
||||
Assert.Equal("hull name 'Skiff' is also claimed by mod.b, mod.c.", composition.Excluded[0].Reason);
|
||||
Assert.Equal("hull name 'Skiff' is also claimed by mod.a, mod.c.", composition.Excluded[1].Reason);
|
||||
Assert.Equal("hull name 'Wasp' collides with an existing catalog item.", composition.Excluded[3].Reason);
|
||||
using var cache = AetheriaStores.Open(Derived);
|
||||
Assert.Equal(new[] { "Fine", "Wasp" }, cache.GetAll<HullData>().Select(hull => hull.Name).OrderBy(name => name));
|
||||
}
|
||||
|
||||
// A package that fails for two reasons names both.
|
||||
[Fact]
|
||||
public void APackageThatCollidesTwiceNamesBothReasons()
|
||||
{
|
||||
WritePackage("mod.a", hullName: "Wasp");
|
||||
WritePackage("mod.b", hullName: "Wasp");
|
||||
|
||||
var composition = ShipModCatalog.Compose(Shipped, Derived, Mods);
|
||||
|
||||
Assert.Empty(composition.Included);
|
||||
Assert.Equal("hull name 'Wasp' collides with an existing catalog item.; hull name 'Wasp' is also claimed by mod.b.",
|
||||
composition.Excluded[0].Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("mod-hull:mod.skiff", false)]
|
||||
[InlineData("mod-ship:mod.skiff", true)]
|
||||
public void ComposeRefusesAModKeyThatAlreadyExistsInTheCatalog(string key, bool authoring)
|
||||
public void APackageWhoseKeyAlreadyExistsInTheCatalogIsExcluded(string key, bool authoring)
|
||||
{
|
||||
WritePackage("mod.skiff");
|
||||
WritePackage("mod.other", hullName: "Other");
|
||||
using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true))
|
||||
{
|
||||
if (authoring)
|
||||
|
|
@ -176,15 +215,17 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
cache.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
Assert.Contains("a mod key collides", Assert.Throws<InvalidOperationException>(() =>
|
||||
ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
|
||||
Assert.False(File.Exists(Derived));
|
||||
var composition = ShipModCatalog.Compose(Shipped, Derived, Mods);
|
||||
|
||||
Assert.Equal(new[] { "mod.other" }, composition.Included);
|
||||
Assert.Contains("a mod key collides", Assert.Single(composition.Excluded).Reason);
|
||||
Assert.Equal("mod.skiff", composition.Excluded[0].Package);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(HullData))]
|
||||
[InlineData(typeof(GearData))]
|
||||
public void ComposeRefusesAHullNamedLikeAShippedItem(Type shippedType)
|
||||
public void APackageWithAHullNamedLikeAShippedItemIsExcluded(Type shippedType)
|
||||
{
|
||||
using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true))
|
||||
{
|
||||
|
|
@ -196,24 +237,14 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
}
|
||||
WritePackage("mod.skiff");
|
||||
|
||||
Assert.Contains("hull name 'Skiff' collides with an existing catalog item", Assert.Throws<InvalidOperationException>(() =>
|
||||
ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
|
||||
Assert.False(File.Exists(Derived));
|
||||
var composition = ShipModCatalog.Compose(Shipped, Derived, Mods);
|
||||
|
||||
Assert.Empty(composition.Included);
|
||||
Assert.Equal("hull name 'Skiff' collides with an existing catalog item.", Assert.Single(composition.Excluded).Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeRefusesTwoModsThatShareAHullName()
|
||||
{
|
||||
WritePackage("mod.a", hullName: "Skiff");
|
||||
WritePackage("mod.b", hullName: "Skiff");
|
||||
|
||||
Assert.Contains("mod.b: hull name 'Skiff' collides", Assert.Throws<InvalidOperationException>(() =>
|
||||
ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
|
||||
Assert.False(File.Exists(Derived));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeRefusesAShipIdThatAShippedAuthoringRecordAlreadyUses()
|
||||
public void APackageWhoseShipIdAShippedAuthoringRecordAlreadyUsesIsExcluded()
|
||||
{
|
||||
using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true))
|
||||
{
|
||||
|
|
@ -222,12 +253,29 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
}
|
||||
WritePackage("mod.skiff", hullName: "Different");
|
||||
|
||||
Assert.Contains("ship ID collides with an existing catalog ship authoring record", Assert.Throws<InvalidOperationException>(() =>
|
||||
ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
|
||||
Assert.False(File.Exists(Derived));
|
||||
var composition = ShipModCatalog.Compose(Shipped, Derived, Mods);
|
||||
|
||||
Assert.Empty(composition.Included);
|
||||
Assert.Equal("ship ID collides with an existing catalog ship authoring record.", Assert.Single(composition.Excluded).Reason);
|
||||
}
|
||||
|
||||
// The game's boot: a bad package is named and the rest boot; with none left, the shipped catalog boots.
|
||||
[Fact]
|
||||
public void TheBootCatalogSurvivesBadPackages()
|
||||
{
|
||||
WritePackage("mod.good", hullName: "Good");
|
||||
WritePackage("mod.bad", hullName: "Bad", nodes: FixtureNodes.Where(node => node != "shield").ToArray());
|
||||
|
||||
var (catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods);
|
||||
Assert.Equal(Derived, catalog);
|
||||
Assert.Equal("mod.bad", Assert.Single(excluded).Package);
|
||||
|
||||
Directory.Delete(Path.Combine(Mods, "mod.good"), true);
|
||||
(catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods);
|
||||
Assert.Equal(Shipped, catalog);
|
||||
Assert.Equal("mod.bad", Assert.Single(excluded).Package);
|
||||
}
|
||||
|
||||
public void ComposeRefusesUnsafeInputsAndOutputs()
|
||||
{
|
||||
WritePackage("mod.skiff");
|
||||
|
|
@ -366,21 +414,21 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
[Fact]
|
||||
public void TheBootCatalogIsTheShippedOneUntilAModIsInstalled()
|
||||
{
|
||||
Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Path.Combine(_directory.Path, "absent")));
|
||||
Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Path.Combine(_directory.Path, "absent")).Catalog);
|
||||
Directory.CreateDirectory(Path.Combine(Mods, "notes"));
|
||||
Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods));
|
||||
Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog);
|
||||
Assert.False(File.Exists(Derived));
|
||||
|
||||
WritePackage("mod.skiff");
|
||||
Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods));
|
||||
Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog);
|
||||
using (var cache = AetheriaStores.Open(Derived))
|
||||
Assert.Contains("Skiff", cache.GetAll<HullData>().Select(hull => hull.Name));
|
||||
|
||||
// Recomposed on every call: a mod removed since the last boot is gone from the file the game opens next.
|
||||
Directory.Delete(Path.Combine(Mods, "mod.skiff"), true);
|
||||
Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods));
|
||||
Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog);
|
||||
WritePackage("mod.barge", hullName: "Barge");
|
||||
Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods));
|
||||
Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog);
|
||||
using (var cache = AetheriaStores.Open(Derived))
|
||||
Assert.Equal(new[] { "Barge", "Wasp" }, cache.GetAll<HullData>().Select(hull => hull.Name).OrderBy(name => name));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,13 +59,26 @@ public sealed class ShipModPlanTests
|
|||
// A thruster-emitter anchor on a weapon mount, and a radiator-mesh anchor that is no mount at all.
|
||||
var ship = Rig();
|
||||
ship.Visual.Anchors.Single(anchor => anchor.Id == "gun").Role = "thruster-emitter";
|
||||
Assert.Contains("thruster-emitter anchor gun must be a Thruster hardpoint's mount", Refusal(ship));
|
||||
Assert.Contains("Energy hardpoint gun needs a articulation anchor of that id", Refusal(ship));
|
||||
|
||||
ship = Rig();
|
||||
ship.Visual.Anchors.Add(new ShipAnchor { Id = "loose", Role = "radiator-mesh", ModelNodeId = "loose" });
|
||||
Assert.Contains("radiator-mesh anchor loose must be a Radiator hardpoint's mount", Refusal(ship));
|
||||
}
|
||||
|
||||
// Equal Order falls back to the id, compared ordinally, whatever order the anchors were authored in.
|
||||
[Fact]
|
||||
public void MuzzlesOfEqualOrderAreTiedByIdOrdinally()
|
||||
{
|
||||
var ship = Rig();
|
||||
ship.Visual.Anchors.RemoveAll(anchor => anchor.Role == "weapon-muzzle");
|
||||
foreach (var id in new[] { "gun.c", "gun.a", "gun.B", "gun.b" })
|
||||
ship.Visual.Anchors.Add(new ShipAnchor { Id = id, Role = "weapon-muzzle", ModelNodeId = id, ParentId = "gun", Order = 2 });
|
||||
ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.z", Role = "weapon-muzzle", ModelNodeId = "gun.z", ParentId = "gun", Order = 1 });
|
||||
|
||||
Assert.Equal(new[] { "gun.z", "gun.B", "gun.a", "gun.b", "gun.c" }, Assert.Single(Plan(ship).Weapons).Muzzles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MuzzlesBelongToWeaponMountsAndEveryWeaponHasOne()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@ public static class ShipAuthoringCommands
|
|||
{
|
||||
if (args[0] == "compose" && args.Length == 4)
|
||||
{
|
||||
var count = ShipModCatalog.Compose(args[1], args[2], args[3]);
|
||||
Console.WriteLine($"Composed {count} mod ships into {Path.GetFullPath(args[2])}");
|
||||
return 0;
|
||||
var composition = ShipModCatalog.Compose(args[1], args[2], args[3]);
|
||||
Console.WriteLine($"Composed {composition.Included.Length} mod ships into {Path.GetFullPath(args[2])}");
|
||||
foreach (var exclusion in composition.Excluded) Console.Error.WriteLine($"Excluded {exclusion}");
|
||||
return composition.Excluded.Length == 0 ? 0 : 1;
|
||||
}
|
||||
if (args[0] == "inspect" && args.Length == 2)
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue