From 9135360bebfafece21cac04d6b7a6da2bcf9977d Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 01:58:59 +0000 Subject: [PATCH 1/6] AetherDb: weapon-ammo lists every weapon's energy, magazine, reload and ammunition type Read-only audit command, so ammunition rulings are checked against the catalog and not against names. Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/Program.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 99963e8dd..50f97c019 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,6 +36,7 @@ public static class Program case "brownout-migrate": return BrownoutMigrate(args.Contains("apply")); case "roles-migrate": return RolesMigrate(args.Contains("apply")); case "firing-arc-migrate": return FiringArcMigrate(args.Contains("apply")); + case "weapon-ammo": return WeaponAmmo(); case "range-falloff": return RangeFalloff(args.Skip(1).ToArray()); case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); @@ -1109,6 +1110,19 @@ public static class Program // and a damage multiplier at maximum range. Guided weapons, lock weapons and mine layers keep the unauthored // defaults (no falloff). The command upserts every weapon design, which is also what drops the retired damage curve // (key 7) from the stored records. Idempotent: the values are functions of Range alone. Dry run unless "apply". + // Read-only: what every weapon design draws per shot (reactor energy, magazine, rounds), so ammunition rulings are audited + // against the catalog and not against names. + private static int WeaponAmmo() + { + var db = AetherDb.Open(); + Console.WriteLine($"{"weapon",-28} {"behaviour",-20} {"energy",-11} {"mag",5} {"reload",6} ammo"); + foreach (var item in db.Cache.GetAll().OrderBy(i => i.Name, StringComparer.Ordinal)) + foreach (var w in item.Behaviors.OfType()) + Console.WriteLine($"{item.Name,-28} {w.GetType().Name,-20} {w.Energy.Min}-{w.Energy.Max,-6} {w.MagazineSize,5} {w.ReloadTime,6} " + + (w.AmmoType.IsSet() ? db.Cache.Get(w.AmmoType.Key)?.Name ?? w.AmmoType.Key.Value : "-")); + return 0; + } + private static int RangeFalloff(string[] args) { var apply = args.Contains("apply"); -- 2.47.3 From 8f77000844c38d457f131109451ae1cceb62a8c5 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 01:58:59 +0000 Subject: [PATCH 2/6] Launchers draw real rounds: Missiles commodity and recipe, a reload that pays its crate, a reserve readout Rulings ballistics-real-ammo and ballistics-cycle-power, follow-up launcher-ammunition. The Munitions batch adds the Missiles commodity (a high-explosive warhead and a rocket motor at the munitions works; guidance waits on the electronics chain) and sets AmmoType=Missiles on the five launcher designs; reactor energy per shot stays as authored. InstantWeapon: the reload used to refill the magazine on its timer and never reach the cargo branch in UseAmmo, so no ammunition-fed weapon ever paid a round. The refill now takes one unit of AmmoType from cargo (TakeReloadRound) and, with none, the magazine stays empty; UseAmmo only starts a reload when a unit is there. Weapon.Reserve exposes the cargo count and the HUD shows rounds/reserve for magazine weapons. Tests: shipped launchers all name Missiles and Missiles has a recipe; a fed weapon fires its magazine and no more without crates, reloads once per crate; Reserve equals the cargo count and falls per reload. The commodity count pin goes 22 to 23. Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/InstantWeapon.cs | 27 +++--- .../Scripts/ServerShared/Behaviors/Weapon.cs | 2 + Assets/Scripts/UI/HUD/SchematicDisplay.cs | 2 +- .../CommodityRecordsTests.cs | 2 +- .../FireControlPerWeaponTests.cs | 88 ++++++++++++++++++- .../LauncherAmmunitionCatalogTests.cs | 28 ++++++ tools/AetherDb/Economy/Munitions.cs | 16 ++++ 7 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/LauncherAmmunitionCatalogTests.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index da23bc107..c8587f72d 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -229,7 +229,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if (!_coolingDown || !(_cooldown < 0)) return false; _overshoot = min(-_cooldown * CountdownSpan, dt); _coolingDown = false; - if (_data.MagazineSize > 0 && _ammo == 0) + if (_data.MagazineSize > 0 && _ammo == 0 && TakeReloadRound()) { _ammo = _data.MagazineSize; OnReloadComplete?.Invoke(); @@ -239,6 +239,17 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo return true; } + // A reload pays a round (ruling ballistics-real-ammo): one unit of the weapon's AmmoType leaves the cargo as the magazine refills, + // and with none to take the magazine stays empty. A weapon with no AmmoType reloads free. + private bool TakeReloadRound() + { + if (!_data.AmmoType.IsSet()) return true; + var cargo = Entity.FindItemInCargo(_data.AmmoType.Key); + if (cargo == null) return false; + if (cargo.ItemsOfType[_data.AmmoType.Key][0] is SimpleCommodity simpleCommodity) cargo.Remove(simpleCommodity, 1); + return true; + } + private float CountdownSpan => max(ShortestCycle, _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown); // A countdown that starts outside a round (a charge failing) carries the last one's overshoot like every restart. @@ -265,18 +276,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo return true; } - var hasAmmo = true; - if (_data.AmmoType.IsSet()) - { - var cargo = Entity.FindItemInCargo(_data.AmmoType.Key); - if (cargo != null) - { - var item = cargo.ItemsOfType[_data.AmmoType.Key][0]; - if (item is SimpleCommodity simpleCommodity) - cargo.Remove(simpleCommodity, 1); - } - else hasAmmo = false; - } + // The reload starts only if a round is there to load; FinishCountdown takes it. + var hasAmmo = !_data.AmmoType.IsSet() || Entity.CountItemsInCargo(_data.AmmoType.Key) > 0; if(hasAmmo) { OnReloadBegin?.Invoke(); diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index cb42d6bd4..62e4f4b14 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -102,6 +102,8 @@ public abstract class Weapon : ActivatedBehavior public abstract float DamagePerSecond { get; } public abstract float RangeDamagePerSecond(float range); public abstract int Ammo { get; } + // Rounds in the ship's cargo that reload this weapon's magazine: crates of AmmoType, 0 when the weapon draws none. + public int Reserve => _data.AmmoType.IsSet() ? Entity.CountItemsInCargo(_data.AmmoType.Key) : 0; public WeaponData WeaponData => _data; public float RangeFactor(float at) => WeaponData.RangeFactor(MinRange, EffectiveRange, Range, _data.DamageAtMaxRange, at); diff --git a/Assets/Scripts/UI/HUD/SchematicDisplay.cs b/Assets/Scripts/UI/HUD/SchematicDisplay.cs index 57f545960..f5d6d8af3 100644 --- a/Assets/Scripts/UI/HUD/SchematicDisplay.cs +++ b/Assets/Scripts/UI/HUD/SchematicDisplay.cs @@ -236,7 +236,7 @@ public class SchematicDisplay : MonoBehaviour if (x.Weapon.WeaponData.AmmoType.IsSet()) { if(x.Weapon.WeaponData.MagazineSize > 1) - x.ListElement.AmmoLabel.text = x.Weapon.Ammo.ToString(); + x.ListElement.AmmoLabel.text = $"{x.Weapon.Ammo}/{x.Weapon.Reserve}"; else x.ListElement.AmmoLabel.text = _entity.CountItemsInCargo(x.Weapon.WeaponData.AmmoType.Key).ToString(); } diff --git a/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs b/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs index 2b2acfaa0..91bce1993 100644 --- a/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs +++ b/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs @@ -13,7 +13,7 @@ public sealed class CommodityRecordsTests public void ShippedCatalogOpensWithTheRetiredKeysGone() { using var cache = RestoredHullsTests.OpenCatalog(); - Assert.Equal(22, cache.GetAll().Count()); + Assert.Equal(23, cache.GetAll().Count()); Assert.Equal(63, cache.GetAll().Count()); Assert.Contains(cache.GetAll(), c => c.Name == "Ammo"); Assert.All(cache.GetAll(), c => Assert.False(string.IsNullOrEmpty(c.Name))); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index f2fc94777..0b5faa343 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -85,7 +85,7 @@ public sealed class FireControlPerWeaponTests : IDisposable (ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f, float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false, bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false, bool charged = false, bool auto = false, float damage = 100f, - float effective = float.PositiveInfinity, float damageAtMax = 0f, float velocity = 0f, float targetLayerHeight = 0f) + float effective = float.PositiveInfinity, float damageAtMax = 0f, float velocity = 0f, float targetLayerHeight = 0f, int? crates = null, float reload = 1f) { mounts ??= new[] { (ItemRotation.None, 0f) }; var hullData = new HullData @@ -144,10 +144,20 @@ public sealed class FireControlPerWeaponTests : IDisposable behavior.Heat = Constant(heat); behavior.Visibility = Constant(visibility); behavior.MagazineSize = magazine; + behavior.ReloadTime = reload; var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); _openCaches.Add(cache); cache.Upsert(new VerseGrammar { Revision = 1 }); + // An ammunition-fed weapon: it names the Missiles commodity, and the shooter carries `crates` of it in a bay. + if (crates.HasValue) + { + var bayShape = new Shape(4, 4); + foreach (var cell in bayShape.AllCoordinates) bayShape[cell] = true; + cache.Upsert(new CargoBayData { Name = "Bay", Shape = new Shape(), InteriorShape = bayShape, Price = 20, Durability = 4 }); + cache.Upsert(new SimpleCommodityData { Name = "Missiles", Shape = new Shape(), Price = 1, MaxStack = 100 }); + behavior.AmmoType = new CultRecordRef(cache.RefOf(cache.GetByName("Missiles")).Key); + } cache.Upsert(hullData); cache.Upsert(shooterHullData); cache.Upsert(new WeaponItemData @@ -220,6 +230,13 @@ public sealed class FireControlPerWeaponTests : IDisposable var item = shooter.Equipment.Single(x => x.EquippableItem == d); rig.Decoy = new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }; } + if (crates.HasValue) + { + var bay = new EquippedCargoBay(items, Make("Bay", lot++, 4), new int2(1, 1), shooter, "bay"); + shooter.CargoBays.Add(bay); + if (crates.Value > 0) + Assert.True(bay.TryStore(new SimpleCommodity { Data = items.ItemData.RefOf(items.ItemData.GetByName("Missiles")), Quantity = crates.Value })); + } var targeting = Make("Targeting", lot++, 1); Assert.True(shooter.TryFindSpace(targeting, out var tpos)); Assert.True(shooter.TryEquip(targeting, tpos)); @@ -1306,4 +1323,73 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.False(weapon.CanFire); } + + // ==== Ruling ballistics-real-ammo: an ammunition-fed weapon pays a round per magazine reload, no free reload ==== + + // One launcher-shaped weapon (magazine 2, reload 3 s, Missiles) held on its trigger at no target, `crates` of Missiles in the + // hold. Returns the rig and a counter of committed shots. + private (Rig Rig, Func Shots) AmmoRig(int crates) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 40f, magazine: 2, auto: true, crates: crates, reload: 3f); + r.Shooter.SetTarget(TargetRef.None); + Aim(r, float2(0, 1)); + var shots = 0; + r.Zone.ShotCommitted.Subscribe(_ => shots++); + r.Gun.Weapon.Activate(); + return (r, () => shots); + } + + private static void Run(Rig r, float seconds) + { + for (var t = 0f; t < seconds; t += .25f) { r.Gun.Weapon.Activate(); r.Zone.Update(.25f); } + } + + // With no crate in the hold the magazine is fired once and the gun then stays empty, however long the trigger is held. Kills: + // a reload that refills the magazine without taking a crate (UseAmmo or FinishCountdown refilling on the timer alone). + [Fact] + public void AnAmmunitionFedWeaponWithNoCratesFiresItsMagazineAndNoMore() + { + var (r, shots) = AmmoRig(crates: 0); + Run(r, 30f); + Assert.Equal(2, shots()); + Assert.Equal(0, r.Gun.Weapon.Ammo); + } + + // One crate is one reload: after ReloadTime the full magazine fires again, the crate is gone, and then the gun is empty. Kills: + // a reload that consumes no crate, or more than one, or refills a partial magazine. + [Fact] + public void OneCrateReloadsTheWholeMagazineOnceAfterTheReloadTime() + { + var (r, shots) = AmmoRig(crates: 1); + Run(r, 2f); + Assert.Equal(2, shots()); + Assert.Equal(1, r.Gun.Weapon.Reserve); + Run(r, 30f); + Assert.Equal(4, shots()); + Assert.Equal(0, r.Gun.Weapon.Reserve); + Assert.Equal(0, r.Gun.Weapon.Ammo); + } + + // The reserve a HUD shows is the cargo count of the weapon's ammunition, and it falls by one when the magazine reloads. Kills: a + // Reserve that reads a constant, the wrong commodity, or a count that ignores the stack quantity. + [Fact] + public void TheReserveIsTheCargoCountOfTheAmmunitionAndFallsByOnePerReload() + { + var (r, shots) = AmmoRig(crates: 3); + Assert.Equal(3, r.Gun.Weapon.Reserve); + Assert.Equal(3, r.Shooter.CountItemsInCargo(r.Gun.Weapon.WeaponData.AmmoType.Key)); + Run(r, 2f); + Assert.Equal(2, shots()); + Assert.Equal(3, r.Gun.Weapon.Reserve); + Run(r, 3.5f); + Assert.Equal(2, r.Gun.Weapon.Reserve); + } + + // A weapon with no ammunition type has no reserve. + [Fact] + public void AWeaponThatDrawsNoAmmunitionHasNoReserve() + { + var r = Build(magazine: 2); + Assert.Equal(0, r.Gun.Weapon.Reserve); + } } diff --git a/tests/Aetheria.Shared.Tests/LauncherAmmunitionCatalogTests.cs b/tests/Aetheria.Shared.Tests/LauncherAmmunitionCatalogTests.cs new file mode 100644 index 000000000..209a0caa9 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LauncherAmmunitionCatalogTests.cs @@ -0,0 +1,28 @@ +using System.Linq; +using GameCult.Caching; +using Xunit; + +// Ruling ballistics-real-ammo, follow-up launcher-ammunition: a launcher is not a free gun. Read from the shipped catalog through +// AetherDb's own open, so what is checked is what ships. +public sealed class LauncherAmmunitionCatalogTests +{ + private static bool IsLauncher(WeaponData data) => data is LauncherData || data is GuidedWeaponData || data is LockWeaponData; + + // Every launcher design reloads from the Missiles commodity, and Missiles can be made. Kills: a launcher authored (or a new one + // added) with AmmoType unset, which reloads for free forever; Missiles without a recipe, which no station would ever supply. + [Fact] + public void EveryShippedLauncherReloadsFromMissilesAndMissilesCanBeMade() + { + using var db = AetherDb.Open(); + var missiles = db.Cache.GetAll().Single(c => c.Name == "Missiles"); + var key = db.Cache.RefOf(missiles).Key; + var launchers = db.Cache.GetAll().SelectMany(i => i.Behaviors.OfType().Where(IsLauncher).Select(d => (i.Name, d))).ToArray(); + Assert.True(launchers.Length >= 5, $"fixture: the catalog holds its launchers ({launchers.Length})"); + foreach (var (name, data) in launchers) + { + Assert.True(data.AmmoType.IsSet(), $"{name} draws no ammunition"); + Assert.Equal(key, data.AmmoType.Key); + } + Assert.Contains(db.Cache.GetAll(), r => r.Output.Key.Equals(key) && r.Inputs.Count > 0); + } +} diff --git a/tools/AetherDb/Economy/Munitions.cs b/tools/AetherDb/Economy/Munitions.cs index 9f0dfd075..2630c2dac 100644 --- a/tools/AetherDb/Economy/Munitions.cs +++ b/tools/AetherDb/Economy/Munitions.cs @@ -141,6 +141,14 @@ public sealed class Munitions : EconomyBatch "The same powder as a gun charge, shaped to burn for seconds instead of milliseconds: missile stocks and gun stocks compete for one powder train.", new[] { "thruster" }, (powder, 2), (steel, 1)); + // Missiles: the crate a launcher design reloads from. One crate is one magazine reload (UseAmmo removes one unit of the weapon's + // AmmoType at an empty magazine), the same convention as Ammo for guns. A crate is a warhead and a motor per two missiles' worth + // of magazine; the guidance package (seeker and fin actuators) waits on the electronics chain, so the recipe does not yet draw it. + var missiles = Simple("missiles", "Missiles", "Crated guided munitions: a high-explosive warhead mated to a rocket motor on a launch rail. The crate a launcher reloads from; the rounds are real, only the reactor energy of the launch is not.", 8f, 1800); + Recipe(missiles, "munitions-works", 1200, 24, 2, + "A missile crate is a warhead, a motor and a seeker in one box, and the box is the arsenal's most counted object: Zhestokost ties each launcher crew's allowance to a serial range, so a cache of loose crates is either a deserter's or a quartermaster's crime.", + new string[0], (warhead, 1), (motor, 1)); + var dispenser = Compound("mine-dispenser", "Mine Dispenser", "A rack and ejector that lays mines in a pattern.", 20f, 1600); Recipe(dispenser, "fabrication", 900, 20, 1, "Little more than steel and actuators, which is why every convoy navy can build one and every blockade starts with one.", @@ -151,6 +159,14 @@ public sealed class Munitions : EconomyBatch foreach (var gun in new[] { "Light Machine Gun", "Autocannon", "Long Slug Rifle", "Charged Scattergun", "Slug Rifle", "Flak Gun", "Cluster Cannon", "Point Defense Gun" }) Design(gun, 1800, 30, "A ballistic gun is a barrel and a feed; the round brings the energy."); Design("Mine Launcher", 1800, 24, "A dispenser and the warheads it lays."); + + // Launchers draw real rounds (ruling ballistics-real-ammo): the reactor energy per shot stays as authored, and each magazine + // reload consumes one Missiles crate. + foreach (var launcher in new[] { "Guided Missile Launcher", "Long-Range Missile Rack", "Short-Range Missile Rack", "Incendiary Rocket Pod", "Swarm Rocket Pod" }) + _a.Edit(launcher, w => + { + foreach (var data in w.Behaviors.OfType()) data.AmmoType = new CultRecordRef(missiles.Key); + }); } private CultRecordRef FactionRef(string name) -- 2.47.3 From fdbf0b6851b8e52e1e581f74a416b91ca89caa09 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 01:58:59 +0000 Subject: [PATCH 3/6] GameData/Aetheria.cc: Munitions apply (Missiles commodity and recipe, AmmoType on five launchers) economy author Munitions apply on the latest origin/master catalog; rerun reports 0 added, 0 changed. sha256 d1c96c6ed8530a4e17246604580eca3a45666b3846d56d557f8869ce7c2cc2b3 Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 7a7b99f5c..5b9332c1f 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68b37cb55c012c9d1df97bbf0de5c0a56d1804ce5f16f498738cd4de51c88e4b -size 214588 +oid sha256:d1c96c6ed8530a4e17246604580eca3a45666b3846d56d557f8869ce7c2cc2b3 +size 213302 -- 2.47.3 From 386f29bf0d9c78f520402352357353380bb8b758 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 02:55:39 +0000 Subject: [PATCH 4/6] Generated ships carry the crates their weapons reload from; Missiles gets a cargo shape; comment and lore fixes LoadoutGenerator.GenerateShipLoadout, the one owner of NPC and generated-scenario fitting, now stocks AmmunitionCrates (3) of every distinct AmmoType the ship's weapons draw, after the consumable roll and without touching the random stream; it logs and goes on if no hold takes them. Missiles is authored with Ammo's 1x1 shape and a stack of 5 (a shapeless commodity cannot be stored at all). Tests: generated ships hold exactly the crates they draw and none of an ammunition they do not; a magazine weapon with no AmmoType still reloads free. weapon-ammo moves so RangeFalloff keeps its doc comment; the Munitions comment and lore now match the recipe (warhead and motor, magazines 2 to 288). Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/LoadoutGenerator.cs | 17 +++++++++ .../AmmunitionStockingTests.cs | 38 +++++++++++++++++++ .../FireControlPerWeaponTests.cs | 14 +++++++ tools/AetherDb/Economy/Munitions.cs | 18 +++++---- tools/AetherDb/Program.cs | 10 ++--- 5 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 9f4829eee..c82ec1300 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -24,6 +24,10 @@ public class LoadoutGenerator public const float ShipConsumableChance = .1f; public const int ShipConsumableUnits = 1; + // Ammunition-fed weapons reload from cargo (ruling ballistics-real-ammo), so a generated ship carries this many crates of every + // distinct AmmoType its weapons draw: enough to fight on past the first magazine, few enough to leave the rest of the hold. + public const int AmmunitionCrates = 3; + public LoadoutGenerator( ref Random random, ItemManager itemManager, @@ -56,6 +60,7 @@ public class LoadoutGenerator OutfitEntity(entity); EquipPickup(entity); StockConsumables(entity, ShipConsumableChance, ShipConsumableUnits); + StockAmmunition(entity); return EntitySerializer.Pack(entity); } @@ -162,6 +167,18 @@ public class LoadoutGenerator } } + // The crates the ship's weapons reload from: AmmunitionCrates of each distinct AmmoType, stopping when no hold takes another. + // Draws nothing from the random stream, so adding it moves no other roll. + private void StockAmmunition(Entity entity) + { + foreach (var key in entity.Weapons.Select(weapon => weapon.WeaponData.AmmoType).Where(type => type.IsSet()).Select(type => type.Key).Distinct()) + { + var crates = new SimpleCommodity { Data = new CultRecordRef(key), Quantity = AmmunitionCrates }; + if (!entity.CargoBays.Any(bay => bay.TryStore(crates))) + ItemManager.Log($"{entity.Name}: no room for {AmmunitionCrates} crates of {key.Value}."); + } + } + private static bool IsHeater(GearData item) => item.HardpointType == HardpointType.Tool && item.Behaviors.Any(b => b is ThermotoggleData { HighPass: false }) && diff --git a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs new file mode 100644 index 000000000..1fd278bfe --- /dev/null +++ b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs @@ -0,0 +1,38 @@ +using System.Linq; +using Xunit; + +// Ruling ballistics-real-ammo: generation stocks combatants with rounds, because a magazine weapon reloads from cargo and a ship with +// no crates fires one magazine and goes quiet. Measured on ships the production generator returns, over the shipped catalog. +public sealed partial class RunStartTests +{ + // Every generated ship whose weapons draw an ammunition type carries crates of each such type; a ship whose weapons draw none + // carries none of an ammunition commodity it does not draw. Kills: generation that stocks nothing (every NPC gun dies after one magazine), + // that stocks only one of several types, or that stocks ships with no use for them. + [Fact] + public void GeneratedShipsCarryTheAmmunitionTheirWeaponsDraw() + { + var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); + var shipHulls = LoadoutGenerator.SoldHulls(_cache).Where(h => h.HullType == HullType.Ship).Select(h => h.Name).ToList(); + var ammunition = _cache.GetAll().SelectMany(i => i.Behaviors.OfType()).Where(w => w.AmmoType.IsSet()) + .Select(w => w.AmmoType.Key).Distinct().ToArray(); + Assert.NotEmpty(ammunition); + var fed = 0; + foreach (var name in shipHulls) + for (var i = 0; i < 12; i++) + { + var pack = generator.GenerateShipLoadout(h => h.Name == name); + if (pack == null) continue; + var ship = EntitySerializer.Unpack(_items, null, pack); + var drawn = ship.Weapons.Select(w => w.WeaponData.AmmoType).Where(t => t.IsSet()).Select(t => t.Key).Distinct().ToArray(); + foreach (var key in drawn) + { + Assert.True(ship.CountItemsInCargo(key) > 0, $"{name}: a weapon draws {key.Value} and the hold has none"); + Assert.Equal(LoadoutGenerator.AmmunitionCrates, ship.CountItemsInCargo(key)); + } + foreach (var key in ammunition.Except(drawn)) + Assert.True(ship.CountItemsInCargo(key) == 0, $"{name}: no weapon draws {key.Value} and the hold carries it"); + if (drawn.Length > 0) fed++; + } + Assert.True(fed > 0, "fixture: some generated ship carries an ammunition-fed weapon"); + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 0b5faa343..690bd27d3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -1392,4 +1392,18 @@ public sealed class FireControlPerWeaponTests : IDisposable var r = Build(magazine: 2); Assert.Equal(0, r.Gun.Weapon.Reserve); } + + // A magazine weapon that names no ammunition still reloads free and keeps firing: only an AmmoType makes a reload cost a crate. + // Kills: TakeReloadRound refusing when AmmoType is unset, which would silence every unfed magazine gun after one magazine. + [Fact] + public void AMagazineWeaponWithNoAmmunitionTypeStillReloadsFree() + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 40f, magazine: 2, auto: true, reload: 3f); + r.Shooter.SetTarget(TargetRef.None); + Aim(r, float2(0, 1)); + var shots = 0; + r.Zone.ShotCommitted.Subscribe(_ => shots++); + Run(r, 30f); + Assert.True(shots > 4, $"an unfed magazine weapon fires past its first magazine, fired {shots}"); + } } diff --git a/tools/AetherDb/Economy/Munitions.cs b/tools/AetherDb/Economy/Munitions.cs index 2630c2dac..83cbee2f2 100644 --- a/tools/AetherDb/Economy/Munitions.cs +++ b/tools/AetherDb/Economy/Munitions.cs @@ -141,12 +141,16 @@ public sealed class Munitions : EconomyBatch "The same powder as a gun charge, shaped to burn for seconds instead of milliseconds: missile stocks and gun stocks compete for one powder train.", new[] { "thruster" }, (powder, 2), (steel, 1)); - // Missiles: the crate a launcher design reloads from. One crate is one magazine reload (UseAmmo removes one unit of the weapon's - // AmmoType at an empty magazine), the same convention as Ammo for guns. A crate is a warhead and a motor per two missiles' worth - // of magazine; the guidance package (seeker and fin actuators) waits on the electronics chain, so the recipe does not yet draw it. - var missiles = Simple("missiles", "Missiles", "Crated guided munitions: a high-explosive warhead mated to a rocket motor on a launch rail. The crate a launcher reloads from; the rounds are real, only the reactor energy of the launch is not.", 8f, 1800); - Recipe(missiles, "munitions-works", 1200, 24, 2, - "A missile crate is a warhead, a motor and a seeker in one box, and the box is the arsenal's most counted object: Zhestokost ties each launcher crew's allowance to a serial range, so a cache of loose crates is either a deserter's or a quartermaster's crime.", + // Missiles: the crate a launcher design reloads from. One crate is one magazine reload (the reload takes one unit of the weapon's + // AmmoType), the same convention as Ammo for guns, whatever the magazine holds (2 to 288 rounds across the five designs). A crate is + // a warhead and a motor; the guidance package (seeker and fin actuators) waits on the electronics chain, so the recipe does not + // yet draw it. Shape and stack match Ammo's, so a hold takes crates cell for cell. + var missiles = new SimpleCommodityData { Name = "Missiles", Shape = new Shape(), MaxStack = 5, Mass = 8f, Price = 1800, + Description = "Crated guided munitions: a high-explosive warhead mated to a rocket motor on a launch rail. The crate a launcher reloads from; the rounds are real, only the reactor energy of the launch is not." }; + _a.Put(missiles, CommodityKey("missiles")); + var missilesRef = new CultRecordRef(CommodityKey("missiles")); + Recipe(missilesRef, "munitions-works", 1200, 24, 2, + "A missile crate is a warhead and a motor in one box, and the box is the arsenal's most counted object: Zhestokost ties each launcher crew's allowance to a serial range, so a cache of loose crates is either a deserter's or a quartermaster's crime.", new string[0], (warhead, 1), (motor, 1)); var dispenser = Compound("mine-dispenser", "Mine Dispenser", "A rack and ejector that lays mines in a pattern.", 20f, 1600); @@ -165,7 +169,7 @@ public sealed class Munitions : EconomyBatch foreach (var launcher in new[] { "Guided Missile Launcher", "Long-Range Missile Rack", "Short-Range Missile Rack", "Incendiary Rocket Pod", "Swarm Rocket Pod" }) _a.Edit(launcher, w => { - foreach (var data in w.Behaviors.OfType()) data.AmmoType = new CultRecordRef(missiles.Key); + foreach (var data in w.Behaviors.OfType()) data.AmmoType = new CultRecordRef(CommodityKey("missiles")); }); } diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 50f97c019..5d3d18e93 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -1105,11 +1105,6 @@ public static class Program return 0; } - // Damage falloff (aetheria-release, damage-curve-fate-ruled): every gun and beam authors an effective range at a - // fraction of its Range (Min and Max scaled, Terms copied so the effective range wears and heats as the range does) - // and a damage multiplier at maximum range. Guided weapons, lock weapons and mine layers keep the unauthored - // defaults (no falloff). The command upserts every weapon design, which is also what drops the retired damage curve - // (key 7) from the stored records. Idempotent: the values are functions of Range alone. Dry run unless "apply". // Read-only: what every weapon design draws per shot (reactor energy, magazine, rounds), so ammunition rulings are audited // against the catalog and not against names. private static int WeaponAmmo() @@ -1123,6 +1118,11 @@ public static class Program return 0; } + // Damage falloff (aetheria-release, damage-curve-fate-ruled): every gun and beam authors an effective range at a + // fraction of its Range (Min and Max scaled, Terms copied so the effective range wears and heats as the range does) + // and a damage multiplier at maximum range. Guided weapons, lock weapons and mine layers keep the unauthored + // defaults (no falloff). The command upserts every weapon design, which is also what drops the retired damage curve + // (key 7) from the stored records. Idempotent: the values are functions of Range alone. Dry run unless "apply". private static int RangeFalloff(string[] args) { var apply = args.Contains("apply"); -- 2.47.3 From 5ec368c11ed1e006a93fc6821f4678efeb6325b4 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 02:55:39 +0000 Subject: [PATCH 5/6] GameData/Aetheria.cc: Munitions re-apply (Missiles shape and stack, recipe lore) economy author Munitions apply; rerun reports 0 changed. sha256 b737d1c74f48c3c0a2e911efd2824345c8491cd8a0c0d3754d78b85d3ea29c40 Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 5b9332c1f..e46487c5f 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d1c96c6ed8530a4e17246604580eca3a45666b3846d56d557f8869ce7c2cc2b3 -size 213302 +oid sha256:b737d1c74f48c3c0a2e911efd2824345c8491cd8a0c0d3754d78b85d3ea29c40 +size 213296 -- 2.47.3 From 35fa50f2561aef083dc9817ecbba772ebe9a1b6a Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 03:12:36 +0000 Subject: [PATCH 6/6] Generated turrets are stocked with the crates their weapons reload from GenerateTurretLoadout calls the same StockAmmunition as ships; an ammo-fed turret otherwise went quiet after one magazine. Test: generated turrets hold exactly the crates for each ammunition type they draw. Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/LoadoutGenerator.cs | 1 + .../AmmunitionStockingTests.cs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index c82ec1300..93c00aff9 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -76,6 +76,7 @@ public class LoadoutGenerator var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings); entity.Faction = Faction; OutfitEntity(entity); + StockAmmunition(entity); return EntitySerializer.Pack(entity) as OrbitalEntityPack; } diff --git a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs index 1fd278bfe..d1d0f1317 100644 --- a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs +++ b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs @@ -35,4 +35,23 @@ public sealed partial class RunStartTests } Assert.True(fed > 0, "fixture: some generated ship carries an ammunition-fed weapon"); } + + // A generated turret reloads from its own hold like a ship: every ammunition type its weapons draw has crates in it. Kills: a turret + // path that skips StockAmmunition, which leaves an ammo-fed turret quiet after one magazine. + [Fact] + public void GeneratedTurretsCarryTheAmmunitionTheirWeaponsDraw() + { + var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); + var fed = 0; + for (var i = 0; i < 60; i++) + { + var turret = EntitySerializer.Unpack(_items, null, generator.GenerateTurretLoadout()); + foreach (var key in turret.Weapons.Select(w => w.WeaponData.AmmoType).Where(t => t.IsSet()).Select(t => t.Key).Distinct()) + { + fed++; + Assert.Equal(LoadoutGenerator.AmmunitionCrates, turret.CountItemsInCargo(key)); + } + } + Assert.True(fed > 0, "fixture: some generated turret carries an ammunition-fed weapon"); + } } -- 2.47.3