Fire control Cut 6b: a shot's dice belong to the shot, and airburst resolves in the simulation
6.1 (Soul finding 6): Commit builds a local CultMath.Random from (Zone.CombatSeed, shot.ShotId) instead of reading and writing the shared ItemManager.Random stream. Zone exposes CombatSeed (the same expression that already seeds its own _random), so a shot's outcome is a pure function of zone identity and shot id -- immune to whatever else drew from the shared stream in between. 6.2 (Soul finding 5): airburst is wired into the simulation instead of back into Projectile.cs. PendingShot gains BurstPosition/BurstRadius, frozen at Fire time from PredictedIntercept and the weapon's authored WeaponItemData.AirburstRange when WeaponModifiers carries Airburst. FireControl.Step resolves such a shot via Splash instead of Apply, never both. Projectile.cs/ProjectileManager.cs drop the dead AirburstDistance/AirburstRange fields and write-back. Tests: FireControlCut6Tests.cs pins both rules (208 baseline + 5 new, all green). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
063804801f
commit
39ad4bfc80
6 changed files with 358 additions and 12 deletions
|
|
@ -4,8 +4,10 @@ using static CultMath.math;
|
|||
|
||||
// Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already
|
||||
// decided this shot's fate before this object was ever spawned (R8). What is left is pure flight-path
|
||||
// presentation: fly, and disappear at Range or ShotId's resolution, whichever comes first. Airburst
|
||||
// (AirburstDistance/AirburstRange) is Cut 4's -- untouched here.
|
||||
// presentation: fly, and disappear at Range or ShotId's resolution, whichever comes first.
|
||||
// Cut 6b, 6.2: airburst resolves in the simulation now (FireControl.Step calls Splash instead of Apply at
|
||||
// arrival) -- a MonoBehaviour calling Splash would be exactly the authority Cut 3 spent itself deleting, so
|
||||
// AirburstDistance/AirburstRange are gone. This object's job stays fly-and-disappear.
|
||||
public class Projectile : MonoBehaviour
|
||||
{
|
||||
public TrailRenderer Trail;
|
||||
|
|
@ -13,9 +15,6 @@ public class Projectile : MonoBehaviour
|
|||
public float Drag = .1f;
|
||||
public Prototype HitEffect;
|
||||
|
||||
public float AirburstDistance;
|
||||
public float AirburstRange;
|
||||
|
||||
private bool _alive;
|
||||
|
||||
// Cut 3: FireControl.Fire's ShotId -- this projectile's handle onto Zone.ShotCommitted/ShotResolved.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using CultMath;
|
||||
using static CultMath.math;
|
||||
using Random = UnityEngine.Random;
|
||||
|
||||
public class ProjectileManager : InstantWeaponEffectManager
|
||||
|
|
@ -29,7 +27,6 @@ public class ProjectileManager : InstantWeaponEffectManager
|
|||
p.Velocity += new Vector3(source.Entity.Velocity.x, 0, source.Entity.Velocity.y);
|
||||
p.Range = weapon.Range;
|
||||
p.Zone = source.Entity.Zone;
|
||||
p.AirburstDistance = target != null ? length(source.Entity.Position - target.Entity.Position) : (weapon.Range * .75f);
|
||||
p.Trail.Clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,15 @@ public static class FireControl
|
|||
}
|
||||
|
||||
var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon;
|
||||
|
||||
// Cut 6b, 6.2 (Soul finding 5): airburst is a property of the weapon, resolved and frozen here like
|
||||
// every other payload field -- never decided later by a Unity projectile. A weapon without the
|
||||
// Airburst flag freezes a zero radius, which Step below reads as "resolve this shot with Apply."
|
||||
var weaponItemData = item.Data as WeaponItemData;
|
||||
var isAirburst = weaponItemData != null && weaponItemData.WeaponModifiers.HasFlag(WeaponModifiers.Airburst);
|
||||
var burstPosition = isAirburst && target != null ? PredictedIntercept(weapon, source, target) : targetPosition;
|
||||
var burstRadius = isAirburst ? weaponItemData.AirburstRange : 0f;
|
||||
|
||||
var shot = new PendingShot
|
||||
{
|
||||
ShotId = zone.NextShotId(),
|
||||
|
|
@ -214,6 +223,8 @@ public static class FireControl
|
|||
FireTargetVelocity = targetVelocity,
|
||||
ArrivalTime = now + flightTime,
|
||||
CommitTime = now + max(0f, flightTime - commitHorizon),
|
||||
BurstPosition = burstPosition,
|
||||
BurstRadius = burstRadius,
|
||||
Committed = false
|
||||
};
|
||||
|
||||
|
|
@ -258,7 +269,11 @@ public static class FireControl
|
|||
|
||||
if (shot.Committed && now >= shot.ArrivalTime)
|
||||
{
|
||||
Apply(shot);
|
||||
// Cut 6b, 6.2: an airburst shot (frozen BurstRadius > 0) resolves as an area effect instead of
|
||||
// a discrete hit -- Splash instead of Apply, never both, which is exactly the double-
|
||||
// application Soul was told to hunt for.
|
||||
if (shot.BurstRadius > 0f) Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType);
|
||||
else Apply(shot);
|
||||
zone.ShotResolved.OnNext(shot.Outcome);
|
||||
shots.RemoveAt(i);
|
||||
}
|
||||
|
|
@ -272,7 +287,13 @@ public static class FireControl
|
|||
// sequence is exactly as if the shot had never queued.
|
||||
private static ShotOutcome Commit(Zone zone, PendingShot shot, float now)
|
||||
{
|
||||
var random = shot.Source.ItemManager.Random;
|
||||
// Cut 6b, 6.1 (Soul finding 6): a shot's dice belong to the shot, not to whatever else happened to
|
||||
// draw from ItemManager.Random first. A pure function of (zone identity, shot id) -- the `| 1u` guards
|
||||
// the degenerate zero seed -- so two runs of the same fight from the same galaxy seed roll identically
|
||||
// no matter what the UI drew from the shared stream in between. This generator is local and dies with
|
||||
// the call: nothing outside Commit may seed or advance a combat draw, and Commit may not read or write
|
||||
// a shared stream.
|
||||
var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);
|
||||
var p = shot.PBase;
|
||||
if (p > 0f && shot.Target != null)
|
||||
{
|
||||
|
|
@ -312,7 +333,6 @@ public static class FireControl
|
|||
else if (shieldActive) shieldBroken = true;
|
||||
}
|
||||
|
||||
shot.Source.ItemManager.Random = random;
|
||||
return MakeOutcome(shot, hit, shielded, shieldBroken, cell, aimed, now);
|
||||
}
|
||||
|
||||
|
|
@ -436,6 +456,12 @@ public struct PendingShot
|
|||
public float Tracking;
|
||||
public float Precision;
|
||||
|
||||
// Cut 6b, 6.2: the airburst payload, frozen at fire alongside everything else above. BurstRadius is zero
|
||||
// for a weapon without the Airburst flag (WeaponModifiers, Enums.cs) -- Step reads that zero as "resolve
|
||||
// with Apply," not a separate bool.
|
||||
public float3 BurstPosition;
|
||||
public float BurstRadius;
|
||||
|
||||
public float3 FireTargetPosition;
|
||||
public float3 FireTargetVelocity;
|
||||
public float FireTime;
|
||||
|
|
|
|||
|
|
@ -489,6 +489,13 @@ public class WeaponItemData : GearData
|
|||
|
||||
[Inspectable, JsonProperty("modifiers"), Key(28)]
|
||||
public WeaponModifiers WeaponModifiers;
|
||||
|
||||
// Cut 6b (docs/fire-control-cut.md, 6.2): the authored blast radius an Airburst-flagged weapon's shot
|
||||
// splashes with at arrival, frozen into PendingShot.BurstRadius at fire. Zero (and unread) for a weapon
|
||||
// without the Airburst flag. No authored value exists yet -- the flag is wired and unexercised until Cut
|
||||
// 6a or a later authoring pass sets it.
|
||||
[Inspectable, JsonProperty("airburstRange"), Key(29)]
|
||||
public float AirburstRange;
|
||||
}
|
||||
|
||||
[CultDocument("aetheria.hulldata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ public class Zone
|
|||
public GalaxyZone GalaxyZone { get; }
|
||||
public Galaxy Galaxy { get; }
|
||||
|
||||
// Cut 6b (docs/fire-control-cut.md, 6.1, Soul finding 6): one zone, one stable identity -- a galaxy seed
|
||||
// reproduces it, and nothing else in the zone (an NPC joining, a shop opening, a wormhole exit) can
|
||||
// perturb it. FireControl.Commit builds its own local generator from this and the shot's own id instead
|
||||
// of touching a shared stream.
|
||||
public uint CombatSeed { get; }
|
||||
|
||||
public Zone(ItemManager itemManager, PlanetSettings settings, ZonePack pack, GalaxyZone galaxyZone, Galaxy galaxy)
|
||||
{
|
||||
_time = pack.Time;
|
||||
|
|
@ -61,7 +67,8 @@ public class Zone
|
|||
Pack = pack;
|
||||
_itemManager = itemManager;
|
||||
Settings = settings;
|
||||
_random = new Random(galaxyZone?.Name.StableHash() ?? 1337u);
|
||||
CombatSeed = galaxyZone?.Name.StableHash() ?? 1337u;
|
||||
_random = new Random(CombatSeed);
|
||||
var cache = itemManager.ItemData;
|
||||
|
||||
// Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7; operator ruling Q3): death removes the ship,
|
||||
|
|
|
|||
310
tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs
Normal file
310
tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using UniRx;
|
||||
using Xunit;
|
||||
using static CultMath.math;
|
||||
using float3 = CultMath.float3;
|
||||
using int2 = CultMath.int2;
|
||||
using Random = CultMath.Random;
|
||||
|
||||
// Cut 6b (docs/fire-control-cut.md, "the dice stop being shared, and the flak cannon works again"): pins 6.1
|
||||
// (a shot's dice are a pure function of zone identity and shot id, not a shared stream) and 6.2 (airburst
|
||||
// resolves in the simulation, via Splash instead of Apply, never both). Builds its own fixtures, the same
|
||||
// convention every earlier cut's test file establishes.
|
||||
public sealed class FireControlCut6Tests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut6-" + Guid.NewGuid().ToString("N"));
|
||||
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
||||
|
||||
public FireControlCut6Tests() => Directory.CreateDirectory(_root);
|
||||
|
||||
private readonly List<CultCache> _openCaches = new List<CultCache>();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var c in _openCaches) c.Dispose();
|
||||
Directory.Delete(_root, true);
|
||||
}
|
||||
|
||||
private static GameplaySettings TestSettings() => new GameplaySettings
|
||||
{
|
||||
DefaultEntitySettings = new EntitySettings(),
|
||||
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
|
||||
QualityPriceModifier = new ExponentialLerp(),
|
||||
TargetDetectionInfoThreshold = .1f,
|
||||
TargetArmorInfoThreshold = .2f,
|
||||
TargetGearInfoThreshold = .8f,
|
||||
FiringArc = 170,
|
||||
CommitHorizon = .5f,
|
||||
SchematicCellSize = 1f,
|
||||
UnaidedAccuracy = .05f,
|
||||
UnaidedTracking = 10f,
|
||||
AgentMinHitProbability = 0f,
|
||||
BeamResolveInterval = .1f
|
||||
};
|
||||
|
||||
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
|
||||
|
||||
private static Shape SolidShape(int w, int h)
|
||||
{
|
||||
var shape = new Shape(w, h);
|
||||
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
||||
return shape;
|
||||
}
|
||||
|
||||
// ---- A discrete-weapon shooter/target engagement, the same shape FireControlCut5Tests.Build uses. Every
|
||||
// engagement shares the fixed GalaxyZone name "Test", so every Zone built from this fixture shares one
|
||||
// CombatSeed (R1's stable identity) -- exactly the substrate 6.1's determinism claim depends on. ----
|
||||
|
||||
private sealed class Engagement
|
||||
{
|
||||
public ItemManager Items;
|
||||
public Zone Zone;
|
||||
public Ship Shooter;
|
||||
public Ship Target;
|
||||
public EquippedItem WeaponItem;
|
||||
public InstantWeapon Weapon;
|
||||
}
|
||||
|
||||
private Engagement Build(
|
||||
GameplaySettings settings,
|
||||
float damage = 10, float range = 1000, float velocity = 0, float spread = 0,
|
||||
float accuracy = 1, float resolution = 1, float tracking = 100000,
|
||||
float targetRange = 100, bool airburst = false, float airburstRange = 0)
|
||||
{
|
||||
var hullShape = SolidShape(5, 5);
|
||||
var hullData = new HullData
|
||||
{
|
||||
Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 100000, Mass = 1000,
|
||||
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
|
||||
};
|
||||
|
||||
var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true);
|
||||
_openCaches.Add(cache);
|
||||
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
||||
cache.Upsert(hullData);
|
||||
// Cut 6b, 6.2: a real WeaponItemData (not the bare GearData earlier cuts' fixtures use) -- it is the
|
||||
// one catalog type carrying WeaponModifiers (the Airburst flag) and, since this cut, AirburstRange.
|
||||
cache.Upsert(new WeaponItemData
|
||||
{
|
||||
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
||||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
WeaponModifiers = airburst ? WeaponModifiers.Airburst : WeaponModifiers.None,
|
||||
AirburstRange = airburstRange,
|
||||
Behaviors = { new InstantWeaponData
|
||||
{
|
||||
Damage = Constant(damage), Range = Constant(range), MinRange = Constant(0),
|
||||
Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0),
|
||||
Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000),
|
||||
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
|
||||
} }
|
||||
});
|
||||
cache.Upsert(new GearData
|
||||
{
|
||||
Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
|
||||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
Behaviors = { new TargetingSystemData
|
||||
{
|
||||
Accuracy = Constant(accuracy), Resolution = Constant(resolution), Precision = Constant(0), Tracking = Constant(tracking)
|
||||
} }
|
||||
});
|
||||
cache.FlushAsync().Wait();
|
||||
|
||||
var ledger = new ProvenanceLedger();
|
||||
ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 };
|
||||
ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 };
|
||||
ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 };
|
||||
ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 };
|
||||
ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 };
|
||||
var items = new ItemManager(cache, ledger, settings, _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
|
||||
|
||||
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
|
||||
var shooterHull = new EquippableItem { Data = hullRef, Durability = 100000, Lot = 1 };
|
||||
var shooter = new Ship(items, zone, shooterHull, new EntitySettings());
|
||||
|
||||
var gunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<WeaponItemData>("Gun"));
|
||||
var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 };
|
||||
Assert.True(shooter.TryEquip(gun, new int2(0, 0)));
|
||||
var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun);
|
||||
var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon);
|
||||
|
||||
var targetingRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Targeting"));
|
||||
var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 };
|
||||
Assert.True(shooter.TryFindSpace(targeting, out var tpos));
|
||||
Assert.True(shooter.TryEquip(targeting, tpos));
|
||||
|
||||
var targetHull = new EquippableItem { Data = hullRef, Durability = 100000, Lot = 4 };
|
||||
var target = new Ship(items, zone, targetHull, new EntitySettings());
|
||||
// A target with no non-hull equipment never runs TryEquip, so Entity's _orderedEquipment (only ever
|
||||
// populated there) stays null and Entity.Update NREs on the very first tick -- give it an inert item
|
||||
// purely so that array gets initialized, the same reason FireControlCut5Tests' fixtures always equip
|
||||
// a target gun.
|
||||
var targetGunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<WeaponItemData>("Gun"));
|
||||
var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 };
|
||||
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
|
||||
|
||||
zone.Entities.Add(shooter);
|
||||
zone.Entities.Add(target);
|
||||
shooter.Activate();
|
||||
target.Activate();
|
||||
|
||||
shooter.Position = float3.zero;
|
||||
target.Position = float3(0, 0, targetRange);
|
||||
shooter.Target.Value = target;
|
||||
shooter.EntityInfoGathered[target] = 1f;
|
||||
shooter.SetIff(target, true);
|
||||
|
||||
zone.Update(0f); // warm up resolved stats
|
||||
|
||||
return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon };
|
||||
}
|
||||
|
||||
// ---- 6.1: a shot's dice belong to the shot (Soul finding 6). ----
|
||||
|
||||
// A fixed fight, run twice from the same zone identity (CombatSeed comes from the fixed GalaxyZone name
|
||||
// "Test", shared by both engagements), with an arbitrary number of unrelated draws from ItemManager.Random
|
||||
// between shots in the second run. Outcomes must be identical shot-for-shot. Mutation: restore
|
||||
// `shot.Source.ItemManager.Random` as Commit's generator -- the unrelated draws below then advance the
|
||||
// shared stream Commit reads from, so the second run's hit/cell sequence diverges from the first's. This
|
||||
// is the test Soul's own hermetic-fixture probe could never be (docs/fire-control-cut.md, 6.1): a fixture
|
||||
// that never draws anything unrelated cannot see a shared-stream defect.
|
||||
[Fact]
|
||||
public void SameFightRollsSameThroughUnrelatedDraws()
|
||||
{
|
||||
var settings = TestSettings();
|
||||
|
||||
List<(bool hit, int2 cell)> RunFight(Action<ItemManager, int> unrelatedDraws)
|
||||
{
|
||||
var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, targetRange: 100);
|
||||
var outcomes = new List<(bool, int2)>();
|
||||
e.Zone.ShotResolved.Subscribe(o => outcomes.Add((o.Hit, o.Cell)));
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
unrelatedDraws?.Invoke(e.Items, i);
|
||||
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
||||
e.Zone.Update(.01f); // flight time 0 (velocity 0) -> commits and resolves in this one call
|
||||
}
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
var baseline = RunFight(null);
|
||||
// A different, growing number of unrelated draws before each shot -- exactly "how many draws came
|
||||
// before it" perturbing a shared stream would be sensitive to.
|
||||
var perturbed = RunFight((items, i) => { for (var k = 0; k < i * 3 + 1; k++) items.Random.NextFloat(); });
|
||||
|
||||
Assert.Equal(baseline.Count, perturbed.Count);
|
||||
Assert.True(baseline.Count > 0);
|
||||
Assert.Equal(baseline, perturbed);
|
||||
}
|
||||
|
||||
// A shot's outcome is a function of its own id: firing many shots at an identical, unmoving target (same
|
||||
// frozen payload in every way but ShotId, which increments per shot) must not always land on the same
|
||||
// cell. Mutation: drop ShotId from the seed -- every shot in a zone would then build the exact same local
|
||||
// generator, so every one of these draws the same cell every time (no variety at all across the run).
|
||||
[Fact]
|
||||
public void ShotIdDecidesTheDie()
|
||||
{
|
||||
var settings = TestSettings();
|
||||
var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, targetRange: 100);
|
||||
|
||||
var cells = new List<int2>();
|
||||
e.Zone.ShotResolved.Subscribe(o => { if (o.Hit) cells.Add(o.Cell); });
|
||||
|
||||
for (var i = 0; i < 12; i++)
|
||||
{
|
||||
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
||||
e.Zone.Update(.01f);
|
||||
}
|
||||
|
||||
Assert.True(cells.Count > 1);
|
||||
Assert.True(cells.Distinct().Count() > 1, "Every shot landed on the same cell -- ShotId is not reaching the seed.");
|
||||
}
|
||||
|
||||
// ---- 6.2: airburst resolves in the simulation (Soul finding 5). ----
|
||||
|
||||
// An airburst shot resolves via Splash, and Splash does not gate on the discrete hit roll at all -- it
|
||||
// damages everything in radius unconditionally (Cut 4's rule). Accuracy 0 pins PBase (and so Commit's hit)
|
||||
// to false with no dependence on the random draw, so a normal discrete shot would apply no damage
|
||||
// whatsoever (Apply's own `if (!shot.Outcome.Hit) return;`). An airburst shot must still deal its area
|
||||
// damage. Mutation: call Apply as well as Splash -- harmless here (Apply still no-ops on a miss), so this
|
||||
// test alone does not catch that mutation; AirburstAndDiscreteNeverDoubleUp below does, with a guaranteed
|
||||
// hit where an extra Apply call is not a no-op.
|
||||
[Fact]
|
||||
public void AirburstSplashesEvenOnAGuaranteedMiss()
|
||||
{
|
||||
var settings = TestSettings();
|
||||
var e = Build(settings, damage: 500, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000,
|
||||
targetRange: 50, airburst: true, airburstRange: 100);
|
||||
|
||||
var before = e.Target.Hull.Durability;
|
||||
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
||||
e.Zone.Update(.01f);
|
||||
|
||||
Assert.True(e.Target.Hull.Durability < before, "An airburst shot must splash regardless of the discrete hit roll.");
|
||||
}
|
||||
|
||||
// The double-application Soul was told to hunt for: with a guaranteed hit (accuracy 1), an airburst shot's
|
||||
// total damage must equal a bare Splash call's own damage -- not more. Mutation: call Apply as well as
|
||||
// Splash at arrival -- Apply's own discrete cell hit would land on top of Splash's area damage, so the
|
||||
// airburst shot's total damage exceeds a lone Splash call's.
|
||||
[Fact]
|
||||
public void AirburstAndDiscreteNeverDoubleUp()
|
||||
{
|
||||
var settings = TestSettings();
|
||||
|
||||
var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000,
|
||||
targetRange: 50, airburst: true, airburstRange: 100);
|
||||
var before = e.Target.Hull.Durability;
|
||||
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
||||
e.Zone.Update(.01f);
|
||||
var actualDelta = before - e.Target.Hull.Durability;
|
||||
|
||||
// A fresh, identically-shaped target, damaged by one bare Splash call with the same parameters
|
||||
// FireControl.Fire freezes for this weapon (BurstPosition, for a stationary velocity-0 target, is
|
||||
// exactly its own position -- PredictedIntercept falls back to target.Position below weapon.Velocity's
|
||||
// .01f floor).
|
||||
var e2 = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000,
|
||||
targetRange: 50, airburst: true, airburstRange: 100);
|
||||
var before2 = e2.Target.Hull.Durability;
|
||||
FireControl.Splash(e2.Zone, e2.Target.Position, 100, 10, DamageType.Kinetic);
|
||||
var expectedDelta = before2 - e2.Target.Hull.Durability;
|
||||
|
||||
Assert.True(expectedDelta > 0);
|
||||
Assert.Equal(expectedDelta, actualDelta, 3);
|
||||
}
|
||||
|
||||
// The other side: a non-airburst shot must never splash, even one that lands exactly on its own frozen
|
||||
// burst position at zero range (a stationary target, so BurstPosition == the target's own position at
|
||||
// arrival) -- the zero-radius edge case a careless "splash unconditionally" mutant would still pass
|
||||
// through, since distance 0 is never greater than radius 0. Accuracy 0 pins the discrete roll to a
|
||||
// guaranteed miss, so the correct path (Apply, gated on Outcome.Hit) deals no damage at all. Mutation:
|
||||
// splash unconditionally at arrival instead of gating on BurstRadius -- Splash does not check Hit, so it
|
||||
// would still deal damage even though this shot never carried the Airburst flag.
|
||||
[Fact]
|
||||
public void NonAirburstNeverSplashes()
|
||||
{
|
||||
var settings = TestSettings();
|
||||
var e = Build(settings, damage: 1000, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000,
|
||||
targetRange: 50, airburst: false);
|
||||
|
||||
var before = e.Target.Hull.Durability;
|
||||
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
||||
e.Zone.Update(.01f);
|
||||
|
||||
Assert.Equal(before, e.Target.Hull.Durability);
|
||||
}
|
||||
|
||||
// ---- negative: ItemManager.Random no longer appears in FireControl.cs; Airburst no longer appears
|
||||
// anywhere under Assets/Scripts/Gameplay (Projectile.cs's fields and ProjectileManager.cs's write-back are
|
||||
// deleted, not merely unused). Enforced by tests/mutation_tests_fire_control_cut6.sh's negative-grep step,
|
||||
// not restated here as a source-scanning unit test.
|
||||
}
|
||||
Loading…
Reference in a new issue