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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 13:27:42 +02:00
commit e3c9cbb282
3 changed files with 74 additions and 16 deletions

View file

@ -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<EquippableItemData>())
{
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<ConsumableItemData>())
{
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<EquippableItemData>()) CultRecordRefs.Validate(data);
foreach (var data in cache.GetAll<ConsumableItemData>()) 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)
{

View file

@ -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