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:
parent
0c5d1f36d2
commit
aff6086c01
5 changed files with 64 additions and 20 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue