diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index cd2589dc0..b16a33af8 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -29,22 +29,10 @@ public static class AetheriaStores cache.AllStoredDocuments.All(stored => stored.Descriptor != descriptor)); if (missing != null) throw new InvalidOperationException($"Catalog {catalogPath} has no {missing.SchemaName} record; catalog globals are authored, never invented."); - // R-heat (docs/stats-and-power-cut.md): every equippable design's heat response must describe a - // coherent range before anything reads it. Fails loudly, naming the item. - foreach (var data in cache.GetAll()) - { - StatValidation.ValidateHeatResponse(data); - StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); - // Cut 7 (docs/stats-and-power-cut.md): a stat naming a role its design lacks is refused. - StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); - } - // Cut 2 (docs/stats-and-power-cut.md): a consumable's own StatModifierData needs the same check; - // consumables carry no heat response, so this is not folded into the loop above. - foreach (var data in cache.GetAll()) - { - StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); - StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); - } + // Every catalog document is held to the one check Upsert applies on write: R-heat (docs/stats-and-power- + // cut.md), stat modifiers, role usage (Cut 7), and the fuse range rule. Fails loudly, naming the item. + foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); + foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); return cache; } catch @@ -85,6 +73,7 @@ public static class CultRecordRefs StatValidation.ValidateHeatResponse(data); StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); + StatValidation.ValidateFuse(data); } if (document is ConsumableItemData consumable) { diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 59d93c1ba..40e2beb83 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -752,6 +752,21 @@ public static class StatValidation "not exceed them"); } + // Operator ruling 2026-09-30 (Soul): a round with a blast that could burst on its own shooter is refused at + // the catalog rather than policed at runtime. A weapon that detonates (a fuse and a blast radius above zero, + // the two FireControl.Fire freezes together) must reach farther than its blast, at the weakest value its Range + // stat can take: a no-lock round bursts at Range, so Range <= BlastRadius would put the shooter inside the + // burst, and Range <= 0 is a burst on the muzzle. A fuse without a radius is inert and needs no range. + public static void ValidateFuse(EquippableItemData data) + { + if (!(data is WeaponItemData weapon) || weapon.Fuse == null || !(weapon.BlastRadius > 0f)) return; + foreach (var behavior in weapon.Behaviors) + if (behavior is WeaponData weaponData && weaponData.Range.Min <= weapon.BlastRadius.Value) + throw new InvalidOperationException( + $"{weapon.Name}: fused weapon's Range {weaponData.Range.Min} does not exceed its BlastRadius " + + $"{weapon.BlastRadius.Value} -- a burst at max range would land on the shooter"); + } + // Cut 2 (docs/stats-and-power-cut.md): a StatReference names its target by (type name, field name) so a // modifier can point at any design or behaviour without a hard type reference -- but before this cut an // unresolvable reference failed silently: StatModifier.Initialize left `_stats` null and the first diff --git a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs index e0fec193d..e23953192 100644 --- a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs +++ b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs @@ -190,6 +190,60 @@ public sealed class AetheriaStoresTests : IDisposable Assert.Null(record); } + // Operator ruling 2026-09-30 (Soul): a weapon that detonates must reach farther than its blast, at the + // weakest value its Range stat can take, or a max-range burst lands on the shooter. Refused where every + // catalog write and every open already checks, not policed at runtime. + private static WeaponItemData FusedWeapon(string name, float rangeMin, float rangeMax, float? blastRadius, WeaponFuse? fuse) => new WeaponItemData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, BlastRadius = blastRadius, + Behaviors = { new InstantWeaponData { Range = new PerformanceStat { Min = rangeMin, Max = rangeMax } } } + }; + + // Kills: a comparison that admits equality (Range == BlastRadius bursts on the shooter's own edge); reading + // Range.Max instead of Min (the second row: Max 100 clears the blast, the weakest value does not); a + // zero range with a positive blast. + [Theory] + [InlineData(3f, 3f, 3f)] + [InlineData(2f, 2f, 3f)] + [InlineData(2f, 100f, 3f)] + [InlineData(0f, 0f, .5f)] + public void ValidateRefusesAFusedWeaponWhoseRangeDoesNotExceedItsBlast(float rangeMin, float rangeMax, float radius) + { + var error = Assert.Throws(() => + CultRecordRefs.Validate(FusedWeapon("ShortFuse", rangeMin, rangeMax, radius, WeaponFuse.Proximity))); + Assert.Contains("ShortFuse", error.Message); + } + + // A fuse without a radius, a radius without a fuse, and a range that clears the blast are all fine: only a + // weapon that will actually detonate is held to the rule. + [Theory] + [InlineData(1f, 1f, null, WeaponFuse.Contact)] + [InlineData(1f, 1f, 0f, WeaponFuse.Contact)] + [InlineData(1f, 1f, 30f, null)] + [InlineData(60f, 60f, 4f, WeaponFuse.Delayed)] + public void ValidateAcceptsAWeaponThatCannotBurstOnItsShooter(float rangeMin, float rangeMax, float? radius, WeaponFuse? fuse) + { + var record = Record.Exception(() => CultRecordRefs.Validate(FusedWeapon("Fine", rangeMin, rangeMax, radius, fuse))); + Assert.Null(record); + } + + // Open holds a catalog already on disk to the same rule Upsert applies on write. The bad record is written + // past Upsert, straight through the cache, the way a hand-edited or migrated catalog would carry one. + [Fact] + public void OpenRefusesAFusedWeaponWhoseRangeDoesNotExceedItsBlast() + { + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.UpsertAsync(FusedWeapon("OnDisk", 2f, 2f, 3f, WeaponFuse.Contact)).Wait(); + cache.FlushAsync().Wait(); + } + + var error = Assert.Throws(() => AetheriaStores.Open(Catalog)); + Assert.Contains("OnDisk", error.Message); + } + // A document type Validate has no opinion about (neither EquippableItemData nor ConsumableItemData) must be // a pure no-op, not a crash -- the three tools' change lists carry `object`, and not every migration touches // an equippable design (e.g. a future repair over FactionProductData).