818 lines
43 KiB
C#
818 lines
43 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using GameCult.Caching;
|
|
using Newtonsoft.Json.Linq;
|
|
using Xunit;
|
|
|
|
// Compose, ReadPackage and ReadNodeIds: the derived catalog a game would open, and the GLB node ids its anchors bind to.
|
|
public sealed class ShipModCatalogTests : IDisposable
|
|
{
|
|
private readonly TempDirectory _directory = new TempDirectory();
|
|
private string Shipped => Path.Combine(_directory.Path, "Aetheria.cc");
|
|
private string Derived => Path.Combine(_directory.Path, "Derived", "Aetheria.modded.cc");
|
|
private string Mods => Path.Combine(_directory.Path, "Mods");
|
|
private static readonly string[] FixtureNodes = ShipFixture.Nodes;
|
|
|
|
public ShipModCatalogTests()
|
|
{
|
|
Directory.CreateDirectory(Mods);
|
|
using var cache = AetheriaStores.Open(Shipped, catalogWritable: true);
|
|
// A populated catalog must hold the VerseGrammar global.
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
cache.Upsert(new HullData { Name = "Wasp", Shape = ShipAuthoringTests.Fixture().Hull.Shape });
|
|
cache.Upsert(new GearData { Name = "Widget" });
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
public void Dispose() => _directory.Dispose();
|
|
|
|
[Fact]
|
|
public void ComposeDerivesACatalogAndLeavesTheShippedOneUntouched()
|
|
{
|
|
WritePackage("mod.skiff");
|
|
Directory.CreateDirectory(Path.Combine(Mods, "notes"));
|
|
var shippedBefore = Hash(Shipped);
|
|
|
|
Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length);
|
|
|
|
Assert.Equal(shippedBefore, Hash(Shipped));
|
|
using var cache = AetheriaStores.Open(Derived);
|
|
Assert.Contains("Wasp", cache.GetAll<HullData>().Select(hull => hull.Name));
|
|
Assert.Contains("Widget", cache.GetAll<GearData>().Select(gear => gear.Name));
|
|
var records = cache.AllStoredDocuments.ToDictionary(record => record.Key.Value, record => record.Document);
|
|
Assert.Equal("Skiff", Assert.IsType<HullData>(records[ShipModCatalog.HullKey("mod.skiff").Value]).Name);
|
|
Assert.Equal("mod.skiff", Assert.IsType<ShipAuthoring>(records[ShipModCatalog.AuthoringKey("mod.skiff").Value]).Id);
|
|
AssertOnlyTheDerivedFile();
|
|
}
|
|
|
|
// A package's station kind is checked per package: a bad one is left out, named with its reason, and the derived catalog
|
|
// still opens. The shipped catalog's own check is unchanged.
|
|
[Fact]
|
|
public void ComposeExcludesPackagesWithBadStationKinds()
|
|
{
|
|
using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true))
|
|
{
|
|
cache.Upsert(new StationKindData { Name = "Hub" });
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
string kindKey;
|
|
using (var cache = AetheriaStores.Open(Shipped)) kindKey = cache.RefOf(cache.GetAll<StationKindData>().Single()).Key.Value;
|
|
var held = new CultRecordRef<StationKindData>(new CultRecordKey(kindKey));
|
|
WritePackage("mod.kindless", "Kindless", tweak: p => p.Hull.HullType = HullType.Station);
|
|
WritePackage("mod.unknown", "Unknown", tweak: p => { p.Hull.HullType = HullType.Station; p.Hull.Kind = new CultRecordRef<StationKindData>(new CultRecordKey("station-kind:nowhere")); });
|
|
WritePackage("mod.shipkind", "ShipKind", tweak: p => p.Hull.Kind = held);
|
|
WritePackage("mod.good", "GoodStation", tweak: p => { p.Hull.HullType = HullType.Station; p.Hull.Kind = held; });
|
|
|
|
var composition = ShipModCatalog.Compose(Shipped, Derived, Mods);
|
|
|
|
Assert.Equal(new[] { "mod.good" }, composition.Included);
|
|
Assert.Equal(new[] { "mod.kindless", "mod.shipkind", "mod.unknown" }, composition.Excluded.Select(e => e.Package).OrderBy(n => n, StringComparer.Ordinal));
|
|
Assert.Contains("no Kind", composition.Excluded.Single(e => e.Package == "mod.kindless").Reason);
|
|
Assert.Contains("names no station kind", composition.Excluded.Single(e => e.Package == "mod.unknown").Reason);
|
|
Assert.Contains("only a station hull has one", composition.Excluded.Single(e => e.Package == "mod.shipkind").Reason);
|
|
using var derived = AetheriaStores.Open(Derived);
|
|
Assert.Contains("GoodStation", derived.GetAll<HullData>().Select(hull => hull.Name));
|
|
Assert.DoesNotContain("Kindless", derived.GetAll<HullData>().Select(hull => hull.Name));
|
|
}
|
|
|
|
// Correction 2 of the S0 map: the derived catalog held each mod hull twice, once as a HullData and once inside the
|
|
// ship's authoring record. The hull is now one record, and the visual holds no hull.
|
|
[Fact]
|
|
public void ComposedCatalogHoldsOneHullPerModShip()
|
|
{
|
|
WritePackage("mod.skiff", hullName: "Skiffhull");
|
|
ShipModCatalog.Compose(Shipped, Derived, Mods);
|
|
|
|
using var cache = AetheriaStores.Open(Derived);
|
|
Assert.Equal(1, cache.GetAll<HullData>().Count(hull => hull.Name == "Skiffhull"));
|
|
// The hull's name is stored exactly once in the derived file, so no second copy of the hull hides in another record.
|
|
Assert.Equal(1, Occurrences(File.ReadAllBytes(Derived), Encoding.UTF8.GetBytes("Skiffhull")));
|
|
var visual = cache.GetAll<ShipAuthoring>().Single();
|
|
Assert.Equal("mod.skiff", visual.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComposeRefusesAShippedHullThatNamesTwoBodies()
|
|
{
|
|
WritePackage("mod.skiff");
|
|
using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true))
|
|
{
|
|
cache.Upsert(new HullData
|
|
{
|
|
Name = "Chimera",
|
|
Shape = ShipAuthoringTests.Fixture().Hull.Shape,
|
|
Prefab = "Djinni",
|
|
Visual = new CultRecordRef<ShipAuthoring>(ShipModCatalog.AuthoringKey("mod.skiff"))
|
|
});
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
Assert.Contains("Chimera: a hull names one body",
|
|
Assert.Throws<InvalidOperationException>(() => ShipModCatalog.Compose(Shipped, Derived, Mods)).Message);
|
|
Assert.False(File.Exists(Derived));
|
|
}
|
|
|
|
[Fact]
|
|
public void ComposeCopiesTheModStoresRecordsUnchangedAndTheHullNamesItsVisualByRef()
|
|
{
|
|
WritePackage("mod.skiff");
|
|
var (modHull, modVisual, _) = ShipAuthoringStore.Read(Path.Combine(Mods, "mod.skiff", "ship.cc"));
|
|
ShipModCatalog.Compose(Shipped, Derived, Mods);
|
|
|
|
using var cache = AetheriaStores.Open(Derived);
|
|
var hull = cache.Get<HullData>(ShipModCatalog.HullKey("mod.skiff"));
|
|
var visual = cache.Get<ShipAuthoring>(ShipModCatalog.AuthoringKey("mod.skiff"));
|
|
Assert.Same(visual, cache.Get(hull.Visual));
|
|
var options = GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.OptionsFor(typeof(ShipAuthoring).Assembly);
|
|
Assert.Equal(MessagePack.MessagePackSerializer.Serialize(modHull, options), MessagePack.MessagePackSerializer.Serialize(hull, options));
|
|
Assert.Equal(MessagePack.MessagePackSerializer.Serialize(modVisual, options), MessagePack.MessagePackSerializer.Serialize(visual, options));
|
|
}
|
|
|
|
// The package the Unity smoke (ShipModPreview.Smoke) loads. Set AETHERIA_SHIP_FIXTURE_DIR to have this run leave it
|
|
// there: `-shipModPath $AETHERIA_SHIP_FIXTURE_DIR/mod.skiff/ship.cc`.
|
|
[Fact]
|
|
public void TheFixturePackageIsCompleteAndItsGlbCarriesAScene()
|
|
{
|
|
var directory = ShipFixture.WritePackage(Mods, "mod.skiff");
|
|
|
|
var package = ShipModCatalog.ReadPackage(Path.Combine(directory, "ship.cc"));
|
|
Assert.Equal(ShipFixture.Nodes.Length, package.NodeIndices.Count);
|
|
var glb = File.ReadAllBytes(package.ModelPath);
|
|
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).Included.Length);
|
|
|
|
var export = Environment.GetEnvironmentVariable("AETHERIA_SHIP_FIXTURE_DIR");
|
|
if (string.IsNullOrEmpty(export)) return;
|
|
var target = Path.Combine(export, "mod.skiff");
|
|
Directory.CreateDirectory(target);
|
|
foreach (var name in new[] { "ship.cc", "skiff.glb" }) File.Copy(Path.Combine(directory, name), Path.Combine(target, name), true);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheFixtureGlbCarriesARealMeshOnEveryAnchorTheAssemblerReadsOneFrom()
|
|
{
|
|
var package = ShipModCatalog.ReadPackage(Path.Combine(ShipFixture.WritePackage(Mods, "mod.skiff"), "ship.cc"));
|
|
var glb = File.ReadAllBytes(package.ModelPath);
|
|
var jsonLength = (int)BitConverter.ToUInt32(glb, 12);
|
|
var json = JObject.Parse(Encoding.UTF8.GetString(glb, 20, jsonLength));
|
|
var binLength = (int)BitConverter.ToUInt32(glb, 20 + jsonLength);
|
|
Assert.Equal(0x004E4942u, BitConverter.ToUInt32(glb, 24 + jsonLength));
|
|
Assert.Equal(glb.Length, 28 + jsonLength + binLength);
|
|
Assert.Equal((int)json["buffers"][0]["byteLength"], ShipFixture.MeshBytes().Length);
|
|
Assert.True(binLength >= ShipFixture.MeshBytes().Length);
|
|
|
|
var roles = new[] { "map-icon", "hull-collider", "thruster-emitter", "radiator-mesh" };
|
|
var needed = package.Visual.Anchors.Where(anchor => roles.Contains(anchor.Role)).ToArray();
|
|
Assert.Contains(needed, anchor => anchor.Role == "thruster-emitter");
|
|
foreach (var anchor in needed)
|
|
{
|
|
var node = json["nodes"][(int)package.NodeIndices[anchor.ModelNodeId]];
|
|
var mesh = json["meshes"][(int)node["mesh"]];
|
|
var accessor = json["accessors"][(int)mesh["primitives"][0]["attributes"]["POSITION"]];
|
|
Assert.True((int)accessor["count"] >= 4, anchor.Id);
|
|
}
|
|
Assert.Contains(package.Visual.Anchors, anchor => anchor.Role == "weapon-muzzle" && anchor.ParentId == "gun");
|
|
Assert.Contains(package.Hull.Hardpoints, hardpoint => hardpoint.Type == HardpointType.Energy && hardpoint.Transform == "gun");
|
|
}
|
|
|
|
[Fact]
|
|
public void ComposeCountsEveryPackageAndReplacesAnExistingDerivedCatalog()
|
|
{
|
|
WritePackage("mod.skiff");
|
|
Assert.Equal(1, ShipModCatalog.Compose(Shipped, Derived, Mods).Included.Length);
|
|
WritePackage("mod.barge", hullName: "Barge");
|
|
|
|
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 ABadPackageIsExcludedAndNamedWhileTheGoodOnesCompose()
|
|
{
|
|
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");
|
|
|
|
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();
|
|
}
|
|
|
|
// 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 APackageWhoseKeyAlreadyExistsInTheCatalogIsExcluded(string key, bool authoring)
|
|
{
|
|
WritePackage("mod.skiff");
|
|
WritePackage("mod.other", hullName: "Other");
|
|
using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true))
|
|
{
|
|
if (authoring)
|
|
{
|
|
var stranger = ShipAuthoringTests.Fixture().WithId("stranger").Visual;
|
|
cache.UpsertAsync(typeof(ShipAuthoring), stranger, new CultRecordKey(key)).GetAwaiter().GetResult();
|
|
}
|
|
else
|
|
cache.UpsertAsync(typeof(HullData), new HullData { Name = "Stranger", Shape = ShipAuthoringTests.Fixture().Hull.Shape },
|
|
new CultRecordKey(key)).GetAwaiter().GetResult();
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
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 APackageWithAHullNamedLikeAShippedItemIsExcluded(Type shippedType)
|
|
{
|
|
using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true))
|
|
{
|
|
if (shippedType == typeof(HullData))
|
|
cache.Upsert(new HullData { Name = "Skiff", Shape = ShipAuthoringTests.Fixture().Hull.Shape });
|
|
else
|
|
cache.Upsert(new GearData { Name = "Skiff" });
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
WritePackage("mod.skiff");
|
|
|
|
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 APackageWhoseShipIdAShippedAuthoringRecordAlreadyUsesIsExcluded()
|
|
{
|
|
using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true))
|
|
{
|
|
cache.UpsertAsync(typeof(ShipAuthoring), ShipAuthoringTests.Fixture().Visual, new CultRecordKey("shipped:skiff")).GetAwaiter().GetResult();
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
WritePackage("mod.skiff", hullName: "Different");
|
|
|
|
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 loader and the LFS rule (GameData/Mods/**/*.glb) agree: only a lowercase .glb is a model.
|
|
[Fact]
|
|
public void AModelWithAnUppercaseExtensionIsRefused()
|
|
{
|
|
WritePackage("mod.skiff", modelAsset: "skiff.GLB");
|
|
|
|
var excluded = Assert.Single(ShipModCatalog.Compose(Shipped, Derived, Mods).Excluded);
|
|
|
|
Assert.Equal("mod.skiff", excluded.Package);
|
|
Assert.Contains("GLB", 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");
|
|
Assert.Contains("Shipped catalog is missing", Assert.Throws<FileNotFoundException>(() =>
|
|
ShipModCatalog.Compose(Path.Combine(_directory.Path, "absent.cc"), Derived, Mods)).Message);
|
|
Assert.Contains("Mod root is missing", Assert.Throws<DirectoryNotFoundException>(() =>
|
|
ShipModCatalog.Compose(Shipped, Derived, Path.Combine(_directory.Path, "NoMods"))).Message);
|
|
var before = Hash(Shipped);
|
|
Assert.Contains("cannot replace the shipped catalog", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.Compose(Shipped, Shipped, Mods)).Message);
|
|
Assert.Contains("cannot replace the shipped catalog", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.Compose(Shipped, Shipped.ToUpperInvariant().Replace(_directory.Path.ToUpperInvariant(), _directory.Path), Mods)).Message);
|
|
Assert.Contains("cannot replace a mod source or asset", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.Compose(Shipped, Path.Combine(Mods, "mod.skiff", "out.cc"), Mods)).Message);
|
|
Assert.Equal(before, Hash(Shipped));
|
|
Assert.False(File.Exists(Path.Combine(Mods, "mod.skiff", "out.cc")));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadPackageBindsTheShipToItsDirectoryAndGlb()
|
|
{
|
|
WritePackage("mod.skiff");
|
|
var package = ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"));
|
|
Assert.Equal("mod.skiff", package.Visual.Id);
|
|
Assert.Equal(Path.GetFullPath(Path.Combine(Mods, "mod.skiff", "skiff.glb")), package.ModelPath);
|
|
Assert.Equal(new Dictionary<string, uint> { ["map"] = 0, ["collider"] = 1, ["shield"] = 2, ["tractor"] = 3, ["thruster-port"] = 4, ["gun"] = 5, ["gun-muzzle"] = 6 }, package.NodeIndices);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadPackageRefusesADirectoryNameThatIsNotTheShipId()
|
|
{
|
|
WritePackage("mod.skiff");
|
|
Directory.Move(Path.Combine(Mods, "mod.skiff"), Path.Combine(Mods, "renamed"));
|
|
Assert.Contains("ship ID must match its package directory name", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.ReadPackage(Path.Combine(Mods, "renamed", "ship.cc"))).Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadPackageRefusesAMissingOrWronglyNamedModel()
|
|
{
|
|
WritePackage("mod.skiff");
|
|
File.Delete(Path.Combine(Mods, "mod.skiff", "skiff.glb"));
|
|
Assert.Contains("must name an existing GLB inside its package", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message);
|
|
|
|
Directory.Delete(Path.Combine(Mods, "mod.skiff"), true);
|
|
WritePackage("mod.skiff", modelAsset: "skiff.bin");
|
|
Assert.Contains("must name an existing GLB inside its package", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadPackageAppliesTheCatalogsItemValidationToTheHull()
|
|
{
|
|
WritePackage("mod.skiff", tweak: ship => ship.Hull.MinimumTemperature = ship.Hull.MaximumTemperature);
|
|
Assert.Contains("zero-span range", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadPackageRefusesAnAnchorWhoseNodeIsNotInTheModel()
|
|
{
|
|
WritePackage("mod.skiff", nodes: FixtureNodes.Where(node => node != "thruster-port").ToArray());
|
|
Assert.Contains("model has no node with aetheria.id=thruster-port for anchor thruster.port", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message);
|
|
}
|
|
|
|
// Ruling thrusters-radiators-are-meshes: a thruster's mesh is its emission surface and a radiator's is what glows, so
|
|
// either on an empty node is refused before compose, as are the map icon and hull collider Assemble reads a mesh from,
|
|
// and a shield whose node does not carry three positive radii; a weapon mount (and shield, tractor) may be an empty.
|
|
[Fact]
|
|
public void AMeshRoleAnchorOnAnEmptyNodeIsRefused()
|
|
{
|
|
var withoutThrusterMesh = ShipFixture.MeshNodes.Where(node => node != "thruster-port").ToArray();
|
|
WritePackage("mod.skiff", meshNodes: withoutThrusterMesh);
|
|
Assert.Contains("thruster-emitter anchor thruster.port needs a mesh, but model node thruster-port has none", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message);
|
|
|
|
Directory.Delete(Path.Combine(Mods, "mod.skiff"), true);
|
|
void AsRadiator(ShipParts ship)
|
|
{
|
|
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "thruster.port").Type = HardpointType.Radiator;
|
|
ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Role = "radiator-mesh";
|
|
}
|
|
WritePackage("mod.skiff", tweak: AsRadiator, meshNodes: withoutThrusterMesh);
|
|
Assert.Contains("radiator-mesh anchor thruster.port needs a mesh", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message);
|
|
|
|
foreach (var (dropped, message) in new[] { ("map", "map-icon anchor map needs a mesh"), ("collider", "hull-collider anchor collider needs a mesh") })
|
|
{
|
|
Directory.Delete(Path.Combine(Mods, "mod.skiff"), true);
|
|
WritePackage("mod.skiff", meshNodes: ShipFixture.MeshNodes.Where(node => node != dropped).ToArray());
|
|
Assert.Contains(message, Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message);
|
|
}
|
|
foreach (var shieldNode in new[] { @"{""name"":""shield"",""scale"":[1,0,1]", @"{""name"":""shield"",""scale"":[1,1,-1]", @"{""name"":""shield"",""matrix"":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]" })
|
|
{
|
|
Directory.Delete(Path.Combine(Mods, "mod.skiff"), true);
|
|
WritePackage("mod.skiff");
|
|
var glb = Path.Combine(Mods, "mod.skiff", "skiff.glb");
|
|
var json = ShipFixture.GlbJson(FixtureNodes).Replace(@"{""name"":""shield""", shieldNode);
|
|
File.WriteAllBytes(glb, ShipFixture.Glb(json, ShipFixture.MeshBytes()));
|
|
Assert.Contains("shield anchor shield needs three positive scale radii", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message);
|
|
}
|
|
|
|
// The same radiator on a mesh node, and the weapon mount, shield and tractor on empty nodes, are accepted.
|
|
Directory.Delete(Path.Combine(Mods, "mod.skiff"), true);
|
|
WritePackage("mod.skiff", tweak: AsRadiator);
|
|
var nodes = ShipModCatalog.ReadNodeIds(Path.Combine(Mods, "mod.skiff", "skiff.glb"));
|
|
Assert.False(nodes["gun"].HasMesh || nodes["shield"].HasMesh || nodes["tractor"].HasMesh);
|
|
Assert.Equal("mod.skiff", ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc")).Visual.Id);
|
|
}
|
|
|
|
// AetherDb's validate judges the GLB once it exists, so Blender's Package sees what Compose would refuse.
|
|
[Fact]
|
|
public void ValidateCommandReadsTheGlbWhenPresent()
|
|
{
|
|
WritePackage("mod.skiff", nodes: FixtureNodes.Where(node => node != "thruster-port").ToArray());
|
|
var path = Path.Combine(Mods, "mod.skiff", "ship.cc");
|
|
var (code, output, error) = Run("validate", path);
|
|
Assert.Equal(1, code);
|
|
Assert.Contains("model has no node with aetheria.id=thruster-port for anchor thruster.port", error);
|
|
|
|
File.Delete(Path.Combine(Mods, "mod.skiff", "skiff.glb"));
|
|
(code, output, error) = Run("validate", path);
|
|
Assert.Equal(0, code);
|
|
Assert.Contains("records only", output);
|
|
|
|
WritePackage("mod.good");
|
|
(code, output, _) = Run("validate", Path.Combine(Mods, "mod.good", "ship.cc"));
|
|
Assert.Equal(0, code);
|
|
Assert.Contains("records and model", output);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("con"), InlineData("nul.x"), InlineData("abc.")]
|
|
public void CreateRefusesABadIdBeforeTouchingTheDisk(string id)
|
|
{
|
|
var target = Path.Combine(_directory.Path, "GameData", "Mods", id, "ship.cc");
|
|
// The display name is itself a valid ship ID, so a rule applied to it instead of the ID would not refuse.
|
|
var (code, output, error) = Run("create", target, id, "probe", "--like", "Djinni");
|
|
Assert.Equal(1, code);
|
|
Assert.Contains("Ship ID", error);
|
|
Assert.False(Directory.Exists(Path.Combine(_directory.Path, "GameData")));
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateJudgesTheIdAndTakesAnyDisplayName()
|
|
{
|
|
var target = Path.Combine(_directory.Path, "GameData", "Mods", "probe.a", "ship.cc");
|
|
var (code, output, error) = Run("create", target, "probe.a", "Probe A");
|
|
Assert.Equal(0, code);
|
|
Assert.True(File.Exists(target), error);
|
|
}
|
|
|
|
[Fact]
|
|
public void CentreCommandPrintsShapeCenterOfMass()
|
|
{
|
|
// An L: its centre of mass (1.2, 0.2) is not the bounds centre (1.5, 0.5).
|
|
var shape = new Shape(4, 2);
|
|
foreach (var (x, y) in new[] { (0, 0), (1, 0), (2, 0), (3, 0), (0, 1) }) shape.Cells[x, y] = true;
|
|
var target = Path.Combine(_directory.Path, "GameData", "Mods", "probe.l", "ship.cc");
|
|
Assert.Equal(0, Run("create", target, "probe.l", "Probe L").Code);
|
|
var (hull, ship, _) = ShipAuthoringStore.Load(target);
|
|
hull.Shape = shape;
|
|
using (var cache = ShipAuthoringStore.Open(target, writable: true))
|
|
{
|
|
ShipAuthoringStore.Write(cache, hull, ship);
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
var (code, output, error) = Run("centre", target);
|
|
Assert.Equal(0, code);
|
|
Assert.Equal("1.2 0.2", output.Trim());
|
|
Assert.Equal("", error);
|
|
}
|
|
|
|
private static (int Code, string Output, string Error) Run(params string[] args)
|
|
{
|
|
var (output, error) = (new StringWriter(), new StringWriter());
|
|
var (previousOut, previousError) = (Console.Out, Console.Error);
|
|
Console.SetOut(output);
|
|
Console.SetError(error);
|
|
try { return (ShipAuthoringCommands.Run(args), output.ToString(), error.ToString()); }
|
|
finally { Console.SetOut(previousOut); Console.SetError(previousError); }
|
|
}
|
|
|
|
[Fact]
|
|
public void NodeIdsMapToTheirGlbNodeIndexSkippingNodesWithoutOne()
|
|
{
|
|
var path = Write("nodes.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[
|
|
{""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""mesh"":0,""extras"":{""aetheria.id"":""b""}}]}"));
|
|
Assert.Equal(new Dictionary<string, (uint, bool, int, bool)> { ["a"] = (1, false, -1, true), ["b"] = (4, true, -1, true) }, ShipModCatalog.ReadNodeIds(path));
|
|
}
|
|
|
|
[Fact]
|
|
public void PositiveScaleReadsAnAbsentScaleAsPositiveAndANegativeComponentOrAMatrixAsNot()
|
|
{
|
|
var path = Write("scales.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[
|
|
{""extras"":{""aetheria.id"":""none""}},{""scale"":[2,3,4],""extras"":{""aetheria.id"":""pos""}},{""scale"":[1,-1,1],""extras"":{""aetheria.id"":""neg""}},
|
|
{""scale"":[1,1,0],""extras"":{""aetheria.id"":""zero""}},{""matrix"":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],""extras"":{""aetheria.id"":""matrix""}}]}"));
|
|
var nodes = ShipModCatalog.ReadNodeIds(path);
|
|
Assert.Equal(new[] { "none", "pos" }, nodes.Where(node => node.Value.PositiveScale).Select(node => node.Key).OrderBy(key => key));
|
|
Assert.Equal(new[] { "matrix", "neg", "zero" }, nodes.Where(node => !node.Value.PositiveScale).Select(node => node.Key).OrderBy(key => key));
|
|
}
|
|
|
|
[Fact]
|
|
public void AGlbWithoutNodesHasNoIds() =>
|
|
Assert.Empty(ShipModCatalog.ReadNodeIds(Write("empty.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""}}"))));
|
|
|
|
[Fact]
|
|
public void ADuplicateNodeIdIsRefused() =>
|
|
Assert.Contains("duplicate GLB node aetheria.id=a", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.ReadNodeIds(Write("dup.glb", ShipFixture.Glb(@"{""nodes"":[{""extras"":{""aetheria.id"":""a""}},{""extras"":{""aetheria.id"":""a""}}]}")))).Message);
|
|
|
|
[Fact]
|
|
public void AMalformedGlbHeaderIsRefused()
|
|
{
|
|
var good = ShipFixture.Glb(@"{""nodes"":[]}");
|
|
void Refuse(string name, byte[] bytes, string message) =>
|
|
Assert.Contains(message, Assert.Throws<InvalidOperationException>(() => ShipModCatalog.ReadNodeIds(Write(name, bytes))).Message);
|
|
|
|
Refuse("short.glb", With(good.Take(19).ToArray(), 8, 19), "invalid GLB 2 header");
|
|
Refuse("magic.glb", With(good, 0, 0x46546C68), "invalid GLB 2 header");
|
|
Refuse("version.glb", With(good, 4, 1), "invalid GLB 2 header");
|
|
Refuse("longer.glb", With(good, 8, (uint)good.Length + 1), "invalid GLB 2 header");
|
|
Refuse("shorter.glb", With(good, 8, (uint)good.Length - 4), "invalid GLB 2 header");
|
|
Refuse("chunktype.glb", With(good, 16, 0x004E4942), "missing GLB JSON chunk");
|
|
Refuse("chunklength.glb", With(good, 12, (uint)good.Length), "missing GLB JSON chunk");
|
|
}
|
|
|
|
[Fact]
|
|
public void AGlbWhoseJsonChunkFillsTheFileIsAccepted()
|
|
{
|
|
var json = Encoding.UTF8.GetBytes(@"{""nodes"":[{""extras"":{""aetheria.id"":""a""}}]} ");
|
|
var bytes = new byte[20 + json.Length];
|
|
BitConverter.GetBytes(0x46546C67u).CopyTo(bytes, 0);
|
|
BitConverter.GetBytes(2u).CopyTo(bytes, 4);
|
|
BitConverter.GetBytes((uint)bytes.Length).CopyTo(bytes, 8);
|
|
BitConverter.GetBytes((uint)json.Length).CopyTo(bytes, 12);
|
|
BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16);
|
|
json.CopyTo(bytes, 20);
|
|
Assert.Equal(new Dictionary<string, uint> { ["a"] = 0 }, Indices(ShipModCatalog.ReadNodeIds(Write("full.glb", bytes))));
|
|
}
|
|
|
|
// A run store references a mod hull by its key, so the same packages must derive the same records on every launch,
|
|
// whatever order the directories were created in.
|
|
[Fact]
|
|
public void ComposeAtBootIsDeterministic()
|
|
{
|
|
var ids = new[] { "mod.a", "mod.b", "mod.c", "mod.d", "mod.e", "mod.f" };
|
|
foreach (var id in ids.Reverse()) WritePackage(id, hullName: "Hull " + id);
|
|
var firstMods = Mods;
|
|
var secondMods = Path.Combine(_directory.Path, "Mods2");
|
|
foreach (var id in ids) ShipFixture.WritePackage(secondMods, id, "Hull " + id);
|
|
var second = Path.Combine(_directory.Path, "Derived2", "Aetheria.modded.cc");
|
|
|
|
ShipModCatalog.Compose(Shipped, Derived, firstMods);
|
|
ShipModCatalog.Compose(Shipped, second, secondMods);
|
|
ShipModCatalog.Compose(Shipped, Path.Combine(_directory.Path, "Derived3", "Aetheria.modded.cc"), firstMods);
|
|
|
|
Assert.Equal(Records(Derived), Records(second));
|
|
Assert.Equal(Records(Derived), Records(Path.Combine(_directory.Path, "Derived3", "Aetheria.modded.cc")));
|
|
Assert.Contains(ShipModCatalog.HullKey("mod.c").Value, Records(Derived).Keys);
|
|
}
|
|
|
|
private string RemovedMods => Path.Combine(_directory.Path, "RemovedMods");
|
|
|
|
[Fact]
|
|
public void RemovePackageMovesItAsideAndTheNextBootDoesNotLoadIt()
|
|
{
|
|
WritePackage("mod.skiff");
|
|
var before = (Ship: Hash(Path.Combine(Mods, "mod.skiff", "ship.cc")), Glb: Hash(Path.Combine(Mods, "mod.skiff", "skiff.glb")));
|
|
Assert.Equal(Derived, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog);
|
|
|
|
var destination = ShipModCatalog.RemovePackage(Mods, "mod.skiff");
|
|
|
|
Assert.Equal(Path.Combine(RemovedMods, "mod.skiff"), destination);
|
|
Assert.False(Directory.Exists(Path.Combine(Mods, "mod.skiff")));
|
|
Assert.Equal(before, (Hash(Path.Combine(destination, "ship.cc")), Hash(Path.Combine(destination, "skiff.glb"))));
|
|
Assert.Equal(Shipped, ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods).Catalog);
|
|
}
|
|
|
|
[Fact]
|
|
public void RemovingAPackageTwiceKeepsBothCopies()
|
|
{
|
|
WritePackage("mod.skiff");
|
|
ShipModCatalog.RemovePackage(Mods, "mod.skiff");
|
|
File.WriteAllText(Path.Combine(RemovedMods, "mod.skiff", "marker.txt"), "first");
|
|
WritePackage("mod.skiff");
|
|
var second = ShipModCatalog.RemovePackage(Mods, "mod.skiff");
|
|
|
|
Assert.Equal(Path.Combine(RemovedMods, "mod.skiff (2)"), second);
|
|
Assert.Equal("first", File.ReadAllText(Path.Combine(RemovedMods, "mod.skiff", "marker.txt")));
|
|
Assert.False(File.Exists(Path.Combine(second, "marker.txt")));
|
|
}
|
|
|
|
// Hostile ids: the ones a filesystem rewrites or resolves elsewhere. A directory is created for each where the platform
|
|
// allows it, so a refusal that merely finds nothing is not enough. Nothing outside the intended package may move.
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(".")]
|
|
[InlineData("..")]
|
|
[InlineData("...")]
|
|
[InlineData(".. ")]
|
|
[InlineData("a.")]
|
|
[InlineData("a ")]
|
|
[InlineData("CON")]
|
|
[InlineData("con.txt")]
|
|
[InlineData("NUL")]
|
|
[InlineData("a:b")]
|
|
[InlineData("(all mods)")]
|
|
[InlineData("a/b")]
|
|
[InlineData("a\\b")]
|
|
[InlineData("../Mods")]
|
|
[InlineData("../Mods2")]
|
|
[InlineData("missing")]
|
|
public void RemovePackageRefusesWhatIsNotAPackageDirectory(string name)
|
|
{
|
|
WritePackage("mod.skiff");
|
|
File.WriteAllText(Path.Combine(_directory.Path, "outside.txt"), "x");
|
|
Directory.CreateDirectory(Path.Combine(_directory.Path, "Mods2"));
|
|
if (name != "missing") try { Directory.CreateDirectory(Path.Combine(Mods, name)); } catch (Exception) { }
|
|
var before = Directory.GetDirectories(Mods).Select(Path.GetFileName).OrderBy(n => n, StringComparer.Ordinal).ToArray();
|
|
|
|
Assert.ThrowsAny<Exception>(() => ShipModCatalog.RemovePackage(Mods, name));
|
|
|
|
Assert.True(Directory.Exists(Mods));
|
|
Assert.True(Directory.Exists(Path.Combine(Mods, "mod.skiff")));
|
|
Assert.True(Directory.Exists(Path.Combine(_directory.Path, "Mods2")));
|
|
Assert.True(File.Exists(Path.Combine(_directory.Path, "outside.txt")));
|
|
Assert.False(Directory.Exists(RemovedMods));
|
|
Assert.Equal(before, Directory.GetDirectories(Mods).Select(Path.GetFileName).OrderBy(n => n, StringComparer.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void RemovePackageRefusesAnAbsolutePathAndAnInPackageLinkOutsideMods()
|
|
{
|
|
WritePackage("mod.skiff");
|
|
var outside = Path.Combine(_directory.Path, "Outside");
|
|
Directory.CreateDirectory(outside);
|
|
File.WriteAllText(Path.Combine(outside, "keep.txt"), "x");
|
|
Assert.ThrowsAny<Exception>(() => ShipModCatalog.RemovePackage(Mods, outside));
|
|
Assert.True(File.Exists(Path.Combine(outside, "keep.txt")));
|
|
|
|
var linked = true;
|
|
try { Directory.CreateSymbolicLink(Path.Combine(Mods, "link"), outside); } catch (Exception) { linked = false; }
|
|
if (linked)
|
|
{
|
|
Assert.ThrowsAny<Exception>(() => ShipModCatalog.RemovePackage(Mods, "link"));
|
|
Assert.True(File.Exists(Path.Combine(outside, "keep.txt")));
|
|
Assert.True(Directory.Exists(Path.Combine(Mods, "link")));
|
|
}
|
|
Assert.False(Directory.Exists(RemovedMods));
|
|
Assert.True(Directory.Exists(Path.Combine(Mods, "mod.skiff")));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheBootCatalogIsTheShippedOneUntilAModIsInstalled()
|
|
{
|
|
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).Catalog);
|
|
Assert.False(File.Exists(Derived));
|
|
|
|
WritePackage("mod.skiff");
|
|
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).Catalog);
|
|
WritePackage("mod.barge", hullName: "Barge");
|
|
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));
|
|
}
|
|
|
|
// A failure of the composition itself is a mod problem too: the game boots on the shipped catalog and names it.
|
|
[Fact]
|
|
public void TheBootCatalogSurvivesAComposeThatFails()
|
|
{
|
|
WritePackage("mod.skiff");
|
|
var blocker = Path.Combine(_directory.Path, "blocker");
|
|
File.WriteAllText(blocker, "a file where the derived catalog's directory should be");
|
|
|
|
var (catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Path.Combine(blocker, "Aetheria.modded.cc"), Mods);
|
|
|
|
Assert.Equal(Shipped, catalog);
|
|
var exclusion = Assert.Single(excluded);
|
|
Assert.Equal("(all mods)", exclusion.Package);
|
|
Assert.False(string.IsNullOrWhiteSpace(exclusion.Reason));
|
|
// An invalid output (the shipped catalog itself) is refused the same way, with its reason.
|
|
(catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Shipped, Mods);
|
|
Assert.Equal(Shipped, catalog);
|
|
Assert.Contains("cannot replace the shipped catalog", Assert.Single(excluded).Reason);
|
|
}
|
|
|
|
// Gameplay reads a mod ship's records from the derived catalog; the package directory supplies only the GLB.
|
|
[Fact]
|
|
public void PackageOfTakesItsRecordsFromTheCatalogAndOnlyTheGlbFromTheMod()
|
|
{
|
|
WritePackage("mod.skiff");
|
|
ShipModCatalog.Compose(Shipped, Derived, Mods);
|
|
File.Delete(Path.Combine(Mods, "mod.skiff", "ship.cc"));
|
|
|
|
using var cache = AetheriaStores.Open(Derived);
|
|
var hull = cache.GetAll<HullData>().Single(candidate => candidate.Name == "Skiff");
|
|
var package = ShipModCatalog.PackageOf(cache, hull, Mods);
|
|
|
|
Assert.Same(hull, package.Hull);
|
|
Assert.Same(cache.Get(hull.Visual), package.Visual);
|
|
Assert.Equal(Path.Combine(Mods, "mod.skiff", "skiff.glb"), package.ModelPath);
|
|
Assert.Equal(ShipFixture.Nodes.Length, package.NodeIndices.Count);
|
|
|
|
// The shared validator judges the catalog's records too, not only a ship.cc.
|
|
hull.Prefab = "Djinni";
|
|
Assert.Contains("names both a Unity prefab and a visual record", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.PackageOf(cache, hull, Mods)).Message);
|
|
hull.Prefab = null;
|
|
|
|
// A hull whose visual the catalog does not hold, and a mod whose GLB is gone, are refused.
|
|
Assert.Contains("holds no visual record", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.PackageOf(cache, new HullData { Name = "Ghost" }, Mods)).Message);
|
|
File.Delete(package.ModelPath);
|
|
Assert.Contains("model asset must name an existing GLB", Assert.Throws<InvalidOperationException>(() =>
|
|
ShipModCatalog.PackageOf(cache, hull, Mods)).Message);
|
|
}
|
|
|
|
// Every stored record's key, schema and payload bytes. StoredAt is a write timestamp, not record content.
|
|
private static Dictionary<string, string> Records(string path) =>
|
|
GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records
|
|
.ToDictionary(record => record.Key, record => record.SchemaId + ":" + Convert.ToHexString(record.Payload));
|
|
|
|
private void AssertOnlyTheDerivedFile()
|
|
{
|
|
var names = Directory.GetFileSystemEntries(Path.GetDirectoryName(Derived)).Select(entry => Path.GetFileName(entry)).ToArray();
|
|
Assert.True(names.Length == 1 && names[0] == "Aetheria.modded.cc", string.Join(" | ", names));
|
|
}
|
|
|
|
private static byte[] With(byte[] bytes, int offset, uint value)
|
|
{
|
|
var copy = (byte[])bytes.Clone();
|
|
BitConverter.GetBytes(value).CopyTo(copy, offset);
|
|
return copy;
|
|
}
|
|
|
|
private string Write(string name, byte[] bytes)
|
|
{
|
|
var path = Path.Combine(_directory.Path, name);
|
|
File.WriteAllBytes(path, bytes);
|
|
return path;
|
|
}
|
|
|
|
private void WritePackage(string id, string hullName = "Skiff", string[] nodes = null, string modelAsset = "skiff.glb",
|
|
Action<ShipParts> tweak = null, string[] meshNodes = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak, meshNodes);
|
|
|
|
private static Dictionary<string, uint> Indices(Dictionary<string, (uint Index, bool HasMesh, int Parent, bool PositiveScale)> nodes) =>
|
|
nodes.ToDictionary(node => node.Key, node => node.Value.Index);
|
|
|
|
private static int Occurrences(byte[] haystack, byte[] needle)
|
|
{
|
|
var count = 0;
|
|
for (var start = 0; start <= haystack.Length - needle.Length; start++)
|
|
if (haystack.AsSpan(start, needle.Length).SequenceEqual(needle)) count++;
|
|
return count;
|
|
}
|
|
|
|
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
|
|
}
|