1164 lines
62 KiB
C#
1164 lines
62 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using CultMath;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using Xunit;
|
|
using Random = CultMath.Random;
|
|
|
|
// Loadouts are ship presets: item designs only, authored into the catalog, materialized all-or-nothing against it.
|
|
// The fixture hull is 3x3: InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior cell a cargo bay could use.
|
|
public sealed class LoadoutTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-loadout-" + Guid.NewGuid().ToString("N"));
|
|
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
|
private string Run => Path.Combine(_root, "run.cc");
|
|
private string Player => Path.Combine(_root, "player.cc");
|
|
|
|
private static readonly int2 HardpointCell = new int2(0, 0);
|
|
private static readonly int2 InteriorCell = new int2(1, 1);
|
|
|
|
public LoadoutTests()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
var maker = cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" });
|
|
|
|
var hullShape = new Shape(3, 3);
|
|
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
|
|
var hull = cache.Upsert(new HullData
|
|
{
|
|
Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Price = 100,
|
|
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = HardpointCell, Shape = new Shape() } }
|
|
});
|
|
var gear = cache.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10 });
|
|
var cargo = cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = new Shape(), Price = 5 });
|
|
var gun = cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, Behaviors = { new InstantWeaponData() } });
|
|
cache.Upsert(new GearData { Name = "Orphan", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1 });
|
|
// Cut 2 (docs/fire-control-cut.md, Q4): required equipment for any entity with a weapon
|
|
// (LoadoutGenerator.FillInterior). "Gun" carries a weapon behaviour and shares this catalog's one
|
|
// Sensors hardpoint type with "Lamp"/"Orphan", so any generated loadout that happens to draw Gun
|
|
// needs a targeting-system product available or FillInterior throws.
|
|
var targeting = cache.Upsert(new GearData { Name = "Scope", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new TargetingSystemData() } });
|
|
|
|
foreach (var (name, design) in new[] { ("Skiff by Maker", hull.Key), ("Lamp by Maker", gear.Key), ("Crate by Maker", cargo.Key), ("Gun by Maker", gun.Key), ("Scope by Maker", targeting.Key) })
|
|
cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef<CraftedItemData>(design), Manufacturer = maker });
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
public void Dispose() => Directory.Delete(_root, true);
|
|
|
|
// The game's shape in every build, the editor included: a read-only catalog with the run and player stores.
|
|
private CultCache Open() => AetheriaStores.Open(Catalog, Run, Player);
|
|
|
|
// Gear on the hull's hardpoint cell, cargo on an interior cell, and two empty weapon groups (fewer than the game's six).
|
|
private static Loadout HandBuilt(CultCache cache, string name = "Skiff build") => new Loadout
|
|
{
|
|
Name = name,
|
|
Hull = cache.RefOf(cache.GetByName<HullData>("Skiff")),
|
|
Slots =
|
|
{
|
|
new LoadoutSlot { Position = HardpointCell, Design = cache.RefOf<EquippableItemData>(cache.GetByName<GearData>("Lamp")) },
|
|
new LoadoutSlot { Position = InteriorCell, Design = cache.RefOf<EquippableItemData>(cache.GetByName<CargoBayData>("Crate")) }
|
|
},
|
|
WeaponGroups = new[] { new int[0], new int[0] }
|
|
};
|
|
|
|
// HandBuilt with the Gun on the hardpoint, alone in the first of the game's six weapon groups.
|
|
private static Loadout Armed(CultCache cache, string name = "Armed build")
|
|
{
|
|
var armed = HandBuilt(cache, name);
|
|
armed.Slots[0].Design = cache.RefOf<EquippableItemData>(cache.GetByName<GearData>("Gun"));
|
|
armed.WeaponGroups = Enumerable.Range(0, 6).Select(g => g == 0 ? new[] { 0 } : new int[0]).ToArray();
|
|
return armed;
|
|
}
|
|
|
|
private static Loadout WithDesign(Loadout loadout, int slot, CultCache cache, string gear)
|
|
{
|
|
loadout.Slots[slot].Design = cache.RefOf<EquippableItemData>(cache.GetByName<GearData>(gear));
|
|
return loadout;
|
|
}
|
|
|
|
// Capture from a built ship, commit to the catalog, reopen fresh read-only: the preset and the ship it builds match.
|
|
[Fact]
|
|
public void CapturedPresetRoundTripsThroughTheCatalog()
|
|
{
|
|
Loadout hand;
|
|
using (var cache = Open())
|
|
{
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
hand = Armed(cache);
|
|
var failures = new List<string>();
|
|
var ship = Build(items, hand, failures);
|
|
Assert.Empty(failures);
|
|
Assert.NotNull(ship);
|
|
Assert.True(Loadouts.Commit(Catalog, Loadouts.Capture(items, ship, hand.Name), replace: false));
|
|
}
|
|
|
|
using (var cache = Open())
|
|
{
|
|
var loaded = Assert.Single(cache.GetAll<Loadout>());
|
|
Assert.Equal(Loadouts.KeyOf(hand.Name), cache.RefOf(loaded).Key);
|
|
Assert.Equal(hand.Name, loaded.Name);
|
|
Assert.Equal(hand.Hull.Key, loaded.Hull.Key);
|
|
Assert.Equal(hand.Slots.Select(Describe), loaded.Slots.Select(Describe));
|
|
Assert.Equal(hand.WeaponGroups, loaded.WeaponGroups);
|
|
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var failures = new List<string>();
|
|
var ship = Build(items, loaded, failures);
|
|
Assert.Empty(failures);
|
|
Assert.Equal(hand.Slots.Select(slot => (slot.Position, slot.Design.Key)),
|
|
ship.Equipment.Concat<EquippedItem>(ship.CargoBays)
|
|
.Where(item => item != ship.EquippedHull)
|
|
.Select(item => (item.Position, cache.RefOf(items.GetData(item.EquippableItem)).Key)));
|
|
}
|
|
|
|
Assert.Single(SchemaNames(Catalog), name => name == "aetheria.loadout");
|
|
Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout"));
|
|
}
|
|
|
|
// The catalog is a stack of packages (ruling catalog-is-a-package-stack): a preset may name a package's design, hull or
|
|
// slot, and the only writer commits it under the package's key. Whether the design resolves is decided where the preset is
|
|
// built, not here.
|
|
[Theory]
|
|
[InlineData("hull", "mod-hull:mod.skiff")]
|
|
[InlineData("slot", "mod-hull:mod.skiff")]
|
|
[InlineData("slot", "mod-ship:mod.skiff")]
|
|
public void APresetNamingAPackageDesignIsCommitted(string where, string 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 = new CultRecordRef<EquippableItemData>(new CultRecordKey(key));
|
|
|
|
Assert.True(Loadouts.Commit(Catalog, loadout, replace: false));
|
|
|
|
using var reopened = Open();
|
|
var written = Assert.Single(reopened.GetAll<Loadout>());
|
|
Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value);
|
|
}
|
|
|
|
// With bossOf, one commit writes the loadout and makes it that faction's boss: Faction.BossLoadout names its key.
|
|
[Fact]
|
|
public void ABossCommitWritesTheLoadoutAndTheFactionsBossReference()
|
|
{
|
|
using (var cache = Open())
|
|
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, cache.RefOf(cache.GetByName<Faction>("Maker"))));
|
|
|
|
using (var cache = Open())
|
|
{
|
|
var loadout = Assert.Single(cache.GetAll<Loadout>());
|
|
Assert.Equal(Loadouts.KeyOf("Skiff build"), cache.RefOf(loadout).Key);
|
|
Assert.Same(loadout, Loadouts.BossOf(cache, cache.GetByName<Faction>("Maker"), out var refusal));
|
|
Assert.Equal(BossRefusal.None, refusal);
|
|
}
|
|
}
|
|
|
|
// The loadout and the boss reference are one batch: when the faction changed on disk after the commit's cache read it,
|
|
// the commit returns false and neither lands, though the loadout alone would have; the other writer's change survives.
|
|
[Fact]
|
|
public void ABossCommitOverAFactionChangedOnDiskWritesNeitherTheLoadoutNorTheReference()
|
|
{
|
|
Loadout loadout;
|
|
CultRecordRef<Faction> maker;
|
|
using (var read = Open())
|
|
{
|
|
loadout = HandBuilt(read);
|
|
maker = read.RefOf(read.GetByName<Faction>("Maker"));
|
|
}
|
|
|
|
using (var stale = AetheriaStores.Open(Catalog, catalogWritable: true))
|
|
{
|
|
Assert.NotNull(stale.Get(maker));
|
|
using (var other = AetheriaStores.Open(Catalog, catalogWritable: true))
|
|
{
|
|
var fresh = other.Get(maker);
|
|
Assert.True(other.Commit(batch =>
|
|
{
|
|
batch.Expect(maker.Key, fresh);
|
|
fresh.ShortName = "NEW";
|
|
batch.Upsert(typeof(Faction), fresh, maker.Key);
|
|
}));
|
|
}
|
|
|
|
Assert.False(Loadouts.Commit(stale, loadout, replace: false, maker));
|
|
}
|
|
|
|
using var after = Open();
|
|
Assert.Empty(after.GetAll<Loadout>());
|
|
var faction = after.Get(maker);
|
|
Assert.Equal("NEW", faction.ShortName);
|
|
Assert.False(faction.BossLoadout.IsSet());
|
|
}
|
|
|
|
// The commit is conditional on the record at KeyOf(Name) too: a loadout written under that name after this cache read the
|
|
// catalog is not overwritten, even with replace set, and the second commit reports false.
|
|
[Fact]
|
|
public void ACommitOverALoadoutWrittenAfterTheReadWritesNothing()
|
|
{
|
|
Loadout loadout;
|
|
using (var read = Open())
|
|
loadout = HandBuilt(read);
|
|
|
|
using (var stale = AetheriaStores.Open(Catalog, catalogWritable: true))
|
|
{
|
|
Assert.Empty(stale.GetAll<Loadout>());
|
|
Assert.True(Loadouts.Commit(Catalog, loadout, replace: false));
|
|
Assert.False(Loadouts.Commit(stale, loadout, replace: true, default));
|
|
}
|
|
|
|
using var after = Open();
|
|
Assert.Single(after.GetAll<Loadout>());
|
|
}
|
|
|
|
// A boss faction the catalog does not hold is refused before anything is written.
|
|
[Fact]
|
|
public void ABossCommitNamingNoFactionIsRefusedAndNothingIsWritten()
|
|
{
|
|
var before = Hash(Catalog);
|
|
using (var cache = Open())
|
|
{
|
|
var error = Assert.Throws<InvalidOperationException>(() =>
|
|
Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, new CultRecordRef<Faction>(new CultRecordKey("faction:absent"))));
|
|
Assert.Contains("boss faction is not in the catalog", error.Message);
|
|
}
|
|
Assert.Equal(before, Hash(Catalog));
|
|
}
|
|
|
|
// Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates.
|
|
[Fact]
|
|
public void SameNameCaptureReplacesOnlyWhenAsked()
|
|
{
|
|
using (var cache = Open())
|
|
{
|
|
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
|
|
var before = Hash(Catalog);
|
|
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(Catalog, Armed(cache, "Skiff build"), replace: false));
|
|
Assert.Contains("Skiff build", error.Message);
|
|
Assert.Equal(before, Hash(Catalog));
|
|
|
|
Assert.True(Loadouts.Commit(Catalog, Armed(cache, "Skiff build"), replace: true));
|
|
}
|
|
|
|
using (var cache = Open())
|
|
Assert.Equal(6, Assert.Single(cache.GetAll<Loadout>()).WeaponGroups.Length);
|
|
}
|
|
|
|
// Play mutates live catalog instances; a preset commit must land only itself.
|
|
[Fact]
|
|
public void PresetCommitWritesNoOtherInMemoryCatalogState()
|
|
{
|
|
using (var cache = Open())
|
|
{
|
|
cache.GetByName<Faction>("Maker").ShortName = "XXX";
|
|
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
using (var cache = Open())
|
|
{
|
|
Assert.Equal("MKR", cache.GetByName<Faction>("Maker").ShortName);
|
|
Assert.Single(cache.GetAll<Loadout>());
|
|
}
|
|
}
|
|
|
|
// The process cache holds the catalog read-only while capture writes through its own cache: the capture lands, and
|
|
// the process cache keeps its instances, gains nothing and stays clean until it reloads.
|
|
[Fact]
|
|
public void CaptureWhileTheCatalogIsHeldReadOnlyLeavesTheProcessCacheUnchanged()
|
|
{
|
|
using (var cache = Open())
|
|
{
|
|
var maker = cache.GetByName<Faction>("Maker");
|
|
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
|
|
Assert.Empty(cache.GetAll<Loadout>());
|
|
Assert.Same(maker, cache.GetByName<Faction>("Maker"));
|
|
Assert.False(cache.IsDirty);
|
|
Assert.Throws<InvalidOperationException>(() => cache.Upsert(new Faction { Name = "Usurper" }));
|
|
}
|
|
|
|
using (var cache = Open())
|
|
Assert.Equal("Skiff build", Assert.Single(cache.GetAll<Loadout>()).Name);
|
|
}
|
|
|
|
// Capture never creates a catalog: a missing file refuses, and no file or directory appears.
|
|
[Fact]
|
|
public void CaptureWithAMissingCatalogRefusesAndCreatesNothing()
|
|
{
|
|
var absent = Path.Combine(_root, "absent", "Aetheria.cc");
|
|
using var cache = Open();
|
|
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(absent, HandBuilt(cache), replace: false));
|
|
Assert.Contains("does not exist", error.Message);
|
|
Assert.False(Directory.Exists(Path.GetDirectoryName(absent)));
|
|
}
|
|
|
|
// A preset authored in Studio has a minted key; capturing its name refuses, replace or not, and names that key.
|
|
[Fact]
|
|
public void SameNameUnderAnotherKeyRefuses()
|
|
{
|
|
using (var writer = AetheriaStores.Open(Catalog, catalogWritable: true))
|
|
{
|
|
writer.Upsert(HandBuilt(writer));
|
|
writer.FlushAsync().Wait();
|
|
}
|
|
var before = Hash(Catalog);
|
|
|
|
using (var cache = Open())
|
|
{
|
|
var studioKey = cache.RefOf(Assert.Single(cache.GetAll<Loadout>())).Key;
|
|
Assert.NotEqual(Loadouts.KeyOf("Skiff build"), studioKey);
|
|
foreach (var replace in new[] { false, true })
|
|
{
|
|
var error = Assert.Throws<InvalidOperationException>(() => Loadouts.Commit(Catalog, Armed(cache, "Skiff build"), replace));
|
|
Assert.Contains(studioKey.Value, error.Message);
|
|
}
|
|
}
|
|
|
|
Assert.Equal(before, Hash(Catalog));
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadoutHoldsNoRunOrGalaxyRefs()
|
|
{
|
|
var forbidden = new[] { typeof(ItemInstance), typeof(EntityPack), typeof(FactionProductData), typeof(Faction) }
|
|
.Concat(AetheriaStores.RunTypes)
|
|
.ToArray();
|
|
var seen = new HashSet<Type>();
|
|
var pending = new Queue<Type>(new[] { typeof(Loadout) });
|
|
while (pending.Count > 0)
|
|
{
|
|
var type = pending.Dequeue();
|
|
if (!seen.Add(type)) continue;
|
|
Assert.DoesNotContain(forbidden, bad => bad.IsAssignableFrom(type));
|
|
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>))
|
|
{
|
|
var named = type.GetGenericArguments()[0];
|
|
Assert.True(typeof(EquippableItemData).IsAssignableFrom(named) || named == typeof(FactionProductData), $"{type} names a record that is neither a design nor a product");
|
|
continue;
|
|
}
|
|
if (type.IsArray) pending.Enqueue(type.GetElementType());
|
|
foreach (var argument in type.IsGenericType ? type.GetGenericArguments() : Type.EmptyTypes) pending.Enqueue(argument);
|
|
if (type.Assembly == typeof(Loadout).Assembly)
|
|
foreach (var field in type.GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic))
|
|
pending.Enqueue(field.FieldType);
|
|
}
|
|
|
|
using (var cache = Open())
|
|
Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false));
|
|
Assert.Contains("aetheria.loadout", SchemaNames(Catalog));
|
|
Assert.True(!File.Exists(Run) || SchemaNames(Run).Length == 0);
|
|
Assert.True(!File.Exists(Player) || SchemaNames(Player).Length == 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingDesignReports()
|
|
{
|
|
using (var cache = Open())
|
|
cache.Commit(batch => batch.Upsert(new PlayerSettings()));
|
|
var before = new[] { Catalog, Run, Player }.Select(Hash).ToArray();
|
|
|
|
using (var cache = Open())
|
|
{
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var missing = HandBuilt(cache);
|
|
missing.Slots[1].Design = new CultRecordRef<EquippableItemData>(new CultRecordKey("absent-design"));
|
|
var failures = new List<string>();
|
|
Assert.Null(Build(items, missing, failures));
|
|
var failure = Assert.Single(failures);
|
|
Assert.Contains("1,1", failure);
|
|
Assert.Contains("absent-design", failure);
|
|
|
|
missing.Slots.Add(new LoadoutSlot { Position = new int2(2, 2), Design = new CultRecordRef<EquippableItemData>(new CultRecordKey("another-absent-design")) });
|
|
failures.Clear();
|
|
Assert.Null(Build(items, missing, failures));
|
|
Assert.Equal(2, failures.Count);
|
|
Assert.Contains("slot 1,1", failures[0]);
|
|
Assert.Contains("slot 2,2", failures[1]);
|
|
Assert.Contains("another-absent-design", failures[1]);
|
|
}
|
|
|
|
Assert.Equal(before, new[] { Catalog, Run, Player }.Select(Hash).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void FailedFitBuildsNothing()
|
|
{
|
|
using var cache = Open();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var clash = HandBuilt(cache);
|
|
clash.Slots[1].Position = HardpointCell;
|
|
var failures = new List<string>();
|
|
Assert.Null(Build(items, clash, failures));
|
|
var failure = Assert.Single(failures);
|
|
Assert.Contains("slot 0,0: Crate does not fit", failure);
|
|
}
|
|
|
|
// Scenarios Q3 (docs/scenarios-cut.md): a design no product makes is outside the economy, and a preset builds it
|
|
// unbranded -- no maker -- at the one named quality. "Orphan" has no product in this catalog.
|
|
[Fact]
|
|
public void ADesignWithNoProductBuildsUnbranded()
|
|
{
|
|
using var cache = Open();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var failures = new List<string>();
|
|
var ship = Build(items, WithDesign(HandBuilt(cache), 0, cache, "Orphan"), failures);
|
|
Assert.Empty(failures);
|
|
var unit = ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem;
|
|
Assert.Equal("Orphan", items.GetData(unit).Name);
|
|
Assert.Null(items.Brand(unit).Maker);
|
|
Assert.Equal(Loadouts.UnbrandedQuality, items.GetLot(unit).Quality);
|
|
}
|
|
|
|
// The existing contract Q3 does not loosen: a design that has a product, none of them available, is not built,
|
|
// unbranded or otherwise.
|
|
[Fact]
|
|
public void ADesignWhoseOnlyProductIsUnavailableStillFails()
|
|
{
|
|
using var cache = Open();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var failures = new List<string>();
|
|
var soleProduct = SoleProduct(cache);
|
|
Assert.Null(Loadouts.Materialize(items, null, HandBuilt(cache), design => design.Name == "Lamp" ? null : soleProduct(design), failures));
|
|
Assert.Equal("slot 0,0: no available product of Lamp", Assert.Single(failures));
|
|
}
|
|
|
|
// F2: this test must not enumerate reloaded instances through EntitySerializer.Items, the GC root walk under
|
|
// test. It hand-mints one real lot per EntityPack root (Hull, Equipment, CargoBays, DockingBays, CargoContents,
|
|
// Children, DockingBayContents) so the expected lot ids are known independently of any enumerator, and reads
|
|
// the reloaded pack's own fields directly. Skipping any one root in Items/Reachable drops that root's lot from
|
|
// the stored ledger, which RunSave.Lots' indexer throws loudly on.
|
|
[Fact]
|
|
public void MaterializedLotsSurviveSaveAndReload()
|
|
{
|
|
CultRecordKey maker;
|
|
int[] expectedLots;
|
|
using (var cache = AetheriaStores.Open(Catalog, Run, Player, catalogWritable: true))
|
|
{
|
|
maker = cache.RefOf(cache.GetByName<Faction>("Maker")).Key;
|
|
var dockingBay = cache.Upsert(new DockingBayData { Name = "Bay", Shape = new Shape(), InteriorShape = new Shape(), Price = 20 });
|
|
cache.Upsert(new FactionProductData { Name = "Bay by Maker", Design = new CultRecordRef<CraftedItemData>(dockingBay.Key), Manufacturer = cache.RefOf(cache.GetByName<Faction>("Maker")) });
|
|
cache.FlushAsync().Wait();
|
|
|
|
var skiff = cache.GetAll<FactionProductData>().Single(p => p.Name == "Skiff by Maker");
|
|
var lamp = cache.GetAll<FactionProductData>().Single(p => p.Name == "Lamp by Maker");
|
|
var crate = cache.GetAll<FactionProductData>().Single(p => p.Name == "Crate by Maker");
|
|
var gun = cache.GetAll<FactionProductData>().Single(p => p.Name == "Gun by Maker");
|
|
var bay = cache.GetAll<FactionProductData>().Single(p => p.Name == "Bay by Maker");
|
|
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
|
|
EquippableItem Mint(FactionProductData product) => (EquippableItem) items.CreateInstance(product);
|
|
|
|
var hull = Mint(skiff);
|
|
var equipment = Mint(lamp);
|
|
var cargoBay = Mint(crate);
|
|
var dockingBayUnit = Mint(bay);
|
|
var cargoContent = Mint(gun);
|
|
var dockingBayContent = Mint(lamp);
|
|
var childHull = Mint(skiff);
|
|
|
|
var childPack = new ShipPack
|
|
{
|
|
Name = "child", Hull = childHull,
|
|
Equipment = Array.Empty<(int2, EquippableItem)>(), CargoBays = Array.Empty<(int2, EquippableItem)>(),
|
|
DockingBays = Array.Empty<(int2, EquippableItem)>(), CargoContents = Array.Empty<(int2, ItemInstance)[]>(),
|
|
DockingBayContents = Array.Empty<(int2, ItemInstance)[]>(), Children = Array.Empty<EntityPack>(),
|
|
Temperature = new float[0, 0],
|
|
Armor = new float[0, 0], Conductivity = new bool2[0, 0], DockingBayAssignments = Array.Empty<int>(),
|
|
Settings = new EntitySettings(), WeaponGroups = Array.Empty<int[]>()
|
|
};
|
|
var rootPack = new ShipPack
|
|
{
|
|
Name = "root", Hull = hull,
|
|
Equipment = new[] { (default(int2), equipment) },
|
|
CargoBays = new[] { (default(int2), cargoBay) },
|
|
DockingBays = new[] { (default(int2), dockingBayUnit) },
|
|
CargoContents = new[] { new[] { (default(int2), (ItemInstance) cargoContent) } },
|
|
DockingBayContents = new[] { new[] { (default(int2), (ItemInstance) dockingBayContent) } },
|
|
Children = new EntityPack[] { childPack },
|
|
DockingBayAssignments = new[] { 0 },
|
|
Temperature = new float[0, 0],
|
|
Armor = new float[0, 0], Conductivity = new bool2[0, 0],
|
|
Settings = new EntitySettings(), WeaponGroups = Array.Empty<int[]>()
|
|
};
|
|
|
|
expectedLots = new[]
|
|
{
|
|
hull.Lot, equipment.Lot, cargoBay.Lot, dockingBayUnit.Lot, cargoContent.Lot, dockingBayContent.Lot, childHull.Lot
|
|
};
|
|
|
|
var game = new SavedGame
|
|
{
|
|
Factions = Array.Empty<CultRecordRef<Faction>>(),
|
|
Relationships = Array.Empty<FactionRelationship>(),
|
|
HomeZones = new Dictionary<int, int>(),
|
|
BossZones = new Dictionary<int, int>(),
|
|
DiscoveredZones = new[] { 0 },
|
|
ActionBarBindings = new SavedActionBarBinding[0],
|
|
Exit = -1
|
|
};
|
|
var zones = new[]
|
|
{
|
|
new SavedZone
|
|
{
|
|
Name = "Zone 0", AdjacentZones = Array.Empty<int>(), Factions = Array.Empty<int>(), Owner = -1,
|
|
Contents = new ZonePack { Entities = new List<EntityPack> { rootPack } }
|
|
}
|
|
};
|
|
RunSave.Commit(cache, game, zones, items.Lots);
|
|
}
|
|
|
|
using (var cache = Open())
|
|
{
|
|
var run = cache.GetGlobal<SavedGame>();
|
|
var zone = cache.Get(run.Zones[0]);
|
|
var reloaded = (ShipPack) zone.Contents.Entities[0];
|
|
var reloadedChild = (ShipPack) reloaded.Children[0];
|
|
var lots = RunSave.Lots(cache);
|
|
|
|
Assert.Equal(expectedLots.OrderBy(l => l), new[]
|
|
{
|
|
reloaded.Hull.Lot, reloaded.Equipment[0].item.Lot, reloaded.CargoBays[0].item.Lot,
|
|
reloaded.DockingBays[0].item.Lot, ((CraftedItemInstance) reloaded.CargoContents[0][0].item).Lot,
|
|
((CraftedItemInstance) reloaded.DockingBayContents[0][0].item).Lot, reloadedChild.Hull.Lot
|
|
}.OrderBy(l => l));
|
|
|
|
foreach (var lotId in expectedLots)
|
|
{
|
|
var lot = lots[lotId];
|
|
var attributed = Assert.IsType<Attributed>(lot.Origin);
|
|
Assert.Equal(maker, attributed.Faction.Key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// FillInterior requires a capacitor product; this fixture hull adds one alongside a 4x4 hull with two identical
|
|
// Sensors hardpoints.
|
|
[Fact]
|
|
public void MatchingHardpointsShareALot()
|
|
{
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
|
|
var hullShape = new Shape(4, 4);
|
|
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
|
|
var hull = cache.Upsert(new HullData
|
|
{
|
|
Name = "Twin", HullType = HullType.Ship, Shape = hullShape, Price = 100,
|
|
Hardpoints =
|
|
{
|
|
new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() },
|
|
new HardpointData { Type = HardpointType.Sensors, Position = new int2(1, 0), Shape = new Shape() }
|
|
}
|
|
});
|
|
var capacitor = cache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } });
|
|
var tractor = cache.Upsert(new GearData { Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new PickupData() } });
|
|
foreach (var (name, design) in new[] { ("Twin by Maker", hull.Key), ("Cap by Maker", capacitor.Key), ("Tractor by Maker", tractor.Key) })
|
|
cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef<CraftedItemData>(design), Manufacturer = maker });
|
|
cache.FlushAsync().Wait();
|
|
|
|
var random = new Random(1);
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var generator = new LoadoutGenerator(ref random, items, null, null, null, .5f);
|
|
var pack = generator.GenerateShipLoadout(candidate => candidate.Name == "Twin");
|
|
Assert.NotNull(pack);
|
|
|
|
var hardpointItems = pack.Equipment
|
|
.Where(e => (cache.Get(e.item.Data) as GearData)?.Hardpoint == HardpointType.Sensors)
|
|
.ToArray();
|
|
Assert.Equal(2, hardpointItems.Length);
|
|
Assert.Equal(hardpointItems[0].item.Lot, hardpointItems[1].item.Lot);
|
|
}
|
|
|
|
// A Lamp lot with Quality .2 and one role fill (lens .15, under the assembly): a stat reading that role gets .15, one
|
|
// reading none gets the effective quality .15, and GetTier reads .2. A lot of
|
|
// assembly .6 with a lens of .3 is Common, not Rare: the tier follows its effective quality.
|
|
[Fact]
|
|
public void StatsReadTheLot()
|
|
{
|
|
using var cache = Open();
|
|
var settings = RunSaveTests.TestSettings();
|
|
// Two tiers with distinct quality thresholds: a single-tier fixture cannot fail "GetTier ignores lot
|
|
// quality", since every lot would resolve to the only tier regardless of its own Quality.
|
|
settings.Tiers = new[]
|
|
{
|
|
new RarityTier { Name = "Common", Quality = .2f, Color = new float3(1, 1, 1) },
|
|
new RarityTier { Name = "Rare", Quality = .5f, Color = new float3(1, 1, 1) }
|
|
};
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
|
var lamp = cache.GetByName<GearData>("Lamp");
|
|
var commonLot = items.Lots.Add(new Lot
|
|
{
|
|
Design = cache.RefOf<ItemData>(lamp),
|
|
Origin = new Attributed(),
|
|
Quality = .2f,
|
|
Roles = new List<RoleFill> { new RoleFill { Role = "lens", Quality = .15f } }
|
|
});
|
|
var rareLot = items.Lots.Add(new Lot
|
|
{
|
|
Design = cache.RefOf<ItemData>(lamp),
|
|
Origin = new Attributed(),
|
|
Quality = .6f,
|
|
Roles = new List<RoleFill>()
|
|
});
|
|
var cappedLot = items.Lots.Add(new Lot
|
|
{
|
|
Design = cache.RefOf<ItemData>(lamp),
|
|
Origin = new Attributed(),
|
|
Quality = .6f,
|
|
Roles = new List<RoleFill> { new RoleFill { Role = "lens", Quality = .3f } }
|
|
});
|
|
// Just under the Rare threshold, inside GetTier's .001 tolerance.
|
|
var nearRareLot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = .4995f, Roles = new List<RoleFill>() });
|
|
var instance = (EquippableItem) items.CreateInstance(commonLot);
|
|
var rareInstance = (EquippableItem) items.CreateInstance(rareLot);
|
|
var cappedInstance = (EquippableItem) items.CreateInstance(cappedLot);
|
|
var nearRareInstance = (EquippableItem) items.CreateInstance(nearRareLot);
|
|
|
|
var withRole = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1, Role = "lens" } } };
|
|
var noRole = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 1 } } };
|
|
Assert.Equal(.15f, items.Evaluate(withRole, instance), 3);
|
|
Assert.Equal(.15f, items.Evaluate(noRole, instance), 3);
|
|
Assert.Equal(.2f, items.GetTier(instance).tier.Quality, 3);
|
|
Assert.Equal(.5f, items.GetTier(rareInstance).tier.Quality, 3);
|
|
Assert.Equal(.2f, items.GetTier(cappedInstance).tier.Quality, 3);
|
|
Assert.Equal(.5f, items.GetTier(nearRareInstance).tier.Quality, 3);
|
|
// Upgrades count the steps of .05 above the tier: the capped lot's effective .3 is two above Common's .2.
|
|
Assert.Equal(2, items.GetTier(cappedInstance).upgrades);
|
|
|
|
// Minting a second instance from an existing lot must not re-roll the lot's quality.
|
|
var secondInstance = (EquippableItem) items.CreateInstance(commonLot);
|
|
Assert.Equal(.2f, items.Lots[commonLot].Quality, 3);
|
|
Assert.Equal(.2f, items.GetTier(secondInstance).tier.Quality, 3);
|
|
}
|
|
|
|
// The shipped tiers (Settings.asset) band a lot's effective quality, so the tier hides a sleeper whose part sits above
|
|
// its assembly (price-follows-effective-quality). Assembly at the fourth threshold, parts at the top and bottom
|
|
// thresholds: the capped mean falls in the second band, where the uncapped part mean reaches the third, the assembly
|
|
// alone the fourth and the best part the fifth.
|
|
[Fact]
|
|
public void GetTierHidesAnOverSpecPart()
|
|
{
|
|
using var cache = Open();
|
|
var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read<GameplaySettings>("GameplaySettings");
|
|
var tiers = settings.Tiers;
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
|
var lamp = cache.GetByName<GearData>("Lamp");
|
|
var assembly = tiers[3].Quality;
|
|
var sleeperLot = items.Lots.Add(new Lot
|
|
{
|
|
Design = cache.RefOf<ItemData>(lamp),
|
|
Origin = new Attributed(),
|
|
Quality = assembly,
|
|
Roles = new List<RoleFill>
|
|
{
|
|
new RoleFill { Role = "lens", Quality = tiers[4].Quality },
|
|
new RoleFill { Role = "frame", Quality = tiers[0].Quality }
|
|
}
|
|
});
|
|
var cappedMean = (assembly + tiers[0].Quality) / 2f;
|
|
var rolelessLot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = cappedMean, Roles = new List<RoleFill>() });
|
|
|
|
var sleeper = items.GetTier((CraftedItemInstance) items.CreateInstance(sleeperLot));
|
|
var roleless = items.GetTier((CraftedItemInstance) items.CreateInstance(rolelessLot));
|
|
Assert.Equal(tiers[1].Name, sleeper.tier.Name);
|
|
Assert.Equal(roleless.tier.Name, sleeper.tier.Name);
|
|
Assert.Equal(roleless.upgrades, sleeper.upgrades);
|
|
}
|
|
|
|
// The tolerance is open: a quality a full .001 under a threshold stays in the tier below, one inside it reaches the
|
|
// threshold's tier. Upgrades count steps of .0499 above the tier's threshold. Every value comes from the shipped tiers.
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
[InlineData(3)]
|
|
[InlineData(4)]
|
|
public void GetTierToleranceIsOpenAtEveryShippedThreshold(int index)
|
|
{
|
|
using var cache = Open();
|
|
var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read<GameplaySettings>("GameplaySettings");
|
|
var tiers = settings.Tiers;
|
|
Assert.True(tiers.Length > index, "premise: the shipped asset carries this tier");
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
|
var lamp = cache.GetByName<GearData>("Lamp");
|
|
string TierAt(float quality) => items.GetTier((CraftedItemInstance) items.CreateInstance(items.Lots.Add(
|
|
new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = quality, Roles = new List<RoleFill>() }))).tier.Name;
|
|
|
|
var threshold = tiers[index].Quality;
|
|
Assert.Equal(tiers[index - 1].Name, TierAt(threshold - .001f));
|
|
Assert.Equal(tiers[index].Name, TierAt(threshold - .0005f));
|
|
Assert.Equal(tiers[index].Name, TierAt(threshold));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetTierUpgradesAreStepsOfFortyNineNinetyNinePerTenThousand()
|
|
{
|
|
using var cache = Open();
|
|
var settings = AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read<GameplaySettings>("GameplaySettings");
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
|
var lamp = cache.GetByName<GearData>("Lamp");
|
|
// Two steps and a hair above the lowest threshold: the step width decides whether that counts as two or one.
|
|
var quality = settings.Tiers[0].Quality + 2 * .0499f + .0001f;
|
|
var lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = quality, Roles = new List<RoleFill>() });
|
|
var result = items.GetTier((CraftedItemInstance) items.CreateInstance(lot));
|
|
Assert.Equal(settings.Tiers[0].Name, result.tier.Name);
|
|
Assert.Equal(2, result.upgrades);
|
|
|
|
// The same width from the other side (follow-up tier-upgrade-step-two-sided): two steps less a hair is one upgrade, so a
|
|
// step narrower than .0499 reads two and a wider one reads none.
|
|
var under = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = settings.Tiers[0].Quality + 2 * .0499f - .0001f, Roles = new List<RoleFill>() });
|
|
var below = items.GetTier((CraftedItemInstance) items.CreateInstance(under));
|
|
Assert.Equal(settings.Tiers[0].Name, below.tier.Name);
|
|
Assert.Equal(1, below.upgrades);
|
|
}
|
|
|
|
// S7 (docs/stats-and-power-cut.md Cut 1 findings): a Quality term must raise the lot's quality to its own
|
|
// declared Exponent, not always to 1. StatsReadTheLot uses Exponent = 1 throughout, which cannot distinguish
|
|
// "read the term's exponent" from "always use 1" -- pow(x, 1) == x either way. Exponent = 2 here can: a
|
|
// mutation that drops the term's own exponent (using 1f regardless of what is declared) reads .5 instead of
|
|
// the correct .25.
|
|
[Fact]
|
|
public void QualityTermAppliesItsOwnExponent()
|
|
{
|
|
using var cache = Open();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var lamp = cache.GetByName<GearData>("Lamp");
|
|
var lotId = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() });
|
|
var instance = (EquippableItem) items.CreateInstance(lotId);
|
|
|
|
var squared = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Quality, Exponent = 2 } } };
|
|
Assert.Equal(.25f, items.Evaluate(squared, instance), 3);
|
|
}
|
|
|
|
// F4: GetPrice reads the lot's effective quality through GameplaySettings.QualityPriceModifier: a lot of assembly .8 whose
|
|
// one part is .2 prices exactly as a roleless .2 lot (price-follows-effective-quality), and a lot of assembly .6 with
|
|
// parts .9 and .2 (capped to .6 and .2) prices as a roleless .4 lot: the mean of the capped parts, not the best part.
|
|
[Fact]
|
|
public void GetPriceReadsEffectiveQuality()
|
|
{
|
|
using var cache = Open();
|
|
var settings = RunSaveTests.TestSettings();
|
|
settings.QualityPriceModifier = new ExponentialLerp { Minimum = 0, Maximum = 1, Exponent = 1 };
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { });
|
|
var lamp = cache.GetByName<GearData>("Lamp");
|
|
var lowLot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = .2f, Roles = new List<RoleFill>() });
|
|
var highLot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = .8f, Roles = new List<RoleFill>() });
|
|
var sleeperLot = items.Lots.Add(new Lot
|
|
{
|
|
Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = .8f,
|
|
Roles = new List<RoleFill> { new RoleFill { Role = "lens", Quality = .2f } }
|
|
});
|
|
var middleLot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = .4f, Roles = new List<RoleFill>() });
|
|
var mixedLot = items.Lots.Add(new Lot
|
|
{
|
|
Design = cache.RefOf<ItemData>(lamp), Origin = new Attributed(), Quality = .6f,
|
|
Roles = new List<RoleFill> { new RoleFill { Role = "lens", Quality = .9f }, new RoleFill { Role = "frame", Quality = .2f } }
|
|
});
|
|
var low = (EquippableItem) items.CreateInstance(lowLot);
|
|
var high = (EquippableItem) items.CreateInstance(highLot);
|
|
var sleeper = (EquippableItem) items.CreateInstance(sleeperLot);
|
|
var middle = (EquippableItem) items.CreateInstance(middleLot);
|
|
var mixed = (EquippableItem) items.CreateInstance(mixedLot);
|
|
Assert.True(items.GetPrice(high) > items.GetPrice(low));
|
|
Assert.Equal(items.GetPrice(low), items.GetPrice(sleeper));
|
|
Assert.Equal(items.GetPrice(middle), items.GetPrice(mixed));
|
|
Assert.True(items.GetPrice(middle) > items.GetPrice(low));
|
|
Assert.True(items.GetPrice(sleeper) < items.GetPrice(high));
|
|
}
|
|
|
|
// F4: the durability exponent path in ItemManager.Evaluate reads the lot's effective quality (GameplaySettings.
|
|
// DurabilityQuality{Min,Max,Exponent}), independently of the quality-for-role term (absent here -- the
|
|
// stat declares only a Durability term): a .9 lot whose one part is .1 evaluates as a roleless .1 lot.
|
|
[Fact]
|
|
public void EvaluateDurabilityExponentReadsEffectiveQuality()
|
|
{
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
var durable = cache.Upsert(new GearData { Name = "Durable", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, Durability = 100 });
|
|
cache.FlushAsync().Wait();
|
|
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var lowLot = items.Lots.Add(new Lot { Design = new CultRecordRef<ItemData>(durable.Key), Origin = new Attributed(), Quality = .1f, Roles = new List<RoleFill>() });
|
|
var highLot = items.Lots.Add(new Lot { Design = new CultRecordRef<ItemData>(durable.Key), Origin = new Attributed(), Quality = .9f, Roles = new List<RoleFill>() });
|
|
var sleeperLot = items.Lots.Add(new Lot
|
|
{
|
|
Design = new CultRecordRef<ItemData>(durable.Key), Origin = new Attributed(), Quality = .9f,
|
|
Roles = new List<RoleFill> { new RoleFill { Role = "lens", Quality = .1f } }
|
|
});
|
|
var low = (EquippableItem) items.CreateInstance(lowLot);
|
|
var high = (EquippableItem) items.CreateInstance(highLot);
|
|
var sleeper = (EquippableItem) items.CreateInstance(sleeperLot);
|
|
low.Durability = items.GetData(low).Durability / 2;
|
|
high.Durability = items.GetData(high).Durability / 2;
|
|
sleeper.Durability = items.GetData(sleeper).Durability / 2;
|
|
|
|
var stat = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Durability, Exponent = 1 } } };
|
|
Assert.NotEqual(items.Evaluate(stat, low), items.Evaluate(stat, high));
|
|
Assert.Equal(items.Evaluate(stat, low), items.Evaluate(stat, sleeper), 3);
|
|
Assert.NotEqual(items.Evaluate(stat, high), items.Evaluate(stat, sleeper), 3);
|
|
}
|
|
|
|
// F4: EquippedItem's thermal and durability exponents (Entity.cs's EquippedItem constructor) read the lot's effective
|
|
// quality (GameplaySettings.{Thermal,Durability}Quality{Min,Max,Exponent}), through a real equip on a live entity: a .9
|
|
// lot whose one part is .1 equips as a roleless .1 lot.
|
|
[Fact]
|
|
public void EquippedItemExponentsReadEffectiveQuality()
|
|
{
|
|
using var cache = Open();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
|
|
var hullData = cache.GetByName<HullData>("Skiff");
|
|
var lampData = cache.GetByName<GearData>("Lamp");
|
|
|
|
// TestSettings has one tier, so materializing through a Loadout always rolls the same quality; mint the
|
|
// gear's lot directly at a chosen quality and equip it onto a bare ship instead.
|
|
Ship BuildWithGear(Func<int> mintGear)
|
|
{
|
|
var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f));
|
|
var ship = new Ship(items, null, hullItem, new EntitySettings());
|
|
var gearItem = (EquippableItem) items.CreateInstance(mintGear());
|
|
Assert.True(ship.TryEquip(gearItem, HardpointCell));
|
|
return ship;
|
|
}
|
|
Ship BuildWithGearQuality(float quality) => BuildWithGear(() => items.CreateLot(lampData, maker, quality));
|
|
// The gear is a lot added directly: assembly .9, one part at .1.
|
|
Ship BuildWithSleeperGear() => BuildWithGear(() => items.Lots.Add(new Lot
|
|
{
|
|
Design = cache.RefOf<ItemData>(lampData), Origin = new Attributed(), Quality = .9f,
|
|
Roles = new List<RoleFill> { new RoleFill { Role = "lens", Quality = .1f } }
|
|
}));
|
|
|
|
var lowEquipped = BuildWithGearQuality(.1f).Equipment.Single(e => e.Data is GearData);
|
|
var highEquipped = BuildWithGearQuality(.9f).Equipment.Single(e => e.Data is GearData);
|
|
var sleeperEquipped = BuildWithSleeperGear().Equipment.Single(e => e.Data is GearData);
|
|
Assert.NotEqual(lowEquipped.ThermalExponent, highEquipped.ThermalExponent);
|
|
Assert.NotEqual(lowEquipped.DurabilityExponent, highEquipped.DurabilityExponent);
|
|
Assert.Equal(lowEquipped.ThermalExponent, sleeperEquipped.ThermalExponent, 5);
|
|
Assert.Equal(lowEquipped.DurabilityExponent, sleeperEquipped.DurabilityExponent, 5);
|
|
Assert.NotEqual(highEquipped.ThermalExponent, sleeperEquipped.ThermalExponent, 5);
|
|
Assert.NotEqual(highEquipped.DurabilityExponent, sleeperEquipped.DurabilityExponent, 5);
|
|
}
|
|
|
|
// Cut 0 (docs/stats-and-power-cut.md): the collapsed evaluation path (PerformanceStat.Evaluate(IStatContext))
|
|
// gives the unequipped and the equipped-at-full-health-and-optimal-temperature cases the same number, for a
|
|
// design whose heat curve genuinely reaches full performance at its plateau. Before the cut this equality was
|
|
// not even expressible: the two paths were separate hand-written formulas (ItemManager.Evaluate and
|
|
// EquippedItem.Evaluate) with no shared entry point to assert against. It is expressible now because both
|
|
// resolve through the one function via an IStatContext, which is the structural point of the cut.
|
|
//
|
|
// Cut 1 (docs/stats-and-power-cut.md), R-heat: the authored shape is minimum, maximum, optimum and plateau
|
|
// width, with linear falloffs -- no curve. This fixture authors a plateau (40-60 out of a 0-100 range) that
|
|
// reaches exactly 1.0 across it, the R-heat equivalent of the old flat-topped bezier fixture, so the
|
|
// equality this test pins (unequipped agrees with equipped-at-optimum) is expressible the same way Cut 0
|
|
// left it.
|
|
[Fact]
|
|
public void UnequippedAgreesWithEquippedAtFullHealthAndOptimalTemperature()
|
|
{
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
|
|
var hullData = cache.GetByName<HullData>("Skiff");
|
|
|
|
var thermalGear = cache.Upsert(new GearData
|
|
{
|
|
Name = "Thermal", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,
|
|
Durability = 100, MinimumTemperature = 0, MaximumTemperature = 100,
|
|
OptimalTemperature = 50, PlateauWidth = 20
|
|
});
|
|
cache.FlushAsync().Wait();
|
|
var thermalData = cache.Get(thermalGear);
|
|
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f));
|
|
var ship = new Ship(items, null, hullItem, new EntitySettings());
|
|
var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f));
|
|
Assert.True(ship.TryEquip(gearItem, HardpointCell));
|
|
var equipped = ship.Equipment.Single(e => e.Data is GearData);
|
|
|
|
// Drive the entity's temperature to the design's optimal, at full durability (CreateInstance's default).
|
|
foreach (var cell in equipped.InsetShape.Coordinates)
|
|
ship.Temperature[cell.x, cell.y] = thermalData.OptimalTemperature;
|
|
equipped.UpdatePerformance();
|
|
Assert.Equal(1f, equipped.ThermalPerformance, 4);
|
|
Assert.Equal(1f, equipped.DurabilityPerformance, 4);
|
|
|
|
var stat = new PerformanceStat
|
|
{
|
|
Min = 0, Max = 1,
|
|
Terms = { new StatTerm { Source = StatSource.Heat, Exponent = 1 }, new StatTerm { Source = StatSource.Durability, Exponent = 1 } }
|
|
};
|
|
var equippedResult = equipped.Evaluate(stat);
|
|
var unequippedResult = items.Evaluate(stat, gearItem);
|
|
Assert.Equal(unequippedResult, equippedResult, 5);
|
|
}
|
|
|
|
// Cut 0: the unequipped context has no heat, by name (UnequippedStatContext.HeatFactor is a fixed 1),
|
|
// regardless of the item's actual temperature or the term's exponent. This is one of the two named
|
|
// disagreements the cut preserves rather than resolves -- the trade menu shows the un-discounted value on
|
|
// purpose. Mutation: have HeatFactor read a real thermal performance instead of the constant 1; this goes
|
|
// red because the item is equipped far off its optimal temperature while unequipped stays unchanged.
|
|
[Fact]
|
|
public void UnequippedIgnoresHeatRegardlessOfTemperature()
|
|
{
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
|
|
var hullData = cache.GetByName<HullData>("Skiff");
|
|
// A peak with zero plateau width: performance reaches 1.0 only exactly at the optimum, matching the old
|
|
// curve fixture's single-point peak at .5 of its range.
|
|
var thermalGear = cache.Upsert(new GearData
|
|
{
|
|
Name = "ColdThermal", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,
|
|
Durability = 100, MinimumTemperature = 0, MaximumTemperature = 100,
|
|
OptimalTemperature = 50, PlateauWidth = 0
|
|
});
|
|
cache.FlushAsync().Wait();
|
|
var thermalData = cache.Get(thermalGear);
|
|
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f));
|
|
var ship = new Ship(items, null, hullItem, new EntitySettings());
|
|
var gearItem = (EquippableItem) items.CreateInstance(items.CreateLot(thermalData, maker, .5f));
|
|
Assert.True(ship.TryEquip(gearItem, HardpointCell));
|
|
var equipped = ship.Equipment.Single(e => e.Data is GearData);
|
|
|
|
// Drive the item to the coldest end of its range -- far from optimal, ThermalPerformance -> 0.
|
|
foreach (var cell in equipped.InsetShape.Coordinates)
|
|
ship.Temperature[cell.x, cell.y] = thermalData.MinimumTemperature;
|
|
equipped.UpdatePerformance();
|
|
Assert.True(equipped.ThermalPerformance < 0.01f);
|
|
|
|
var stat = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.Heat, Exponent = 1 } } };
|
|
var equippedResult = equipped.Evaluate(stat);
|
|
var unequippedResult = items.Evaluate(stat, gearItem);
|
|
Assert.True(equippedResult < 0.01f); // heat tanks the equipped read
|
|
Assert.Equal(1f, unequippedResult, 3); // unequipped never reads heat at all
|
|
}
|
|
|
|
// Cut 1: ConsumableItemEffect's progress-through-duration is now its own declared term (ConsumableProgress)
|
|
// rather than a hard override of "heat" applied to every stat regardless of what it declares. A stat that
|
|
// declares the term reads progress; one that does not is unaffected by it, the same as any other source a
|
|
// context has no reader for. Mutation: apply ConsumableProgressFactor unconditionally instead of gating it
|
|
// on a declared term; the "undeclared" assertion goes red because Undeclared would then also read .5.
|
|
[Fact]
|
|
public void ConsumableProgressAppliesOnlyWhenDeclared()
|
|
{
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
|
|
var hullData = cache.GetByName<HullData>("Skiff");
|
|
var ramp = new BezierCurve { Keys = new[] { new float4(0f, 0f, 0f, 0f), new float4(1f, 1f, 0f, 0f) } };
|
|
var consumableRef = cache.Upsert(new ConsumableItemData { Name = "Booster", Duration = 10, Effectiveness = ramp });
|
|
cache.FlushAsync().Wait();
|
|
var consumableData = cache.Get(consumableRef);
|
|
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f));
|
|
var ship = new Ship(items, null, hullItem, new EntitySettings());
|
|
var consumableItem = (ConsumableItem) items.CreateInstance(items.CreateLot(consumableData, maker, .5f));
|
|
var effect = new ConsumableItemEffect(consumableItem, ship);
|
|
effect.Update(5f); // half the duration elapsed -> progress .5 -> effectiveness .5 on a linear ramp
|
|
|
|
// Exponent 2, not 1: pow(.5, 2) == .25 differs from the raw progress .5, so a mutation that dropped the
|
|
// term's own exponent (using the raw effectiveness value unexponentiated) is distinguishable from this.
|
|
var declared = new PerformanceStat { Min = 0, Max = 1, Terms = { new StatTerm { Source = StatSource.ConsumableProgress, Exponent = 2 } } };
|
|
var undeclared = new PerformanceStat { Min = 0, Max = 1 };
|
|
Assert.Equal(.25f, effect.Evaluate(declared), 3);
|
|
Assert.Equal(1f, effect.Evaluate(undeclared), 3);
|
|
}
|
|
|
|
// Creating a behaviour reads the fire mode from the data wherever the weapon is carried: a consumable's automatic gun is an
|
|
// AutoWeapon that repeats, every other kind a plain semi-automatic InstantWeapon. Kills: the consumable CreateInstance
|
|
// ignoring Automatic.
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void CreatingABehaviourFromAConsumableHonoursAutomatic(bool automatic)
|
|
{
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
|
|
var hullData = cache.GetByName<HullData>("Skiff");
|
|
InstantWeaponData gun = automatic ? new AutoWeaponData() : new InstantWeaponData();
|
|
var consumableRef = cache.Upsert(new ConsumableItemData { Name = "Dose", Duration = 10, Behaviors = { gun } });
|
|
cache.FlushAsync().Wait();
|
|
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f));
|
|
var ship = new Ship(items, null, hullItem, new EntitySettings());
|
|
var consumableItem = (ConsumableItem) items.CreateInstance(items.CreateLot(cache.Get(consumableRef), maker, .5f));
|
|
|
|
var behavior = new ConsumableItemEffect(consumableItem, ship).Behaviors.Single();
|
|
|
|
Assert.True(automatic == (behavior is AutoWeapon), $"automatic {automatic}: created a {behavior.GetType().Name}");
|
|
Assert.IsAssignableFrom<InstantWeapon>(behavior);
|
|
}
|
|
|
|
// F4: CreateLot(product) fills each of the design's roles from the product's own per-role spread
|
|
// (FactionProductData.Roles), not the design-wide default.
|
|
[Fact]
|
|
public void CreateLotFillsRolesFromProductSpread()
|
|
{
|
|
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
|
|
var lensGear = cache.Upsert(new GearData
|
|
{
|
|
Name = "Lensed", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10,
|
|
Roles = { new ItemRole { Name = "lens" } }
|
|
});
|
|
cache.Upsert(new FactionProductData
|
|
{
|
|
Name = "Lensed by Maker", Design = new CultRecordRef<CraftedItemData>(lensGear.Key), Manufacturer = maker,
|
|
Roles = { new ProductRole { Role = "lens", Mean = .9f, StandardDeviation = 0 } }
|
|
});
|
|
cache.FlushAsync().Wait();
|
|
|
|
var product = cache.GetAll<FactionProductData>().Single(p => p.Name == "Lensed by Maker");
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var lotId = items.CreateLot(product);
|
|
var roleFill = items.Lots[lotId].Roles.Single(r => r.Role == "lens");
|
|
Assert.Equal(.9f, roleFill.Quality, 3);
|
|
}
|
|
|
|
// F4: Brand reads the maker faction off a Produced origin, not only Attributed.
|
|
[Fact]
|
|
public void BrandReadsProducedFaction()
|
|
{
|
|
using var cache = Open();
|
|
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var lamp = cache.GetByName<GearData>("Lamp");
|
|
var lotId = items.Lots.Add(new Lot
|
|
{
|
|
Design = cache.RefOf<ItemData>(lamp),
|
|
Origin = new Produced { Faction = maker, Station = 1, Facility = 2, Inputs = Array.Empty<int>() },
|
|
Quality = .5f, Roles = new List<RoleFill>()
|
|
});
|
|
var instance = (EquippableItem) items.CreateInstance(lotId);
|
|
var (brandMaker, _) = items.Brand(instance);
|
|
Assert.NotNull(brandMaker);
|
|
Assert.Equal(maker.Key, cache.RefOf(brandMaker).Key);
|
|
}
|
|
|
|
[Fact]
|
|
public void OutOfRangeWeaponGroupIndexBuildsNothing()
|
|
{
|
|
using var cache = Open();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var stale = Armed(cache);
|
|
stale.WeaponGroups = new[] { new[] { 0, 2 }, new[] { -1 } };
|
|
var failures = new List<string>();
|
|
Assert.Null(Build(items, stale, failures));
|
|
Assert.Equal(new[] { "weapon group: no slot 2", "weapon group: no slot -1" }, failures);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnequippingAGroupedWeaponRemovesItFromItsGroups()
|
|
{
|
|
using var cache = Open();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var ship = Build(items, Armed(cache), new List<string>());
|
|
var item = Assert.Single(ship.WeaponGroups[0].items);
|
|
Assert.NotNull(item.GetBehavior<Weapon>());
|
|
|
|
Assert.NotNull(ship.TryUnequip(item));
|
|
Assert.All(ship.WeaponGroups, group => Assert.Empty(group.items));
|
|
Assert.All(ship.WeaponGroups, group => Assert.Empty(group.weapons));
|
|
}
|
|
|
|
// A group index on a non-weapon slot would put a null weapon in the group; it is refused before anything is built.
|
|
[Fact]
|
|
public void NonWeaponGroupIndexBuildsNothing()
|
|
{
|
|
using var cache = Open();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var lampGrouped = WithDesign(Armed(cache), 0, cache, "Lamp");
|
|
var failures = new List<string>();
|
|
Assert.Null(Build(items, lampGrouped, failures));
|
|
Assert.Equal("weapon group: slot 0,0: Lamp is not a weapon", Assert.Single(failures));
|
|
}
|
|
|
|
[Fact]
|
|
public void MoreWeaponGroupsThanTheGameHasBuildsNothing()
|
|
{
|
|
using var cache = Open();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var wide = Armed(cache);
|
|
wide.WeaponGroups = wide.WeaponGroups.Append(new[] { 0 }).ToArray();
|
|
var failures = new List<string>();
|
|
Assert.Null(Build(items, wide, failures));
|
|
Assert.Equal("weapon groups: 7 groups, at most 6", Assert.Single(failures));
|
|
}
|
|
|
|
// Fewer groups, or none, still give the ship exactly WeaponGroupCount groups, so GenerateWeaponGroups and G1-G6
|
|
// bindings can index every one.
|
|
[Fact]
|
|
public void FewerWeaponGroupsArePaddedToTheGameCount()
|
|
{
|
|
using var cache = Open();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var armed = Armed(cache);
|
|
armed.WeaponGroups = new[] { new[] { 0 } };
|
|
foreach (var groups in new[] { armed.WeaponGroups, null })
|
|
{
|
|
armed.WeaponGroups = groups;
|
|
var failures = new List<string>();
|
|
var ship = Build(items, armed, failures);
|
|
Assert.Empty(failures);
|
|
Assert.Equal(6, ship.WeaponGroups.Length);
|
|
Assert.All(ship.WeaponGroups.Skip(1), group => Assert.Empty(group.items));
|
|
Assert.Equal(groups == null ? 0 : 1, ship.WeaponGroups[0].items.Count);
|
|
ship.GenerateWeaponGroups();
|
|
}
|
|
}
|
|
|
|
// Cut 0 (settings-globals-cut.md): Entity's ctor must copy the template it is given. No entity may alias
|
|
// GameplaySettings.DefaultEntitySettings, or a write through one ship (InventoryMenu's Shutdown Threshold
|
|
// edit) would silently move the default for every other ship, and once settings are a catalog global, the
|
|
// cached record itself.
|
|
[Fact]
|
|
public void EntitiesDoNotShareDefaultEntitySettings()
|
|
{
|
|
using var cache = Open();
|
|
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
|
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
|
|
var hullData = cache.GetByName<HullData>("Skiff");
|
|
var template = items.GameplaySettings.DefaultEntitySettings;
|
|
var originalThreshold = template.ShutdownPerformance;
|
|
|
|
Ship BuildShip() => new Ship(items, null, (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f)), template);
|
|
|
|
var shipA = BuildShip();
|
|
var shipB = BuildShip();
|
|
|
|
shipA.Settings.ShutdownPerformance = originalThreshold + 1;
|
|
|
|
Assert.NotSame(template, shipA.Settings);
|
|
Assert.NotSame(shipA.Settings, shipB.Settings);
|
|
Assert.Equal(originalThreshold, shipB.Settings.ShutdownPerformance);
|
|
Assert.Equal(originalThreshold, template.ShutdownPerformance);
|
|
}
|
|
|
|
// The deterministic chooser for tests about placement and failure lists: the design's only product, and nothing (so a
|
|
// failure) when it has several.
|
|
internal static Func<CraftedItemData, FactionProductData> SoleProduct(CultCache cache) => design =>
|
|
{
|
|
var key = cache.RefOf(design).Key;
|
|
var made = cache.GetAll<FactionProductData>().Where(p => p.Design.Key.Equals(key)).Take(2).ToArray();
|
|
return made.Length == 1 ? made[0] : null;
|
|
};
|
|
|
|
private static Entity Build(ItemManager items, Loadout loadout, List<string> failures) =>
|
|
Loadouts.Materialize(items, null, loadout, SoleProduct(items.ItemData), failures);
|
|
|
|
private static (int2, ItemRotation, CultRecordKey) Describe(LoadoutSlot slot) => (slot.Position, slot.Rotation, slot.Design.Key);
|
|
|
|
private static string Hash(string path) => File.Exists(path) ? Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))) : "absent";
|
|
|
|
private static string[] SchemaNames(string path)
|
|
{
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
return snapshot.Records
|
|
.Select(record => snapshot.SchemaCatalog.Single(entry => entry.SchemaId == record.SchemaId).SchemaName)
|
|
.ToArray();
|
|
}
|
|
}
|