diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs new file mode 100644 index 000000000..716a41c39 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs @@ -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().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().Where(w => Names.Contains(w.Name)).ToArray(); + var keys = smoke.Select(w => db.Cache.RefOf(w).Key).ToArray(); + var products = db.Cache.GetAll().Count(p => keys.Any(k => p.Design.Key.Equals(k))); + return (smoke, db.Cache.GetAll().Single(w => w.Name == "Spectra"), db.Cache.GetAll().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().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(Data("Smoke Refused Beam")); + Assert.IsType(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); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index b24161383..572346d7c 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -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 ]... [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 ]... [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().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(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() }; + + 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().Single().Velocity = Fixed(150f); + delayed.Behaviors.OfType().Single().Penetration = Fixed(2f); + refused.Behaviors.OfType().Single().Range = Fixed(20f); + charge.Behaviors.OfType().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().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().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