diff --git a/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs b/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs new file mode 100644 index 000000000..0964f9e7c --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// The first two consumables (aetheria-release cut consumables-first-set, rulings consumables-scarce, +// overdrive-keep-steering, vapour-cloud-obscuration), read from the shipped catalog and run on a restored hull +// through the real consumable host: the Thruster Overdrive locks the throttle and multiplies the drive, the +// Coolant Vent leaves a cloud and takes its toll on the radiators. Throttles and sight lines belong to +// ThrottleLockTests and VapourCloudTests; this file owns what the catalog says and that it reaches the stats. +public sealed class ConsumablesFirstSetTests +{ + private const string OverdriveName = "Thruster Overdrive"; + private const string VentName = "Coolant Vent"; + private const float Tick = .25f; // exact in floats, so a duration of 6 s is 24 ticks and 15 s is 60 + + private static ConsumableItemData Design(CultCache cache, string name) => Assert.Single(cache.GetAll(), c => c.Name == name); + + private static void AssertStat(PerformanceStat stat, float min, float max, string role) + { + Assert.Equal(min, stat.Min, 4); + Assert.Equal(max, stat.Max, 4); + var term = Assert.Single(stat.Terms); + Assert.Equal(StatSource.Quality, term.Source); + Assert.Equal(role, term.Role); + } + + private static void AssertModifier(BehaviorData behavior, string target, string stat, float min, float max, string role) + { + var modifier = Assert.IsType(behavior); + Assert.Equal(target, modifier.Stat.Target); + Assert.Equal(stat, modifier.Stat.Stat); + Assert.Equal(StatModifierType.Multiplier, modifier.Type); + AssertStat(modifier.Modifier, min, max, role); + } + + // Half the diagonal of a ship hull's footprint in zone units: the radius of the smallest disc that holds it. + private static float Circumradius(HullData hull, float cellSize) => + .5f * cellSize * MathF.Sqrt(hull.Shape.Width * hull.Shape.Width + hull.Shape.Height * hull.Shape.Height); + + [Fact] + public void ConsumablesFirstSetIsAuthored() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var cellSize = RestoredHullsTests.Settings().SchematicCellSize; + + var overdrive = Design(cache, OverdriveName); + Assert.Equal(1, overdrive.Shape.Width); + Assert.Equal(1, overdrive.Shape.Height); + Assert.Equal(10f, overdrive.Mass); + Assert.Equal(18000, overdrive.Price); + Assert.False(overdrive.Stackable); + Assert.Equal(6f, overdrive.Duration); + Assert.Equal(new[] { "propellant", "regulator" }, overdrive.Roles.Select(r => r.Name).ToArray()); + Assert.Equal(5, overdrive.Behaviors.Count); + Assert.IsType(overdrive.Behaviors[0]); // the lock is decided before the multipliers run + AssertModifier(overdrive.Behaviors[1], nameof(ThrusterData), nameof(ThrusterData.Thrust), 3f, 4.5f, "propellant"); + AssertModifier(overdrive.Behaviors[2], nameof(VelocityLimitData), nameof(VelocityLimitData.TopSpeed), 2f, 3f, "propellant"); + AssertModifier(overdrive.Behaviors[3], nameof(ThrusterData), nameof(ThrusterData.Heat), 5f, 3f, "regulator"); + AssertModifier(overdrive.Behaviors[4], nameof(ThrusterData), nameof(ThrusterData.Visibility), 5f, 3f, "regulator"); + + var vent = Design(cache, VentName); + Assert.Equal(1, vent.Shape.Width); + Assert.Equal(1, vent.Shape.Height); + Assert.Equal(15f, vent.Mass); + Assert.Equal(12000, vent.Price); + Assert.False(vent.Stackable); + Assert.Equal(15f, vent.Duration); + Assert.Equal(new[] { "nozzle", "coolant" }, vent.Roles.Select(r => r.Name).ToArray()); + Assert.Equal(2, vent.Behaviors.Count); + var dump = Assert.IsType(vent.Behaviors[0]); + // The cloud at its smallest holds every ship hull; its largest is half as big again. + var radiusMin = dump.Radius.Min; + foreach (var hull in cache.GetAll().Where(h => h.HullType == HullType.Ship)) + Assert.True(radiusMin >= Circumradius(hull, cellSize), $"{hull.Name} pokes out of the smallest cloud"); + Assert.Equal(1.5f * Circumradius(cache.GetByName("Djinni"), cellSize), radiusMin, 3); + Assert.Equal(33.03f, radiusMin, 1); // the Djinni, 14x17 cells at two units a cell + AssertStat(dump.Radius, radiusMin, 1.5f * radiusMin, "nozzle"); + AssertStat(dump.Opacity, .90f, .98f, "nozzle"); + AssertStat(dump.Lifetime, 14f, 20f, "coolant"); + AssertModifier(vent.Behaviors[1], nameof(RadiatorData), nameof(RadiatorData.Emissivity), .3f, .5f, "coolant"); + + foreach (var design in new[] { overdrive, vent }) + { + AetheriaStores.Validate(design); // every modifier resolves and every role a stat reads is declared + Assert.True(design.SpecificHeat > 0f && design.Conductivity > 0f); + + var products = cache.GetAll().Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray(); + Assert.Equal(2, products.Length); + var makers = products.Select(p => cache.Get(p.Manufacturer)).ToArray(); + Assert.Equal(2, makers.Select(m => m.Name).Distinct().Count()); + Assert.All(makers, maker => + { + Assert.DoesNotContain("Terri", maker.Name + maker.ShortName, StringComparison.OrdinalIgnoreCase); // ruling miss-terris-belongs-to-emily-r3 + Assert.DoesNotContain("Pirates", maker.Name + maker.ShortName, StringComparison.OrdinalIgnoreCase); + }); + Assert.All(products, product => + { + Assert.Equal(design.Roles.Select(r => r.Name).OrderBy(r => r, StringComparer.Ordinal).ToArray(), + product.Roles.Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray()); + Assert.NotEqual(design.Name, product.Name); + Assert.False(string.IsNullOrWhiteSpace(product.Description)); + }); + // No description names a maker of this design, the design's own included. + foreach (var text in products.Select(p => p.Description).Append(design.Description)) + foreach (var maker in makers) + { + Assert.DoesNotContain(maker.Name, text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(maker.ShortName, text, StringComparison.OrdinalIgnoreCase); + } + } + } + + // A restored Djinni with its drives, reactor and one radiator, warmed up; the consumable is minted through the + // production lot path with the given quality per role. + private sealed class Run : IDisposable + { + public readonly CultCache Cache = RestoredHullsTests.OpenCatalog(); + public readonly Ship Ship; + public readonly Zone Zone; + + public Run() + { + Ship = RestoredHullsTests.BuildThrustedShip(Cache, "Djinni"); + Zone = Ship.Zone; + var hull = Cache.GetByName("Djinni"); + var hardpoint = Assert.Single(hull.Hardpoints, h => h.Type == HardpointType.Radiator); + var design = Cache.GetAll().First(g => hardpoint.IsFilledBy(g) && g.Behaviors.OfType().Any()); + var item = new EquippableItem + { + Data = Cache.RefOf(design), Durability = design.Durability, + Lot = Ship.ItemManager.Lots.Add(new Lot { Design = Cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + Assert.True(Ship.TryEquip(item, hardpoint.Position)); + Zone.Update(0f); + } + + public void Dispose() => Cache.Dispose(); + + public IEnumerable With() where T : BehaviorData => Ship.Equipment.Where(e => e.Data.Behaviors.OfType().Any()); + + public float Read(EquippedItem item, Func stat) where T : BehaviorData => + item.Evaluate(stat(item.Data.Behaviors.OfType().First())); + + public void Activate(ConsumableItemData design, float propellantOrNozzleQuality, float regulatorOrCoolantQuality) + { + var first = design.Roles[0].Name; + var second = design.Roles[1].Name; + var lot = Ship.ItemManager.Lots.Add(new Lot + { + Design = Cache.RefOf(design), Origin = new Attributed(), Quality = .5f, + Roles = new List + { + new RoleFill { Role = first, Quality = propellantOrNozzleQuality }, + new RoleFill { Role = second, Quality = regulatorOrCoolantQuality }, + } + }); + Ship.ActivateConsumable(new ConsumableItem { Data = Cache.RefOf(design), Lot = lot }); + } + + public void Step(int ticks) + { + for (var i = 0; i < ticks; i++) Zone.Update(Tick); + } + } + + private static float TopSpeed(Run run) => run.Read(run.With().First(), d => d.TopSpeed); + + // Quality rises the benefit and lowers the cost: each role's quality picks its own stats' place in Min..Max. + [Theory] + [InlineData(1f, 0f, 4.5f, 3f, 5f, 5f)] + [InlineData(0f, 1f, 3f, 2f, 3f, 3f)] + public void TheOverdriveLocksAndBoosts(float propellant, float regulator, float thrust, float topSpeed, float heat, float visibility) + { + using var run = new Run(); + var design = Design(run.Cache, OverdriveName); + var drives = run.With().ToArray(); + Assert.NotEmpty(drives); + var baseThrust = drives.Select(d => run.Read(d, t => t.Thrust)).ToArray(); + var baseHeat = drives.Select(d => run.Read(d, t => t.Heat)).ToArray(); + var baseVisibility = drives.Select(d => run.Read(d, t => t.Visibility)).ToArray(); + var baseTopSpeed = TopSpeed(run); + Assert.All(baseThrust.Concat(baseHeat).Concat(baseVisibility).Append(baseTopSpeed), v => Assert.True(v > 0f)); + Assert.False(run.Ship.ThrottleLocked); + var entries = run.Ship.Resolver.ModifierEntryCount; + + run.Activate(design, propellant, regulator); + Assert.True(run.Ship.ThrottleLocked); + run.Step(2); // the first tick executes the modifiers and the second applies them + + // Heat wears a drive slightly while it burns at full input, so the scale is read within 3 percent. + for (var i = 0; i < drives.Length; i++) + { + Assert.InRange(run.Read(drives[i], t => t.Thrust) / baseThrust[i], thrust * .97f, thrust * 1.03f); + Assert.InRange(run.Read(drives[i], t => t.Heat) / baseHeat[i], heat * .97f, heat * 1.03f); + Assert.InRange(run.Read(drives[i], t => t.Visibility) / baseVisibility[i], visibility * .97f, visibility * 1.03f); + } + Assert.InRange(TopSpeed(run) / baseTopSpeed, topSpeed * .97f, topSpeed * 1.03f); + Assert.True(run.Ship.ThrottleLocked); + + run.Step(21); // 5.75 s in: the last tick before the effect runs out + Assert.True(run.Ship.ThrottleLocked); + Assert.InRange(TopSpeed(run) / baseTopSpeed, topSpeed * .97f, topSpeed * 1.03f); + + run.Step(1); // 6 s: the effect is spent, the lock goes with it, and no modifier outlives it + Assert.False(run.Ship.ThrottleLocked); + Assert.Null(run.Ship.FindActiveConsumable(design)); + Assert.Equal(entries, run.Ship.Resolver.ModifierEntryCount); + for (var i = 0; i < drives.Length; i++) + { + Assert.InRange(run.Read(drives[i], t => t.Thrust) / baseThrust[i], 0f, 1.2f); + Assert.InRange(run.Read(drives[i], t => t.Heat) / baseHeat[i], 0f, 1.2f); + Assert.InRange(run.Read(drives[i], t => t.Visibility) / baseVisibility[i], 0f, 1.2f); + } + Assert.InRange(TopSpeed(run) / baseTopSpeed, 0f, 1.2f); + } + + [Theory] + [InlineData(1f, 0f, 1.5f, .98f, 14f, .3f)] + [InlineData(0f, 1f, 1f, .90f, 20f, .5f)] + public void TheCoolantVentsACloud(float nozzle, float coolant, float radiusOfMax, float opacity, float lifetime, float emissivity) + { + using var run = new Run(); + var design = Design(run.Cache, VentName); + var radiatorItem = run.With().First(); + var baseEmissivity = run.Read(radiatorItem, r => r.Emissivity); + Assert.True(baseEmissivity > 0f); + var entries = run.Ship.Resolver.ModifierEntryCount; + Assert.Empty(run.Zone.Clouds); + + run.Activate(design, nozzle, coolant); + run.Step(2); + + var cloud = Assert.Single(run.Zone.Clouds); + var radiusMin = ((VapourDumpData) design.Behaviors[0]).Radius.Min; + Assert.Equal(radiusMin * radiusOfMax, cloud.Radius, 2); + Assert.Equal(opacity, cloud.Opacity, 3); + Assert.Equal(lifetime, cloud.Lifetime, 3); + Assert.InRange(run.Read(radiatorItem, r => r.Emissivity) / baseEmissivity, emissivity * .97f, emissivity * 1.03f); + + run.Step(57); // 14.75 s in: the penalty holds to the last tick + Assert.InRange(run.Read(radiatorItem, r => r.Emissivity) / baseEmissivity, emissivity * .97f, emissivity * 1.03f); + + run.Step(1); // 15 s: the coolant is back + Assert.Null(run.Ship.FindActiveConsumable(design)); + Assert.Equal(entries, run.Ship.Resolver.ModifierEntryCount); + Assert.InRange(run.Read(radiatorItem, r => r.Emissivity) / baseEmissivity, .97f, 1.03f); + } +}