AetherDb launch-suppressor: two Tool designs (Launch Suppressor, Launch Baffle) and six products, with catalog and command tests
The catalog tests need the applied catalog; this commit does not pass them until the next commit lands the apply output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fa6108de7d
commit
de6dbce70f
3 changed files with 552 additions and 1 deletions
289
tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs
Normal file
289
tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
|
||||
// The two launch suppressors the catalog sells (campaign aetheria-ew, cut ew-hide-launch-catalog; rulings
|
||||
// ew-suppressor-strength-ruled, ew-suppressor-makers-ruled, ew-mild-suppressor-makers-defaulted), read from the shipped catalog and
|
||||
// fitted to shipped hulls: what the designs say, that quality moves only the stat it is authored on, and what a player feels. The
|
||||
// strong Launch Suppressor hides a launch from the target it is fired at and gives out after an opening volley; the mild Launch
|
||||
// Baffle leaves the target its sight, hides the launch from the crowd and lasts a fight. The entity-level mechanism belongs to
|
||||
// ContactRevealTests; this file owns the catalog's figures.
|
||||
public sealed class LaunchSuppressorCatalogTests
|
||||
{
|
||||
internal const string StrongName = "Launch Suppressor";
|
||||
internal const string MildName = "Launch Baffle";
|
||||
private const float Flash = 1000f;
|
||||
private const float Tick = .25f; // exact in floats: four ticks are one second
|
||||
|
||||
private static GearData Design(CultCache cache, string name) => Assert.Single(cache.GetAll<GearData>(), g => g.Name == name);
|
||||
|
||||
private static LaunchSuppressorData Data(GearData design) => Assert.Single(design.Behaviors.OfType<LaunchSuppressorData>());
|
||||
|
||||
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);
|
||||
Assert.Equal(1f, term.Exponent); // linear in the role's quality: the design's range is the whole of what quality changes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LaunchSuppressorDesignsAreAuthored()
|
||||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var computer = Design(cache, "Targeting Computer");
|
||||
var designs = new[]
|
||||
{
|
||||
(Design: Design(cache, StrongName), Price: 40000, Factor: (.08f, .03f), Heat: (1.4f, .8f), Rated: (310f, 330f)),
|
||||
(Design: Design(cache, MildName), Price: 25000, Factor: (.4f, .2f), Heat: (.8f, .5f), Rated: (310f, 330f)),
|
||||
};
|
||||
foreach (var (design, price, factor, heat, rated) in designs)
|
||||
{
|
||||
Assert.Equal(HardpointType.Tool, design.Hardpoint);
|
||||
Assert.Equal((1, 1), (design.Shape.Width, design.Shape.Height));
|
||||
Assert.True(design.Shape.Cells[0, 0], "the one cell is solid");
|
||||
Assert.Equal(60f, design.Mass);
|
||||
Assert.Equal(50f, design.Durability);
|
||||
Assert.Equal(price, design.Price);
|
||||
Assert.False(string.IsNullOrWhiteSpace(design.Description));
|
||||
Assert.Equal(new[] { "baffles", "heat sink" }, design.Roles.Select(r => r.Name).ToArray());
|
||||
// The thermal constants and temperatures are the Targeting Computer's, the catalog's 1x1 tool.
|
||||
Assert.Equal(
|
||||
(computer.SpecificHeat, computer.Conductivity, computer.MinimumTemperature, computer.MaximumTemperature, computer.OptimalTemperature, computer.PlateauWidth, computer.ThermalResilience),
|
||||
(design.SpecificHeat, design.Conductivity, design.MinimumTemperature, design.MaximumTemperature, design.OptimalTemperature, design.PlateauWidth, design.ThermalResilience));
|
||||
var data = Data(design);
|
||||
AssertStat(data.Factor, factor.Item1, factor.Item2, "baffles");
|
||||
AssertStat(data.HeatPerUnit, heat.Item1, heat.Item2, "heat sink");
|
||||
AssertStat(data.RatedTemperature, rated.Item1, rated.Item2, "heat sink");
|
||||
CultRecordRefs.Validate(design);
|
||||
}
|
||||
|
||||
// Exactly these six products and these makers, each authoring both roles at its maker's own mean and deviation.
|
||||
var profiles = new Dictionary<string, (float Mean, float Dev)>
|
||||
{
|
||||
["NiteLife"] = (.55f, .12f), ["Finch"] = (.65f, .20f), ["DME"] = (.55f, .20f), ["R&D"] = (.60f, .15f),
|
||||
["Lightsail"] = (.55f, .10f), ["Alakrita"] = (.60f, .14f),
|
||||
};
|
||||
foreach (var (name, makers) in new[]
|
||||
{
|
||||
(StrongName, new[] { "DME", "Finch", "NiteLife", "R&D" }),
|
||||
(MildName, new[] { "Alakrita", "Lightsail" }),
|
||||
})
|
||||
{
|
||||
var key = cache.RefOf(Design(cache, name)).Key;
|
||||
var products = cache.GetAll<FactionProductData>().Where(p => p.Design.Key.Equals(key)).ToArray();
|
||||
Assert.Equal(makers, products.Select(p => cache.Get(p.Manufacturer).ShortName).OrderBy(m => m, StringComparer.Ordinal).ToArray());
|
||||
Assert.Equal(products.Length, products.Select(p => p.Name).Distinct().Count());
|
||||
Assert.All(products, product =>
|
||||
{
|
||||
var (mean, dev) = profiles[cache.Get(product.Manufacturer).ShortName];
|
||||
Assert.Equal(new[] { "baffles", "heat sink" }, product.Roles.Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray());
|
||||
Assert.All(product.Roles, r =>
|
||||
{
|
||||
Assert.Equal(mean, r.Mean, 3);
|
||||
Assert.Equal(dev, r.StandardDeviation, 3);
|
||||
});
|
||||
Assert.False(string.IsNullOrWhiteSpace(product.Description));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A shipped hull with its drives, a reactor, a radiator and the named suppressor fitted before the ship goes live (a ship
|
||||
// cannot be equipped once active), minted through the production lot path with a quality for each of the suppressor's roles.
|
||||
// The recipe is RestoredHullsTests.BuildThrustedShip's; the suppressor takes the first free interior space, as generation does.
|
||||
internal sealed class Rig : IDisposable
|
||||
{
|
||||
public readonly CultCache Cache = RestoredHullsTests.OpenCatalog();
|
||||
public readonly Ship Ship;
|
||||
public readonly EquippedItem Suppressor;
|
||||
public readonly LaunchSuppressorData Data;
|
||||
public readonly int2 Cell;
|
||||
|
||||
public Rig(string hullName, string designName, float baffles = .5f, float sink = .5f)
|
||||
{
|
||||
var hull = Cache.GetByName<HullData>(hullName);
|
||||
var design = Design(Cache, designName);
|
||||
Data = LaunchSuppressorCatalogTests.Data(design);
|
||||
var settings = RestoredHullsTests.Settings();
|
||||
var ledger = new ProvenanceLedger();
|
||||
var suppressorLot = hull.Hardpoints.Count + 2;
|
||||
for (var i = 1; i <= suppressorLot; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f };
|
||||
ledger.Lots[suppressorLot] = new Lot
|
||||
{
|
||||
Origin = new Attributed(), Quality = .5f,
|
||||
Roles = new List<RoleFill> { new RoleFill { Role = "baffles", Quality = baffles }, new RoleFill { Role = "heat sink", Quality = sink } },
|
||||
};
|
||||
var items = new ItemManager(Cache, ledger, settings, _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
|
||||
Ship = new Ship(items, zone, new EquippableItem { Data = Cache.RefOf<ItemData>(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings);
|
||||
|
||||
var lot = 2;
|
||||
var thrusters = Cache.GetAll<GearData>().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray();
|
||||
foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster))
|
||||
{
|
||||
var thruster = thrusters.First(hardpoint.IsFilledBy);
|
||||
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(thruster), Durability = thruster.Durability, Lot = lot++ }, hardpoint.Position));
|
||||
}
|
||||
foreach (var type in new[] { HardpointType.Reactor, HardpointType.Radiator })
|
||||
{
|
||||
var hardpoint = hull.Hardpoints.First(h => h.Type == type);
|
||||
var filler = Cache.GetAll<GearData>().First(hardpoint.IsFilledBy);
|
||||
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(filler), Durability = filler.Durability, Lot = lot++ }, hardpoint.Position));
|
||||
}
|
||||
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(design), Durability = design.Durability, Lot = suppressorLot }));
|
||||
|
||||
zone.Admit(Ship, piloted: false);
|
||||
Ship.Position = float3.zero;
|
||||
zone.Update(0f);
|
||||
Suppressor = Ship.Equipment.Single(e => e.Data.Name == designName);
|
||||
Cell = Suppressor.InsetShape.Coordinates.Single();
|
||||
}
|
||||
|
||||
public void Dispose() => Cache.Dispose();
|
||||
|
||||
// Write one launch flash (a direct call: no update, so nothing cools between calls) and read back what was stored.
|
||||
public float Launch(float strength)
|
||||
{
|
||||
var source = new object();
|
||||
Ship.SetSignature(source, SignatureChannel.LaunchFlash, strength, Suppressor);
|
||||
return Ship.VisibilitySources[source].Strength;
|
||||
}
|
||||
|
||||
public float Reading(PerformanceStat stat) => Suppressor.Evaluate(stat);
|
||||
|
||||
public float Temperature => Ship.Temperature[Cell.x, Cell.y];
|
||||
|
||||
// The temperature the cell reaches after n direct launches of a flash while the suppressor is working: each removed unit
|
||||
// costs HeatPerUnit, spread over the cell's thermal mass. Counts the launches made while the cell is at or under its rating.
|
||||
public int OpeningVolley(float strength)
|
||||
{
|
||||
var factor = Math.Clamp(Reading(Data.Factor), 0f, 1f);
|
||||
var perCall = (strength - strength * factor) * Reading(Data.HeatPerUnit) / Ship.ThermalMass[Cell.x, Cell.y];
|
||||
var rated = Reading(Data.RatedTemperature);
|
||||
var count = 0;
|
||||
for (double t = Temperature; t <= rated; t += perCall) count++;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
// The number of launches, one a second with the hull ticking between them, that are suppressed before the first that is not.
|
||||
internal static int LeadingSuppressed(string hull, string design, float strength, int launches = 60)
|
||||
{
|
||||
using var rig = new Rig(hull, design);
|
||||
for (var i = 0; i < launches; i++)
|
||||
{
|
||||
if (rig.Launch(strength) >= strength) return i;
|
||||
for (var t = 0; t < 4; t++) rig.Ship.Zone.Update(Tick);
|
||||
}
|
||||
return launches;
|
||||
}
|
||||
|
||||
// A sensor ship at the origin: the observer. What it reveals is Entity.Sense's own test, a stored strength times its
|
||||
// passive information at the emitter's position over the settings' threshold.
|
||||
private sealed class Observer : IDisposable
|
||||
{
|
||||
private readonly CultCache _cache = RestoredHullsTests.OpenCatalog();
|
||||
private readonly Ship _ship;
|
||||
private readonly float _threshold = RestoredHullsTests.Settings().TargetDetectionInfoThreshold;
|
||||
|
||||
public Observer() => _ship = RestoredHullsTests.BuildThrustedShip(_cache, "Longinus", HardpointType.Sensors);
|
||||
|
||||
public void Dispose() => _cache.Dispose();
|
||||
|
||||
public bool Sees(float stored, float distance) => stored * _ship.PassiveInfo(new float2(distance, 0f), 1f) > _threshold;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> QualityGrid() =>
|
||||
from design in new[] { StrongName, MildName }
|
||||
from role in new[] { "baffles", "heat sink" }
|
||||
from quality in new[] { 0f, .5f, 1f }
|
||||
select new object[] { design, role, quality };
|
||||
|
||||
// The role under test takes the quality; the other role sits at a quality no row uses, so a stat read from the wrong role
|
||||
// lands somewhere else.
|
||||
[Theory]
|
||||
[MemberData(nameof(QualityGrid))]
|
||||
public void ShippedSuppressorQualityMovesItsStats(string designName, string role, float quality)
|
||||
{
|
||||
const float Decoy = .25f;
|
||||
var baffles = role == "baffles" ? quality : Decoy;
|
||||
var sink = role == "heat sink" ? quality : Decoy;
|
||||
using var rig = new Rig("Longinus", designName, baffles, sink);
|
||||
float Lerp(PerformanceStat stat, float q) => stat.Min + (stat.Max - stat.Min) * q;
|
||||
|
||||
var factor = rig.Reading(rig.Data.Factor);
|
||||
var perUnit = rig.Reading(rig.Data.HeatPerUnit);
|
||||
Assert.Equal(Lerp(rig.Data.Factor, baffles), factor, 4);
|
||||
Assert.Equal(Lerp(rig.Data.HeatPerUnit, sink), perUnit, 4);
|
||||
Assert.Equal(Lerp(rig.Data.RatedTemperature, sink), rig.Reading(rig.Data.RatedTemperature), 4);
|
||||
|
||||
var before = rig.Temperature;
|
||||
var stored = rig.Launch(Flash);
|
||||
Assert.Equal(Flash * factor, stored, 2);
|
||||
var expected = (Flash - stored) * perUnit / rig.Ship.ThermalMass[rig.Cell.x, rig.Cell.y];
|
||||
Assert.InRange(rig.Temperature - before, expected * .999f, expected * 1.001f);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Longinus")]
|
||||
[InlineData("Djinni")]
|
||||
public void StrongSuppressorHidesFromItsTargetAndStopsAfterAnOpeningVolley(string hull)
|
||||
{
|
||||
// From rest with no cooling: each call pays heat into one cell, so a few calls in the rating is crossed and every later
|
||||
// call is bright. The count is derived from the cell's thermal mass and the authored figures; the Djinni's margin is
|
||||
// under a kelvin, so it is never a literal.
|
||||
using var rig = new Rig(hull, StrongName);
|
||||
var expectedSuppressed = rig.OpeningVolley(Flash);
|
||||
Assert.InRange(expectedSuppressed, 2, 8);
|
||||
var stored = Enumerable.Range(0, 12).Select(_ => rig.Launch(Flash)).ToArray();
|
||||
var hidden = rig.Reading(rig.Data.Factor) * Flash;
|
||||
Assert.All(stored.Take(expectedSuppressed), s => Assert.Equal(hidden, s, 2));
|
||||
Assert.All(stored.Skip(expectedSuppressed), s => Assert.Equal(Flash, s));
|
||||
|
||||
// With the hull ticking, one launch a second, a light flash still gets hidden for a dozen launches.
|
||||
Assert.True(LeadingSuppressed(hull, StrongName, 300f) >= 12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StrongSuppressorHidesALaunchFromTheObserverAtTheTarget()
|
||||
{
|
||||
using var rig = new Rig("Longinus", StrongName);
|
||||
using var observer = new Observer();
|
||||
var hidden = rig.Launch(Flash);
|
||||
Assert.False(observer.Sees(hidden, 1500f)); // the shot leaves unseen
|
||||
Assert.True(observer.Sees(Flash, 1500f)); // the same shot with no suppressor is seen
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MildSuppressorLeavesTheTargetItsSightAndHidesFromTheCrowd()
|
||||
{
|
||||
using var mild = new Rig("Longinus", MildName);
|
||||
using var strong = new Rig("Longinus", StrongName);
|
||||
using var observer = new Observer();
|
||||
var dimmed = mild.Launch(Flash);
|
||||
Assert.Equal(300f, dimmed, 1); // mid quality: Factor .3
|
||||
Assert.True(observer.Sees(dimmed, 1500f)); // the target still sees the launch
|
||||
Assert.False(observer.Sees(dimmed, 6000f)); // a far observer loses it...
|
||||
Assert.True(observer.Sees(Flash, 6000f)); // ...and would have seen it unsuppressed
|
||||
Assert.False(observer.Sees(strong.Launch(Flash), 1500f)); // the strong design hides it from the target too
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Longinus")]
|
||||
[InlineData("Djinni")]
|
||||
public void MildSuppressorSurvivesALongerFightThanStrong(string hull)
|
||||
{
|
||||
foreach (var (flash, atLeast) in new[] { (300f, 30), (Flash, 8) })
|
||||
{
|
||||
var mild = LeadingSuppressed(hull, MildName, flash);
|
||||
var strong = LeadingSuppressed(hull, StrongName, flash);
|
||||
Assert.True(mild >= atLeast, $"{hull}: the mild design hid only {mild} launches of {flash}");
|
||||
Assert.True(mild > strong, $"{hull}: mild {mild} did not outlast strong {strong} at {flash}");
|
||||
}
|
||||
}
|
||||
}
|
||||
130
tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs
Normal file
130
tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using Xunit;
|
||||
|
||||
// AetherDb's launch-suppressor command (aetheria-ew, cut ew-hide-launch-catalog), run on a scratch copy of the shipped catalog with
|
||||
// the two designs and their six products removed from it. Apply must write what the shipped catalog holds, and a catalog that is
|
||||
// neither untouched nor fully authored is refused rather than completed.
|
||||
public sealed class LaunchSuppressorCommandTests : IDisposable
|
||||
{
|
||||
private static readonly string[] DesignNames = { LaunchSuppressorCatalogTests.StrongName, LaunchSuppressorCatalogTests.MildName };
|
||||
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "launch-suppressor-command-" + Guid.NewGuid().ToString("N"));
|
||||
private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc");
|
||||
|
||||
// The designs and products as the shipped catalog holds them, taken before they are removed from the scratch copy.
|
||||
private readonly (string Name, byte[] Bytes)[] _shippedDesigns;
|
||||
private readonly string[] _shippedProducts;
|
||||
|
||||
private static byte[] Pack(GearData design) =>
|
||||
MessagePackSerializer.Serialize(design, CultDocumentMessagePackSerialization.OptionsFor(typeof(GearData).Assembly));
|
||||
|
||||
private static string Describe(CultCache cache, FactionProductData product) =>
|
||||
$"{cache.Get(product.Design).Name} | {product.Name} | {cache.Get(product.Manufacturer).ShortName} | {product.Description} | " +
|
||||
string.Join(",", product.Roles.OrderBy(r => r.Role, StringComparer.Ordinal).Select(r => $"{r.Role}:{r.Mean}:{r.StandardDeviation}"));
|
||||
|
||||
public LaunchSuppressorCommandTests()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_root, "GameData"));
|
||||
Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources"));
|
||||
var shipped = AetherDb.FindRoot();
|
||||
File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath);
|
||||
File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset"));
|
||||
using var cache = new CultCache(TestCatalog.Registry());
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
|
||||
var designs = cache.GetAll<GearData>().Where(g => DesignNames.Contains(g.Name)).ToArray();
|
||||
Assert.Equal(DesignNames.Length, designs.Length);
|
||||
_shippedDesigns = designs.Select(d => (d.Name, Pack(d))).ToArray();
|
||||
var keys = designs.Select(d => cache.RefOf(d).Key).ToArray();
|
||||
var products = cache.GetAll<FactionProductData>().Where(p => keys.Any(k => p.Design.Key.Equals(k))).ToArray();
|
||||
_shippedProducts = products.Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
Assert.Equal(6, products.Length);
|
||||
foreach (var product in products) Assert.True(cache.Remove(cache.RefOf(product).Key));
|
||||
foreach (var key in keys) Assert.True(cache.Remove(key));
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
public void Dispose() => Directory.Delete(_root, true);
|
||||
|
||||
private (GearData[] Designs, string[] Products) Read()
|
||||
{
|
||||
var db = AetherDb.Open(root: _root);
|
||||
try
|
||||
{
|
||||
var designs = db.Cache.GetAll<GearData>().Where(g => DesignNames.Contains(g.Name)).ToArray();
|
||||
var keys = designs.Select(d => db.Cache.RefOf(d).Key).ToArray();
|
||||
var products = db.Cache.GetAll<FactionProductData>().Where(p => keys.Any(k => p.Design.Key.Equals(k))).ToArray();
|
||||
return (designs, products.Select(p => Describe(db.Cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
finally { db.Cache.Dispose(); }
|
||||
}
|
||||
|
||||
// Remove from the scratch catalog what the predicate picks: products by name, designs by name.
|
||||
private void Remove(Func<FactionProductData, bool> products, Func<GearData, bool> designs)
|
||||
{
|
||||
using var cache = new CultCache(TestCatalog.Registry());
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
|
||||
foreach (var product in cache.GetAll<FactionProductData>().Where(products).ToArray()) Assert.True(cache.Remove(cache.RefOf(product).Key));
|
||||
foreach (var design in cache.GetAll<GearData>().Where(designs).ToArray()) Assert.True(cache.Remove(cache.RefOf(design).Key));
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_dry_run_writes_nothing_and_apply_lands_the_shipped_designs_once()
|
||||
{
|
||||
var before = File.ReadAllBytes(CatalogPath);
|
||||
Assert.Empty(Read().Designs);
|
||||
|
||||
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: false, root: _root));
|
||||
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
|
||||
Assert.Empty(Read().Designs);
|
||||
|
||||
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: true, root: _root));
|
||||
var landed = File.ReadAllBytes(CatalogPath);
|
||||
Assert.NotEqual(before, landed);
|
||||
var (designs, products) = Read();
|
||||
// The record keys are CultCache's to mint and differ run to run; the designs and products do not.
|
||||
foreach (var (name, bytes) in _shippedDesigns)
|
||||
Assert.True(bytes.AsSpan().SequenceEqual(Pack(designs.Single(d => d.Name == name))), $"{name} differs from the shipped catalog's record");
|
||||
Assert.Equal(_shippedProducts, products);
|
||||
|
||||
// A second apply finds everything authored and writes nothing; so does a dry run.
|
||||
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: true, root: _root));
|
||||
Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
|
||||
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: false, root: _root));
|
||||
Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
|
||||
}
|
||||
|
||||
public enum PartialState { OneDesignOnly, DesignWithSomeProducts, ProductsWithoutTheirDesign }
|
||||
|
||||
[Theory]
|
||||
[InlineData(PartialState.OneDesignOnly)]
|
||||
[InlineData(PartialState.DesignWithSomeProducts)]
|
||||
[InlineData(PartialState.ProductsWithoutTheirDesign)]
|
||||
public void Apply_refuses_a_partly_authored_catalog_and_writes_nothing(PartialState state)
|
||||
{
|
||||
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: true, root: _root));
|
||||
switch (state)
|
||||
{
|
||||
case PartialState.OneDesignOnly:
|
||||
Remove(p => DesignOf(p) == DesignNames[1], g => g.Name == DesignNames[1]);
|
||||
break;
|
||||
case PartialState.DesignWithSomeProducts:
|
||||
Remove(p => p.Name == "Hush" || p.Name == "Low Profile", g => false);
|
||||
break;
|
||||
case PartialState.ProductsWithoutTheirDesign:
|
||||
Remove(p => false, g => g.Name == DesignNames[0]);
|
||||
break;
|
||||
}
|
||||
var partial = File.ReadAllBytes(CatalogPath);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => Program.LaunchSuppressorCatalog(apply: true, root: _root));
|
||||
Assert.Equal(partial, File.ReadAllBytes(CatalogPath));
|
||||
}
|
||||
|
||||
private string DesignOf(FactionProductData p) => p.Name is "Low Profile" or "Fast Pass Dim" ? DesignNames[1] : DesignNames[0];
|
||||
}
|
||||
|
|
@ -46,12 +46,13 @@ public static class Program
|
|||
case "smoke-rounds": return SmokeRoundsCatalog(args.Contains("apply"));
|
||||
case "roles-backfill": return RolesBackfill(args.Contains("apply"));
|
||||
case "consumables-first-set": return ConsumablesFirstSetCatalog(args.Contains("apply"));
|
||||
case "launch-suppressor": return LaunchSuppressorCatalog(args.Contains("apply"));
|
||||
case "pirates-faction":
|
||||
Console.OutputEncoding = new System.Text.UTF8Encoding(false);
|
||||
return PiratesFaction(args.Contains("apply"));
|
||||
case "verse-grammar": return VerseGrammarCommand.Run(args.Contains("apply"));
|
||||
default:
|
||||
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]");
|
||||
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -2247,4 +2248,135 @@ public static class Program
|
|||
return 0;
|
||||
}
|
||||
|
||||
// aetheria-ew cut ew-hide-launch-catalog (rulings ew-suppressor-strength-ruled, ew-suppressor-makers-ruled,
|
||||
// ew-mild-suppressor-makers-defaulted): two launch suppressors on the Tool hardpoint, one helper for both. The strong
|
||||
// Launch Suppressor hides a launch from its own target and gives out after an opening volley; the mild Launch Baffle
|
||||
// only dims the flash for distant observers and runs through a long fight. Every figure is one Quality term, linear in
|
||||
// its role (baffles for the Factor, heat sink for the heat), so quality moves the part and nothing else does. Thermal
|
||||
// constants and temperatures come from the Targeting Computer, the catalog's 1x1 tool, as the consumables do. Dry run
|
||||
// unless passed "apply".
|
||||
public static int LaunchSuppressorCatalog(bool apply, string root = null)
|
||||
{
|
||||
var db = AetherDb.Open(catalogWritable: apply, root: root);
|
||||
var designNames = new[] { "Launch Suppressor", "Launch Baffle" };
|
||||
var template = db.Cache.GetAll<GearData>().Single(g => g.Name == "Targeting Computer");
|
||||
|
||||
Faction Maker(string shortName) =>
|
||||
db.Cache.GetAll<Faction>().FirstOrDefault(f => f.ShortName == shortName) ?? throw new InvalidOperationException($"No faction with short name \"{shortName}\".");
|
||||
PerformanceStat Rolled(float min, float max, string role) => new PerformanceStat
|
||||
{
|
||||
Min = min,
|
||||
Max = max,
|
||||
Terms = new List<StatTerm> { new StatTerm { Source = StatSource.Quality, Exponent = 1f, Role = role } },
|
||||
};
|
||||
// Min applies at quality 0 of the role and Max at quality 1: a falling Factor and falling heat per unit are Min > Max.
|
||||
GearData Design(string name, string description, int price, (float Min, float Max) factor, (float Min, float Max) heatPerUnit, (float Min, float Max) rated)
|
||||
{
|
||||
var design = new GearData
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Hardpoint = HardpointType.Tool,
|
||||
Mass = 60f,
|
||||
Price = price,
|
||||
Durability = 50f,
|
||||
Shape = new Shape(1, 1),
|
||||
SpecificHeat = template.SpecificHeat,
|
||||
Conductivity = template.Conductivity,
|
||||
MinimumTemperature = template.MinimumTemperature,
|
||||
MaximumTemperature = template.MaximumTemperature,
|
||||
OptimalTemperature = template.OptimalTemperature,
|
||||
PlateauWidth = template.PlateauWidth,
|
||||
ThermalResilience = template.ThermalResilience,
|
||||
Roles = new[] { "baffles", "heat sink" }.Select(r => new ItemRole { Name = r }).ToList(),
|
||||
Behaviors = new List<BehaviorData>
|
||||
{
|
||||
new LaunchSuppressorData
|
||||
{
|
||||
Factor = Rolled(factor.Min, factor.Max, "baffles"),
|
||||
HeatPerUnit = Rolled(heatPerUnit.Min, heatPerUnit.Max, "heat sink"),
|
||||
RatedTemperature = Rolled(rated.Min, rated.Max, "heat sink"),
|
||||
},
|
||||
},
|
||||
};
|
||||
design.Shape.Cells[0, 0] = true;
|
||||
return design;
|
||||
}
|
||||
|
||||
var strong = Design(designNames[0], "Baffles and a heat sink remove nearly all of a weapon's launch flash, hiding the shot from its target. The removed light is dissipated as heat in the device's own cell; above its rated temperature it passes the full flash.",
|
||||
40000, (.08f, .03f), (1.4f, .8f), (310f, 330f));
|
||||
var mild = Design(designNames[1], "Baffles dim a weapon's launch flash to a fraction and a heat sink absorbs the difference. The target still sees the shot; distant observers do not. The sink sustains the effect through a long fight.",
|
||||
25000, (.4f, .2f), (.8f, .5f), (310f, 330f));
|
||||
var designs = new[] { strong, mild };
|
||||
|
||||
var products = new (GearData Design, string Name, string Maker, string Description)[]
|
||||
{
|
||||
(strong, "Velvet Rope", "NiteLife", "Keeps the flash on the inside. Nobody sees who you let out."),
|
||||
(strong, "Hush", "Finch", "Small, quiet, and the first shot is a secret. The second, probably."),
|
||||
(strong, "lights out", "DME", "no flash/ no name/ nobody saw a thing"),
|
||||
(strong, "Plausible Deniability", "R&D", "Hides 100% of launches!* *Launches defined as the ones nobody saw."),
|
||||
(mild, "Low Profile", "Lightsail", "Quiet on the manifest, quiet on the scope. A hauler nobody notices is a hauler that gets paid."),
|
||||
(mild, "Fast Pass Dim", "Alakrita", "Takes the glare off every launch and keeps the schedule. Anyone close enough to mind was already your problem."),
|
||||
};
|
||||
foreach (var product in products) Maker(product.Maker);
|
||||
|
||||
// Authored when both designs exist and every product is present on its own design; untouched when nothing is
|
||||
// present; anything between needs a human, a product name that belongs to an unrelated item included.
|
||||
var existing = designNames.Select(name => db.Cache.GetAll<GearData>().SingleOrDefault(g => g.Name == name)).ToArray();
|
||||
var productNames = products.Select(p => p.Name).ToHashSet();
|
||||
var presentProducts = db.Cache.GetAll<FactionProductData>().Where(p => productNames.Contains(p.Name)).ToArray();
|
||||
if (existing.All(d => d != null) && presentProducts.Length == products.Length
|
||||
&& products.All(p => presentProducts.Any(x => x.Name == p.Name && x.Design.Key.Equals(db.Cache.RefOf(existing[p.Design == strong ? 0 : 1]).Key))))
|
||||
{
|
||||
Console.WriteLine("The launch suppressors are already authored; nothing to do.");
|
||||
return 0;
|
||||
}
|
||||
if (existing.Any(d => d != null) || presentProducts.Length != 0)
|
||||
throw new InvalidOperationException("The launch suppressors are partly authored; the catalog needs a human.");
|
||||
|
||||
Console.WriteLine("New designs:");
|
||||
foreach (var design in designs)
|
||||
{
|
||||
var data = design.Behaviors.OfType<LaunchSuppressorData>().Single();
|
||||
Console.WriteLine($" {design.Name,-18} {design.Shape.Width}x{design.Shape.Height} {design.Hardpoint} mass {design.Mass:0.##} price {design.Price} durability {design.Durability:0.##} roles [{string.Join(", ", design.Roles.Select(r => r.Name))}]");
|
||||
foreach (var (label, stat) in new[] { ("Factor", data.Factor), ("HeatPerUnit", data.HeatPerUnit), ("RatedTemperature", data.RatedTemperature) })
|
||||
Console.WriteLine($" {label} {stat.Min:0.##}-{stat.Max:0.##} ({stat.Terms.Single().Role})");
|
||||
Console.WriteLine($" \"{design.Description}\"");
|
||||
}
|
||||
Console.WriteLine("\nNew products:");
|
||||
foreach (var (design, name, maker, description) in products)
|
||||
{
|
||||
var (mean, dev) = MakerProfiles[maker];
|
||||
Console.WriteLine($" {design.Name} -> \"{name}\" by {maker}, every role mean {mean:0.##} dev {dev:0.##}: {description}");
|
||||
}
|
||||
|
||||
if (!apply)
|
||||
{
|
||||
Console.WriteLine($"\nDry run. Pass \"apply\" to author 2 designs and {products.Length} products.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var design in designs) CultRecordRefs.Validate(design);
|
||||
db.Cache.Commit(batch =>
|
||||
{
|
||||
var keys = new Dictionary<GearData, CultRecordKey>();
|
||||
foreach (var design in designs) keys[design] = batch.Upsert(typeof(GearData), design);
|
||||
foreach (var (design, name, maker, description) in products)
|
||||
{
|
||||
var (mean, dev) = MakerProfiles[maker];
|
||||
batch.Upsert(typeof(FactionProductData), new FactionProductData
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Design = new CultRecordRef<CraftedItemData>(keys[design]),
|
||||
Manufacturer = db.Cache.RefOf(Maker(maker)),
|
||||
Roles = design.Roles.Select(r => new ProductRole { Role = r.Name, Mean = mean, StandardDeviation = dev }).ToList(),
|
||||
});
|
||||
}
|
||||
});
|
||||
Console.WriteLine($"\nAuthored 2 designs and {products.Length} products in Aetheria.cc");
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue