AetherDb smoke-rounds: the five product-less smoke weapons, dry run unless apply

Smoke Proximity, Smoke Delayed and Smoke Refused are Spectra clones with the operator's fuse values; Smoke Refused Beam is a constant weapon and Smoke Refused Charge a charged one cloned from ChargeBlast SG, each fused with a Range below its BlastRadius. No products. SmokeRoundsCommandTests covers dry run, apply and the no-op second apply.

The catalog and the arenas that carry the designs follow in the next commit. This commit builds; its command test needs no catalog change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 03:33:22 +02:00
commit 93d5a0c69c
2 changed files with 176 additions and 1 deletions

View file

@ -0,0 +1,91 @@
using System;
using System.IO;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using Xunit;
// AetherDb's smoke-rounds command (aetheria-release, cut scenarios-smoke-rounds), run on a scratch copy of the shipped catalog
// with the five smoke weapons removed from it.
public sealed class SmokeRoundsCommandTests : IDisposable
{
private static readonly string[] Names = { "Smoke Proximity", "Smoke Delayed", "Smoke Refused", "Smoke Refused Beam", "Smoke Refused Charge" };
private readonly string _root = Path.Combine(Path.GetTempPath(), "smoke-rounds-command-" + Guid.NewGuid().ToString("N"));
private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc");
public SmokeRoundsCommandTests()
{
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);
foreach (var weapon in cache.GetAll<WeaponItemData>().Where(w => Names.Contains(w.Name)).ToArray()) Assert.True(cache.Remove(cache.RefOf(weapon).Key));
cache.FlushAsync().Wait();
}
public void Dispose() => Directory.Delete(_root, true);
private (WeaponItemData[] Smoke, WeaponItemData Spectra, WeaponItemData ChargeBlast, int Products) Read()
{
var db = AetherDb.Open(root: _root);
try
{
var smoke = db.Cache.GetAll<WeaponItemData>().Where(w => Names.Contains(w.Name)).ToArray();
var keys = smoke.Select(w => db.Cache.RefOf(w).Key).ToArray();
var products = db.Cache.GetAll<FactionProductData>().Count(p => keys.Any(k => p.Design.Key.Equals(k)));
return (smoke, db.Cache.GetAll<WeaponItemData>().Single(w => w.Name == "Spectra"), db.Cache.GetAll<WeaponItemData>().Single(w => w.Name == "ChargeBlast SG"), products);
}
finally { db.Cache.Dispose(); }
}
[Fact]
public void A_dry_run_writes_nothing_and_apply_lands_five_productless_designs_once()
{
var before = File.ReadAllBytes(CatalogPath);
Assert.Empty(Read().Smoke);
Assert.Equal(0, Program.SmokeRoundsCatalog(apply: false, root: _root));
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
Assert.Empty(Read().Smoke);
Assert.Equal(0, Program.SmokeRoundsCatalog(apply: true, root: _root));
var landed = File.ReadAllBytes(CatalogPath);
Assert.NotEqual(before, landed);
var (smoke, spectra, chargeBlast, products) = Read();
Assert.Equal(Names.OrderBy(n => n), smoke.Select(w => w.Name).OrderBy(n => n));
Assert.Equal(0, products);
WeaponItemData Design(string name) => smoke.Single(w => w.Name == name);
WeaponData Data(string name) => Design(name).Behaviors.OfType<WeaponData>().Single();
// The operator's own values: the fuse and blast of each, Smoke Delayed's two cells of penetration, Smoke Refused's range of 20.
Assert.Equal((WeaponFuse.Proximity, 8f), (Design("Smoke Proximity").Fuse.Value, Design("Smoke Proximity").BlastRadius.Value));
Assert.Equal((WeaponFuse.Delayed, 4f, 2f, 2f), (Design("Smoke Delayed").Fuse.Value, Design("Smoke Delayed").BlastRadius.Value, Data("Smoke Delayed").Penetration.Min, Data("Smoke Delayed").Penetration.Max));
Assert.Equal((WeaponFuse.Proximity, 30f, 20f, 20f), (Design("Smoke Refused").Fuse.Value, Design("Smoke Refused").BlastRadius.Value, Data("Smoke Refused").Range.Min, Data("Smoke Refused").Range.Max));
// Spectra clones are energy, 1x2, with the Spectra's own gear; the charged one is a ChargeBlast SG clone on its 2x2 energy mount.
foreach (var name in new[] { "Smoke Proximity", "Smoke Delayed", "Smoke Refused", "Smoke Refused Beam" })
Assert.Equal((spectra.Hardpoint, spectra.Shape.Width, spectra.Shape.Height, spectra.Mass), (Design(name).Hardpoint, Design(name).Shape.Width, Design(name).Shape.Height, Design(name).Mass));
Assert.Equal((chargeBlast.Hardpoint, chargeBlast.Shape.Width, chargeBlast.Shape.Height), (Design("Smoke Refused Charge").Hardpoint, 2, 2));
// The beam is a constant weapon and the charge a charged one, each fused with a Range below its BlastRadius.
Assert.IsType<ConstantWeaponData>(Data("Smoke Refused Beam"));
Assert.IsType<ChargedWeaponData>(Data("Smoke Refused Charge"));
foreach (var name in new[] { "Smoke Refused Beam", "Smoke Refused Charge" })
{
Assert.NotNull(Design(name).Fuse);
Assert.True(Data(name).Range.Max < Design(name).BlastRadius, $"{name}'s range is not below its blast radius");
}
// A second apply finds them authored and writes nothing; so does a dry run.
Assert.Equal(0, Program.SmokeRoundsCatalog(apply: true, root: _root));
Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
Assert.Equal(0, Program.SmokeRoundsCatalog(apply: false, root: _root));
Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
Assert.Equal(5, Read().Smoke.Length);
}
}

View file

@ -43,6 +43,7 @@ public static class Program
case "pd-gear": return PdGearCatalog(args.Contains("apply"));
case "mine-launcher": return MineLauncherCatalog(args.Contains("apply"));
case "smoke-hull": return SmokeHullCatalog(args.Contains("apply"));
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 "pirates-faction":
@ -50,7 +51,7 @@ public static class Program
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], 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], pirates-faction [apply], verse-grammar [apply]");
return 1;
}
}
@ -1669,6 +1670,89 @@ public static class Program
return 0;
}
// aetheria-release cut scenarios-smoke-rounds (docs/scenarios-cut.md 5.3): the five smoke weapons the Fused Rounds, Refused
// Rounds and AI Fused Discipline arenas fit. None has a product, so no generated ship, station or shop offers one and only a
// scenario fits them. Smoke Proximity, Smoke Delayed and Smoke Refused are Spectra clones (energy, 1x2) with the operator's
// fuse values; Smoke Refused Beam is a constant weapon and Smoke Refused Charge a charged one cloned from ChargeBlast SG,
// each fused with a Range below its BlastRadius, so every refused weapon kind has a design. The rounds that fly are slow
// (Velocity 150) so a scenario can kill the target while one is in the air. Dry run unless passed "apply".
public static int SmokeRoundsCatalog(bool apply, string root = null)
{
var db = AetherDb.Open(catalogWritable: apply, root: root);
var weapons = db.Cache.GetAll<WeaponItemData>().ToArray();
var names = new[] { "Smoke Proximity", "Smoke Delayed", "Smoke Refused", "Smoke Refused Beam", "Smoke Refused Charge" };
var already = names.Count(name => weapons.Any(w => w.Name == name));
if (already == names.Length)
{
Console.WriteLine("The smoke rounds are already authored; nothing to do.");
return 0;
}
if (already != 0) throw new InvalidOperationException("The smoke rounds are partly authored; the catalog needs a human.");
WeaponItemData Template(string name) =>
weapons.FirstOrDefault(w => w.Name == name) ?? throw new InvalidOperationException($"No weapon design named \"{name}\".");
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(WeaponItemData).Assembly);
WeaponItemData Copy(string template, string name, string description, WeaponFuse fuse, float blast)
{
var source = Template(template);
var design = MessagePackSerializer.Deserialize<WeaponItemData>(MessagePackSerializer.Serialize(source, options), options);
design.Name = name;
design.Description = description;
design.Fuse = fuse;
design.BlastRadius = blast;
return design;
}
static PerformanceStat Fixed(float value) => new PerformanceStat { Min = value, Max = value, Terms = new List<StatTerm>() };
var proximity = Copy("Spectra", names[0], "A Spectra that bursts by proximity, blast 8. For scenarios; no one builds it.", WeaponFuse.Proximity, 8f);
var delayed = Copy("Spectra", names[1], "A Spectra that bursts after two cells of metal, blast 4. For scenarios; no one builds it.", WeaponFuse.Delayed, 4f);
var refused = Copy("Spectra", names[2], "A Spectra whose blast is wider than its reach, so it never fires. For scenarios; no one builds it.", WeaponFuse.Proximity, 30f);
var charge = Copy("ChargeBlast SG", names[4], "A ChargeBlast SG whose blast is wider than its reach, so it never charges. For scenarios; no one builds it.", WeaponFuse.Proximity, 30f);
var beam = Copy("Spectra", names[3], "A constant beam whose blast is wider than its reach, so it never starts. For scenarios; no one builds it.", WeaponFuse.Proximity, 30f);
foreach (var design in new[] { proximity, delayed })
design.Behaviors.OfType<InstantWeaponData>().Single().Velocity = Fixed(150f);
delayed.Behaviors.OfType<InstantWeaponData>().Single().Penetration = Fixed(2f);
refused.Behaviors.OfType<InstantWeaponData>().Single().Range = Fixed(20f);
charge.Behaviors.OfType<ChargedWeaponData>().Single().Range = Fixed(20f);
// The beam keeps the Spectra's gear (shape, hardpoint, thermal envelope, roles) and swaps its behaviour for a constant one.
var instant = beam.Behaviors.OfType<InstantWeaponData>().Single();
var constant = new ConstantWeaponData
{
DamageType = instant.DamageType, Damage = instant.Damage, Penetration = instant.Penetration, DamageSpread = instant.DamageSpread,
MinRange = instant.MinRange, Range = Fixed(20f), DamageCurve = instant.DamageCurve, EffectPrefab = instant.EffectPrefab,
Energy = instant.Energy, Heat = instant.Heat, Visibility = instant.Visibility, AmmoType = instant.AmmoType,
MagazineSize = instant.MagazineSize, ReloadTime = instant.ReloadTime, Spread = instant.Spread, Velocity = instant.Velocity,
Tracking = instant.Tracking
};
beam.Behaviors.Remove(instant);
beam.Behaviors.Add(constant);
var designs = new[] { proximity, delayed, refused, beam, charge };
Console.WriteLine("Adds 5 weapon designs, no products:");
foreach (var design in designs)
{
var data = design.Behaviors.OfType<WeaponData>().Single();
Console.WriteLine($" {design.Name,-20} {design.Hardpoint,-8} {design.Shape.Width}x{design.Shape.Height} {data.GetType().Name,-18} fuse {design.Fuse} blast {design.BlastRadius} range {data.Range.Min}-{data.Range.Max} velocity {data.Velocity.Min}");
}
if (!apply)
{
Console.WriteLine("
Dry run. Pass \"apply\" to author them.");
return 0;
}
foreach (var design in designs) CultRecordRefs.Validate(design);
db.Cache.Commit(batch =>
{
foreach (var design in designs) batch.Upsert(typeof(WeaponItemData), design);
});
Console.WriteLine($"
Authored {designs.Length} designs in Aetheria.cc");
return 0;
}
// sim-mines-presenter: the Mine Launcher item, so a ship can be fitted with the sim's mine layer. Its numbers
// are the 2021 record's (GameData/Legacy/AetherDB.2021-03-05.msgpack, recovered in 8c71a637: mass 75, price
// 125000, damage 50, range 2000, energy 15, heat 500, visibility 10, magazine 6, reload 5, spread 5, velocity