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:
Meta Vens 2026-10-13 20:10:21 +02:00
commit de6dbce70f
3 changed files with 552 additions and 1 deletions

View 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}");
}
}
}

View 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];
}

View file

@ -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;
}
}