Mines are flat by data: refuse falloff on a mine layer, Lay reads no factor; clamp test

A mine layer carries no EffectiveRange or DamageAtMaxRange (StatValidation.ValidateMineLayerFlat, run wherever the other catalog rules run), so RangeFactor is 1 inside its gate for the mine, the AI and the panel without any reader branching on type; Lay's damage is the weapon's damage again. Adds a test that fails without Deliver's min(reach, MaxRange) clamp. Not built or tested yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-14 09:06:20 +02:00
commit aff6086c01
5 changed files with 64 additions and 20 deletions

View file

@ -81,12 +81,14 @@ public static class CultRecordRefs
StatValidation.ValidateHeatResponse(data);
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
StatValidation.ValidateMunitionParts(data.Name, data.Behaviors);
StatValidation.ValidateMineLayerFlat(data.Name, data.Behaviors);
StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors);
}
if (document is ConsumableItemData consumable)
{
StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors);
StatValidation.ValidateMunitionParts(consumable.Name, consumable.Behaviors);
StatValidation.ValidateMineLayerFlat(consumable.Name, consumable.Behaviors);
StatValidation.ValidateRoleUsage(consumable.Name, consumable.Roles, consumable.Behaviors);
}
}

View file

@ -797,9 +797,9 @@ public static class FireControl
FuseDelay = data.FuseDelay,
Lifetime = data.Lifetime,
BlastRadius = blastRadius,
// A mine is laid at the layer, distance 0: it goes through the same falloff as every other payload, so a layer's
// authored ranges decide its mines, the AI's reading of it and its panel alike.
Damage = weapon.Damage * weapon.RangeFactor(0f),
// Mines are flat (ruling effective-range-authoring-ruled): a mine layer carries no falloff authoring
// (StatValidation.ValidateMineLayerFlat), so its figure is the AI's and the panel's at every distance it can engage.
Damage = weapon.Damage,
DamageType = weapon.WeaponData.DamageType,
Layer = source,
LayerFaction = source.Faction

View file

@ -840,6 +840,21 @@ public static class StatValidation
}
}
// Ruling effective-range-authoring-ruled: mines are flat. A mine layer's Range and MinRange gate where it may lay; its
// damage meets no range, so it carries no falloff authoring, and WeaponData.RangeFactor reads 1 inside the gate for the
// mine, the AI and the panel alike. Refused here, with the other catalog rules, wherever those run.
public static void ValidateMineLayerFlat(string ownerName, IEnumerable<BehaviorData> behaviors)
{
foreach (var layer in (behaviors ?? Enumerable.Empty<BehaviorData>()).OfType<MineLayerData>())
{
var effective = layer.EffectiveRange;
if (effective == null || !float.IsPositiveInfinity(effective.Min) || !float.IsPositiveInfinity(effective.Max) || (effective.Terms != null && effective.Terms.Count > 0))
throw new InvalidOperationException($"{ownerName}: {nameof(MineLayerData)}.{nameof(WeaponData.EffectiveRange)} is authored, but a mine layer carries no damage falloff");
if (layer.DamageAtMaxRange != 0f)
throw new InvalidOperationException($"{ownerName}: {nameof(MineLayerData)}.{nameof(WeaponData.DamageAtMaxRange)} is authored, but a mine layer carries no damage falloff");
}
}
// Cut 7 (docs/stats-and-power-cut.md): "a stat naming a role its design lacks" must be refused, not silently
// fall back to the lot's own workmanship (Lot.QualityForRole already falls back for a role a lot never rolled
// -- that is the intended forward-compat path for an old lot meeting a newly-authored role. This is the other

View file

@ -67,7 +67,7 @@ public sealed class MineTests : IDisposable
private Lab Build(float blast = 4f, float arming = 2f, float fuse = 2f, float life = 30f, float spread = 0f, float velocity = 0f,
float damage = 100f, float range = 1000f, float cooldown = 1000f, Faction layerFaction = null,
WeaponFuse? fuseKind = WeaponFuse.Proximity, bool well = false, float3? layerAt = null, int magazine = 100,
float minRange = 0f, float effectiveRange = float.PositiveInfinity, float damageAtMaxRange = 0f)
float minRange = 0f)
{
var settings = new GameplaySettings
{
@ -93,7 +93,7 @@ public sealed class MineTests : IDisposable
{
ArmingDelay = arming, FuseDelay = fuse, Lifetime = life,
Damage = Constant(damage), Range = Constant(range), MinRange = Constant(minRange),
EffectiveRange = Constant(effectiveRange), DamageAtMaxRange = damageAtMaxRange, Velocity = Constant(velocity),
Velocity = Constant(velocity),
Spread = Constant(spread), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1),
BurstTime = Constant(0), Cooldown = Constant(cooldown), MagazineSize = magazine,
};
@ -547,22 +547,39 @@ public sealed class MineTests : IDisposable
Assert.True(angles.Max() > 10f && angles.Min() < -10f, "the angle spans +-Spread/2, not a narrower cone");
}
// A mine goes through the same falloff as every payload, read at its lay distance (0, at the layer): the layer's authored
// ranges decide the mine's damage, the AI's reading of the layer and the panel's chart alike. Authored flat to 10 with
// a .3 end, a mine layer with no minimum range lays a full-damage mine; with a minimum of 5 the layer has no reach at the
// lay point and the mine is worth nothing, in the AI's figure too. Kills: Lay without the factor, Lay reading it at any
// other distance (20 reads under 1 for both fixtures).
// Mines are flat (ruling effective-range-authoring-ruled): a layer's MinRange and Range gate where it may lay, and the one
// RangeFactor reads 1 inside them, so the mine's damage, the AI's figure and the panel's function agree at every distance.
[Theory]
[InlineData(0f, 100f)]
[InlineData(5f, 0f)]
public void ALayersAuthoredRangesDecideItsMinesAndTheAILikewise(float minRange, float damage)
[InlineData(0f)]
[InlineData(5f)]
public void AMineLayerIsFlatForTheMineTheAIAndThePanel(float minRange)
{
var lab = Build(damage: 100f, range: 1000f, minRange: minRange, effectiveRange: 10f, damageAtMaxRange: .3f, layerFaction: A);
Assert.NotEqual(damage, 100f * lab.Weapon.RangeFactor(20f));
var lab = Build(damage: 100f, range: 1000f, minRange: minRange, layerFaction: A);
var mine = LayOne(lab);
Assert.Equal(100f, mine.Damage, 3);
Assert.Equal(damage, mine.Damage, 3);
Assert.Equal(damage, lab.Weapon.RangeDamagePerSecond(0f) * lab.Weapon.Cooldown, 3);
var data = lab.Weapon.WeaponData;
foreach (var at in new[] { 5f, 10f, 100f, 500f, 999f })
{
Assert.Equal(mine.Damage, lab.Weapon.RangeDamagePerSecond(at) * lab.Weapon.Cooldown, 3);
Assert.Equal(mine.Damage, 100f * WeaponData.RangeFactor(data.MinRange.Max, data.EffectiveRange.Max, data.Range.Max, data.DamageAtMaxRange, at), 3);
}
}
// A falloff on a mine layer would act on nothing the mine does, so the catalog refuses it where it is authored.
[Theory]
[InlineData(10f, 0f)]
[InlineData(float.PositiveInfinity, .3f)]
public void AMineLayerAuthoringFalloffIsRefused(float effective, float end)
{
var item = new WeaponItemData
{
Name = "Mine Layer", Hardpoint = HardpointType.Sensors, Shape = new Shape(), BlastRadius = 4f,
Behaviors = { new MineLayerData { Range = Constant(1000f), EffectiveRange = Constant(effective), DamageAtMaxRange = end } }
};
var refusal = Assert.Throws<InvalidOperationException>(() => StatValidation.ValidateMineLayerFlat(item.Name, item.Behaviors));
Assert.Contains("falloff", refusal.Message);
Assert.Throws<InvalidOperationException>(() => CultRecordRefs.Validate(item));
}
[Fact]

View file

@ -116,13 +116,14 @@ public sealed partial class FireControlCut124Tests
// A no-lock fused round reads the falloff where its burst point is decided, at its commit: the shot right after the tick
// that committed it, still in the air (velocity 20). With a clear line it travels to Range ("bomb fishing") and lands at
// the end multiplier.
private float NoLockDamageAfterCommit(float effective, float blockerAt, out float travelled)
private float NoLockDamageAfterCommit(float effective, float blockerAt, out float travelled, float2? aim = null)
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, penetration: 1f,
damage: 100f, weaponRange: NoLockRange, effectiveRange: effective, damageAtMaxRange: .25f);
e.Shooter.SetTarget(TargetRef.None);
Aim(e, float2(0, 1));
if (blockerAt > 0f) AddShip(e, PointAlong(e, float2(0, 1), blockerAt), 100000f, 300);
var direction = aim ?? float2(0, 1);
Aim(e, direction);
if (blockerAt > 0f) AddShip(e, PointAlong(e, direction, blockerAt), 100000f, 300);
ShotOutcome outcome = null;
using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o);
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
@ -140,6 +141,15 @@ public sealed partial class FireControlCut124Tests
Assert.Equal(NoLockRange, travelled, 3);
}
// A clear line's burst point is planned at Range, but its planar distance can come out a rounding step past it (3.8e-6 at
// aim (1, 0) and Range 60); Deliver clamps that to Range, so the round still lands at the end multiplier (a non-zero end
// here: unclamped, the read past Range is 0).
[Fact]
public void ARoundFishedToRangeIsNeverReadPastRangeByRounding()
{
Assert.Equal(25f, NoLockDamageAfterCommit(effective: 20f, blockerAt: 0f, out _, aim: float2(1, 0)), 3);
}
// The round stops on the first hull ahead, 21 out (well inside a 30 effective range of a 60 range weapon): it delivers
// full damage there, not the end multiplier of the point it was planned to. With a shorter effective range (10) the
// factor is the lerp at the distance it actually travelled.