From e3c9cbb2821ce37b00b7e07ed734548b7b2e5bf4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 13:27:42 +0200 Subject: [PATCH] Refuse a fused weapon whose Range does not exceed its BlastRadius Soul: a no-lock round bursts at Range, so Range <= BlastRadius (or a zero Range) lands the blast on the shooter. Made structurally impossible at the catalog rather than policed at runtime: StatValidation.ValidateFuse joins the checks CultRecordRefs.Validate runs on every Upsert, at the weakest value the Range stat can take. Only a weapon that will detonate (a Fuse and a BlastRadius above zero) is held to it. AetheriaStores.Open now calls that same Validate for every equippable and consumable document instead of carrying its own copy of the three checks, so Upsert and Open can no longer disagree. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 21 ++------ Assets/Scripts/ServerShared/ItemData.cs | 15 ++++++ .../AetheriaStoresTests.cs | 54 +++++++++++++++++++ 3 files changed, 74 insertions(+), 16 deletions(-) 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).