Mining cut 4: rocks take blasts and shots through the one fire path #18

Merged
nibu merged 7 commits from eureka/aetheria-release-mining-4 into master 2026-11-12 22:42:48 +00:00
6 changed files with 999 additions and 92 deletions

View file

@ -84,10 +84,10 @@ public static class FireControl
// Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so
// a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The
// answer is Solve's own -- there is no second copy of the arming test.
public static bool Refuses(Weapon weapon, Entity shooter) => Refuses(weapon, shooter, shooter.Target.Value.Entity);
public static bool Refuses(Weapon weapon, Entity shooter) => Refuses(weapon, shooter, shooter.Target.Value);
// The same answer against a target the shooter's pilot has commanded but not yet applied.
public static bool Refuses(Weapon weapon, Entity shooter, Entity target) =>
public static bool Refuses(Weapon weapon, Entity shooter, TargetRef target) =>
!IsMineLayer(weapon) && Solve(weapon, weapon.Item, shooter, target).Outcome == FireOutcome.Refused;
// A mine layer lays a body, not a round: it is never refused, never arc-gated, and Fire routes it to Lay.
@ -139,27 +139,18 @@ public static class FireControl
// The one owner of a gun's solution against its subject (the designated target; ruling controls-solution-subject).
// Bears: the gun can engage the subject, so the player fires on it without the aim. Free: the aim is inside the
// arc, so the gun can fire along it. Direction: where a round from this gun travels and where its barrel points --
// the subject's intercept when it bears, else the arc-clamped aim. A chunk subject bears through its pose and
// flies the aim, as an untargeted round (Subject is null for it). Derived on every read and never stored: turrets
// the subject's intercept when it bears (a rock's too), else the arc-clamped aim. Subject is null for a rock. Derived on every read and never stored: turrets
// traverse instantly in the simulation. Traverse is the weapon's Tracking (ruling mount-arc-gun-traverse), priced in
// TrackingRate; this holds no slew state, and a rig's slew is presentation. It never prices; HitProbability is the one price.
public static GunSolution Solution(Weapon weapon, Entity shooter, TargetRef subject)
{
var bears = false;
if (subject.Entity != null) bears = Bears(weapon, shooter, subject.Entity, out _);
else if (subject.Chunk is ChunkId chunk)
{
var at = shooter.Zone.ChunkPose(chunk.Field, chunk.Index).xy;
bears = InArc(weapon.Item, float3(at.x, 0, at.y) - shooter.Position);
}
var bears = Bears(weapon, shooter, subject, out _);
return new GunSolution
{
Subject = subject.Entity,
Bears = bears,
Free = InArc(weapon.Item, shooter.Aim),
Direction = bears && subject.Entity != null
? TravelDirection(weapon, shooter, subject.Entity)
: AimDirection(weapon.Item, shooter)
Direction = bears ? TravelDirection(weapon, shooter, subject) : AimDirection(weapon.Item, shooter)
};
}
@ -224,8 +215,8 @@ public static class FireControl
return degrees(abs(offset.x * relativeVelocity.y - offset.y * relativeVelocity.x) / (range * range));
}
public static float AngularVelocity(Entity source, Entity target) =>
AngularVelocity((target.Position - source.Position).xz, target.Velocity - source.Velocity);
public static float AngularVelocity(Entity source, TargetRef target) =>
AngularVelocity((PositionOf(source, target) - source.Position).xz, VelocityOf(source, target) - source.Velocity);
// The gun's rate times the ship's gear over the unaided baseline (ruling tracking-per-gun, "gun times ship"): the
// one product of the two. An unauthored gun is +infinity and so is its rate whatever the gear reads: a mount with no
@ -241,14 +232,31 @@ public static class FireControl
// be judged against another. This is the one function; CombatState (and FireControl.Fire below) both call
// it instead of touching CultMath.first_order_intercept directly. Planar: source velocity is never fed in
// (no shooter here leads its own motion into the shot), matching every existing call site this replaces.
public static float3 PredictedIntercept(Weapon weapon, Entity source, Entity target)
// Mining Cut 4b: a target is an entity or a rock; its position and velocity come from PositionOf and VelocityOf.
public static float3 PredictedIntercept(Weapon weapon, Entity source, TargetRef target)
{
var targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y);
var position = PositionOf(source, target);
var velocity = VelocityOf(source, target);
return weapon.Velocity > .01f
? first_order_intercept(source.Position, float3.zero, weapon.Velocity, target.Position, targetVelocity)
: target.Position;
? first_order_intercept(source.Position, float3.zero, weapon.Velocity, position, float3(velocity.x, 0, velocity.y))
: position;
}
// Where a target is now: an entity's position, a rock's pose at the shooter's height (everything here is planar).
private static float3 PositionOf(Entity source, TargetRef target)
{
if (target.Chunk is ChunkId chunk)
{
var at = source.Zone.ChunkPose(chunk.Field, chunk.Index).xy;
return float3(at.x, source.Position.y, at.y);
}
return target.Entity.Position;
}
// How fast a target moves: an entity's velocity, a rock's from Zone.ChunkVelocity, the one owner.
private static float2 VelocityOf(Entity source, TargetRef target) =>
target.Chunk is ChunkId chunk ? source.Zone.ChunkVelocity(chunk) : target.Entity.Velocity;
// Cut 3, R1/R3: the targeting solution (0b table) -- pure, no draw, recomputed fresh on every call. AI
// reads this to decide whether a shot is worth taking; the HUD reads it to draw a number; Fire below reads
// it once, at the instant the trigger is pulled, and freezes the result into the shot (R10, Q6). Two reads
@ -263,13 +271,13 @@ public static class FireControl
// freezes (R10), gated exactly as HitProbability gates. Nothing about the target's facing or silhouette
// enters here; that is Silhouette's and PSpread's job, priced fresh by HitProbability below and again,
// live, by Commit at the commit tick. 0 the moment any gate closes.
private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _, out _, out _);
private static float PFire(Weapon weapon, Entity source, TargetRef target) => PFire(weapon, source, target, out _, out _, out _);
// The `out range` overload is HitProbability's own gate call -- one computation of `target.Position -
// source.Position`, not two. HitProbability used to recompute its own copy for PSpread, which is exactly
// the kind of split authority Cut 3 named as the risk (two places computing the same vector, free to
// drift or to have their subtraction order flipped in only one of them).
private static float PFire(Weapon weapon, Entity source, Entity target, out float range) =>
private static float PFire(Weapon weapon, Entity source, TargetRef target, out float range) =>
PFire(weapon, source, target, out range, out _, out _);
// The one answer to "does the shooter hold valid targeting data on this target": visible, in range and locked
@ -279,25 +287,29 @@ public static class FireControl
// visible, out of range) already denies the target. Planar (R7: the simulation is 2D): the range gate, the
// burst reach and the range clamp all measure the same distance, so a target's height never moves one of
// them without the others.
public static bool Designated(Weapon weapon, Entity source, Entity target, out float range)
// Mining Cut 4b: a rock is designated by the same range gates, seen when Entity.ChunkVisible says so; a
// LockWeapon (a launcher) is never designated on a rock (ruling Q12 = A, 2026-09-30).
public static bool Designated(Weapon weapon, Entity source, TargetRef target, out float range)
{
range = 0f;
if (target == null) return false;
range = length((target.Position - source.Position).xz);
return source.VisibleEntities.Contains(target) && range >= weapon.MinRange && range <= weapon.Range &&
if (target.IsNone) return false;
range = length((PositionOf(source, target) - source.Position).xz);
var inRange = range >= weapon.MinRange && range <= weapon.Range;
if (target.Chunk is ChunkId chunk) return inRange && !(weapon is LockWeapon) && source.ChunkVisible(chunk);
return source.VisibleEntities.Contains(target.Entity) && inRange &&
!(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked);
}
// Whether this gun can engage this target: designated and inside the arc. The one test of it; PFire prices a shot
// at zero unless it holds, and Solution reads it to decide whether the gun fires on the target. `designated` is
// the answer above, kept apart for Solve's burst flight: a designated target out of arc still sets the burst range.
public static bool Bears(Weapon weapon, Entity source, Entity target, out float range) =>
public static bool Bears(Weapon weapon, Entity source, TargetRef target, out float range) =>
Bears(weapon, source, target, out range, out _);
private static bool Bears(Weapon weapon, Entity source, Entity target, out float range, out bool designated)
private static bool Bears(Weapon weapon, Entity source, TargetRef target, out float range, out bool designated)
{
designated = Designated(weapon, source, target, out range);
return designated && InArc(weapon.Item, target.Position - source.Position);
return designated && InArc(weapon.Item, PositionOf(source, target) - source.Position);
}
// `designated` is the answer above. `inArc` is Bears, asked only once a target is designated.
@ -305,13 +317,14 @@ public static class FireControl
// (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but
// out of arc it bursts at the target's range, along the aim. The HUD reads Designated and the
// arc from Solve and Inspect and decides nothing.
private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc)
private static float PFire(Weapon weapon, Entity source, TargetRef target, out float range, out bool designated, out bool inArc)
{
inArc = Bears(weapon, source, target, out range, out designated);
if (!inArc) return 0f;
var settings = source.ItemManager.GameplaySettings;
var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
var info = target.Chunk is ChunkId chunk ? source.ChunkInfo(chunk)
: source.EntityInfoGathered.TryGetValue(target.Entity, out var gathered) ? gathered : 0f;
return Accuracy(source) * PSensor(settings, Resolution(source), info);
}
@ -364,22 +377,33 @@ public static class FireControl
return (sil, pSpread, evasion);
}
public static float HitProbability(Weapon weapon, Entity source, Entity target) =>
public static float HitProbability(Weapon weapon, Entity source, TargetRef target) =>
HitProbability(weapon, source, target, (AimTarget?) null);
// Priced on `aim` rather than the source's aim slot: the aim its pilot commands this step.
public static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget aim) =>
public static float HitProbability(Weapon weapon, Entity source, TargetRef target, AimTarget aim) =>
HitProbability(weapon, source, target, (AimTarget?) aim);
// A null aim reads source.ResolvedAim, after the gate, so a gated-out shot reads nothing more.
private static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget? aim)
// Mining Cut 4b: a rock is priced by the same factors as a ship at equal range, precision and info -- its disc
// through the same integrator, its evasion 0 (a rock is not a Ship), and no aim point: the disc is aimed at.
private static float HitProbability(Weapon weapon, Entity source, TargetRef target, AimTarget? aim)
{
var pFire = PFire(weapon, source, target, out var range);
if (pFire <= 0f) return 0f;
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range, aim ?? source.ResolvedAim);
return pFire * PMount(AngularVelocity(source, target), TrackingRate(weapon, source)) * pSpread * sil.POnHull * PDeviation(evasion, Tracking(source));
var pMount = PMount(AngularVelocity(source, target), TrackingRate(weapon, source));
if (target.Chunk is ChunkId chunk)
{
var mass = RockMass(source.Zone, chunk, Precision(source), out var span);
var pSpreadRock = PSpread(weapon.Spread, span, range, source.ItemManager.GameplaySettings.SchematicCellSize);
return pFire * pMount * pSpreadRock * mass * PDeviation(0f, Tracking(source));
}
var entity = target.Entity;
var targetHull = source.ItemManager.GetData(entity.Hull) as HullData;
var (sil, pSpread, evasion) = Forecast(weapon, source, entity, targetHull, Precision(source), range, aim ?? source.ResolvedAim);
return pFire * pMount * pSpread * sil.POnHull * PDeviation(evasion, Tracking(source));
}
// Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes
@ -457,7 +481,7 @@ public static class FireControl
// at Fire (R10); HitProbability and Inspect recompute it fresh, at the current source/target positions,
// for their forecast. Below a length of 1e-6 (point-blank, source and intercept coincide) it falls back
// to world +z, the same fallback Apply used before this cut (`:463`).
public static float2 TravelDirection(Weapon weapon, Entity source, Entity target)
public static float2 TravelDirection(Weapon weapon, Entity source, TargetRef target)
{
var toIntercept = (PredictedIntercept(weapon, source, target) - source.Position).xz;
return lengthsq(toIntercept) < 1e-6f ? float2(0, 1) : normalize(toIntercept);
@ -505,12 +529,21 @@ public static class FireControl
// The commit price's deviation: what the target has already moved off the frozen forecast, plus what it can and
// does move off the line of fire (Evasion). The second part applies at any flight time, so an instant weapon
// (flight time 0, realized deviation 0) still feels a jinking target.
public static float DeviationProbability(PendingShot shot, float now, out float deviation)
// Mining Cut 4b: a rock's deviation is where its pose is now against that projection, with no evasion; its
// zone is the shooter's (a shot that outlives its shooter is resolved by Step, which passes the zone it runs in).
public static float DeviationProbability(PendingShot shot, float now, out float deviation) =>
DeviationProbability(shot.Source?.Zone, shot, now, out deviation);
private static float DeviationProbability(Zone zone, PendingShot shot, float now, out float deviation)
{
deviation = 0f;
if (shot.Target == null && shot.Chunk == null) return 1f;
var elapsed = now - shot.FireTime;
var predicted = shot.FireTargetPosition + shot.FireTargetVelocity * elapsed;
deviation = shot.Target == null ? 0f : length((shot.Target.Position - predicted).xz) + Evasion(shot.Target, shot.TravelDirection);
return shot.Target == null ? 1f : PDeviation(deviation, shot.Tracking);
deviation = shot.Chunk is ChunkId chunk
? length(zone.ChunkPose(chunk.Field, chunk.Index).xy - predicted.xz)
: length((shot.Target.Position - predicted).xz) + Evasion(shot.Target, shot.TravelDirection);
return PDeviation(deviation, shot.Tracking);
}
// How far a target can and does move off the line of fire in the solution window: the term DeviationProbability
@ -608,7 +641,7 @@ public static class FireControl
// BurstReach the planar distance from the shooter at which the payload is delivered (a direct round's target, a fused
// round's burst point, 0 for an untargeted direct round), which Fire's range factor reads for every outcome. Refused: a fused round whose blast
// radius (its arming distance) exceeds the weapon's Range; it cannot be fired.
public static FireSolution Solve(Weapon weapon, EquippedItem item, Entity source, Entity target)
public static FireSolution Solve(Weapon weapon, EquippedItem item, Entity source, TargetRef target)
{
var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc);
@ -639,14 +672,14 @@ public static class FireControl
// that leaves the zone cancels it like any other. A round whose Range is shorter than the arming distance
// cannot be fired at all: it is refused, per round, where Range is read after Execute refreshed it (a
// burst's later rounds and a power-starved Range included).
var engaged = fuse != null && !(designated && inArc) ? null : target;
var engaged = fuse != null && !(designated && inArc) ? TargetRef.None : target;
var origin = source.Position;
var travelDirection = Solution(weapon, source, target).Direction;
var arming = fuse != null ? blastRadius : 0f;
var burstPosition = engaged != null ? engaged.Position : origin;
var burstPosition = !engaged.IsNone ? PositionOf(source, engaged) : origin;
var flightDistance = 0f;
if (fuse != null && engaged != null)
if (fuse != null && !engaged.IsNone)
{
// Nothing bursts beyond max range: an intercept a receding target leads the round past Range is
// pulled back along its own line to Range.
@ -662,7 +695,7 @@ public static class FireControl
burstPosition = Armed(origin, travelDirection, arming, origin + float3(travelDirection.x, 0, travelDirection.y) * reach);
flightDistance = max(reach, arming);
}
else if (engaged != null) flightDistance = fireRange;
else if (!engaged.IsNone) flightDistance = fireRange;
var solution = new FireSolution
{
@ -710,8 +743,7 @@ public static class FireControl
Lay(weapon, item, source);
return 0;
}
// Mining Cut 3: a chunk target fires as an untargeted round until Cut 4 routes shots at chunks.
var target = source.Target.Value.Entity;
var target = source.Target.Value;
var now = zone.Time - age;
// R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time
@ -721,7 +753,7 @@ public static class FireControl
var solution = Solve(weapon, item, source, target);
if (solution.Outcome == FireOutcome.Refused) return 0;
// The mount factor is frozen with the shooter-side probability: nothing at commit time re-reads the sweep.
var pFire = solution.PFire * (solution.Engaged != null ? PMount(AngularVelocity(source, solution.Engaged), TrackingRate(weapon, source)) : 1f);
var pFire = solution.PFire * (!solution.Engaged.IsNone ? PMount(AngularVelocity(source, solution.Engaged), TrackingRate(weapon, source)) : 1f);
var fireRange = solution.FireRange;
var engaged = solution.Engaged;
var travelDirection = solution.TravelDirection;
@ -732,10 +764,11 @@ public static class FireControl
var targetVelocity = float3.zero;
var targetPosition = origin;
if (engaged != null)
if (!engaged.IsNone)
{
targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y);
targetPosition = engaged.Position - targetVelocity * age;
var velocity = VelocityOf(source, engaged);
targetVelocity = float3(velocity.x, 0, velocity.y);
targetPosition = PositionOf(source, engaged) - targetVelocity * age;
}
var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f;
@ -746,7 +779,8 @@ public static class FireControl
{
ShotId = zone.NextShotId(),
Source = source,
Target = engaged,
Target = engaged.Entity,
Chunk = engaged.Chunk,
Weapon = item,
Aimed = aim.Item,
AimedMount = aim.Mount,
@ -773,7 +807,7 @@ public static class FireControl
// The commit horizon exists so the target's facing can be read late (Bearing timing). A round fired at
// nothing has no facing to read, so it commits at Fire: its burst point is decided from the poses of
// the moment it leaves, and a Contact round short of max range can arrive as early as it truly does.
CommitTime = fuse != null && engaged == null ? now : now + max(0f, flightTime - commitHorizon),
CommitTime = fuse != null && engaged.IsNone ? now : now + max(0f, flightTime - commitHorizon),
BurstPosition = burstPosition,
Fuse = fuse,
BlastRadius = blastRadius,
@ -861,7 +895,7 @@ public static class FireControl
{
var shot = shots[i];
var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);
var targetGone = TargetGone(zone, shot);
if (targetGone)
{
// Cut 11 (Soul C4): a shot whose target has left the zone resolves as a miss whichever
@ -908,7 +942,7 @@ public static class FireControl
if (i < 0) continue;
var shot = shots[i];
var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);
var targetGone = TargetGone(zone, shot);
if (targetGone)
{
// As in the commit walk: a shot whose target left the zone in this step resolves as a miss.
@ -930,6 +964,10 @@ public static class FireControl
}
}
// A shot's target has left the zone, or a rock it flies at no longer exists (broken in flight): the shot is a miss.
private static bool TargetGone(Zone zone, PendingShot shot) =>
(shot.Target != null && !zone.Entities.Contains(shot.Target)) || (shot.Chunk is ChunkId chunk && !zone.ChunkExists(chunk));
// R3/R4: the one roll. p_base was frozen at fire, PFire carrying the mount factor; the only thing measured live is how far the target has
// actually strayed, by now, from where a straight-line projection of its fire-time velocity said it would
// be -- forgiven by the targeting system's Tracking, likewise frozen at fire.
@ -945,15 +983,25 @@ public static class FireControl
// (bearing) -- is read live, here, at the commit tick. The Cut 7 guard extends to cover the new factors:
// when PFire x pDeviation is already 0, no Silhouette is built. Shared by Commit's own roll and by the
// debug HUD's forecast (TheHudEstimateIsTheCommitPrice) -- one function, one commit-time price.
public static float CommitProbability(PendingShot shot, float now, out Silhouette sil)
public static float CommitProbability(PendingShot shot, float now, out Silhouette sil) =>
CommitProbability(shot.Source?.Zone, shot, now, out sil);
// Mining Cut 4b: a rock's price is PFire x PDeviation x PSpread(span 2R) x the disc's mass; it reads no hull.
private static float CommitProbability(Zone zone, PendingShot shot, float now, out Silhouette sil)
{
sil = default;
if (shot.Target == null) return 0f;
if (shot.Target == null && shot.Chunk == null) return 0f;
var pDeviation = DeviationProbability(shot, now, out _);
var pDeviation = DeviationProbability(zone, shot, now, out _);
var p = shot.PFire * pDeviation;
if (p <= 0f) return 0f;
if (shot.Chunk is ChunkId chunk)
{
var mass = RockMass(zone, chunk, shot.Precision, out var span);
return p * PSpread(shot.Spread, span, shot.FireRange, zone.SchematicCellSize) * mass;
}
var bearing = Bearing(shot.Target, shot.TravelDirection);
var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData;
sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision, null, shot.AimedMount);
@ -984,7 +1032,7 @@ public static class FireControl
// drawing once is the documented way to get a correlated first output, and a follow-up to give
// CultMath.Random a mixed-seed entry point of its own is recorded for a separate CultLib cut.
var random = new Random(SimulationDice.Mix((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));
var p = CommitProbability(shot, now, out var sil);
var p = CommitProbability(zone, shot, now, out var sil);
var hit = p > 0f && random.NextFloat() < p;
var cell = int2.zero;
@ -993,7 +1041,8 @@ public static class FireControl
var shielded = false;
var shieldBroken = false;
if (hit)
// A rock hit has no lane and no shield: Cell, Bearing and Lateral stay zero (Apply wears the rock).
if (hit && shot.Chunk == null)
{
// Cut 12.2: the same silhouette pOnHull drew its mass from -- same bearing (live, at this commit
// tick), same sigma (the frozen Precision). A shot that passed the roll always lands on metal (R3:
@ -1189,6 +1238,12 @@ public static class FireControl
return;
}
if (shot.Chunk is ChunkId rock)
{
ApplyRockHit(zone, shot, rock);
return;
}
switch (shot.Fuse)
{
case null:
@ -1201,6 +1256,27 @@ public static class FireControl
}
}
// Mining Cut 4b: a committed hit on a rock. A round without a fuse wears the rock by its Damage (the falloff was
// read at Fire) and publishes one absorb on the zone's ChunkCapabilities (through Publish, at height 0 like a blast's), at the
// rock's surface facing the shot and before the wear shrinks it. A contact or delayed round detonates at the rock's centre as it arrives (the
// arming rule kept), through Detonate, the one area owner.
private static void ApplyRockHit(Zone zone, PendingShot shot, ChunkId rock)
{
if (!shot.Outcome.Hit) return;
var centre = zone.ChunkPose(rock.Field, rock.Index).xy;
if (shot.Fuse == null)
{
var direction = float3(shot.TravelDirection.x, 0, shot.TravelDirection.y);
var surface = centre - shot.TravelDirection * zone.ChunkRadius(rock);
Publish(zone.ChunkCapabilities, AbsorbLayer.Hull, float3(surface.x, 0f, surface.y), direction, shot.Damage, shot.DamageType);
zone.Wear(rock, shot.Damage);
return;
}
var point = Armed(shot.FireOrigin, shot.TravelDirection, shot.BlastRadius, float3(centre.x, shot.FireOrigin.y, centre.y)).xz;
Detonate(zone, point, shot.BlastRadius, shot.Damage, shot.DamageType);
}
// Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step
// march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A):
// 2n+1 parallel lanes, sharing the shot's damage evenly over whichever lanes actually meet metal -- the
@ -1221,7 +1297,7 @@ public static class FireControl
if (shot.Outcome.Shielded)
{
Publish(shot.Target, AbsorbLayer.Shield, ImpactPoint(shot.Target, shot.Outcome.Cell),
Publish(shot.Target.Capabilities, AbsorbLayer.Shield, ImpactPoint(shot.Target, shot.Outcome.Cell),
float3(shot.TravelDirection.x, 0, shot.TravelDirection.y), shot.Damage, shot.DamageType);
shot.Target.Shield.TakeHit(shot.DamageType, shot.Damage);
return;
@ -1266,7 +1342,7 @@ public static class FireControl
for (var li = 0; li < laneCount; li++) ArrayPool<LaneCell>.Shared.Return(buffers[li]);
Publish(target, AbsorbLayer.Hull, ImpactPoint(target, shot.Outcome.Cell),
Publish(target.Capabilities, AbsorbLayer.Hull, ImpactPoint(target, shot.Outcome.Cell),
float3(shot.TravelDirection.x, 0, shot.TravelDirection.y), shot.Damage, shot.DamageType);
target.DamageHull(hullDamage);
}
@ -1278,17 +1354,18 @@ public static class FireControl
return float3(world.x, target.Position.y, world.y);
}
// The one publisher of an absorbed impact: once per receiver, on its Capabilities, before the damage it reports.
private static void Publish(Entity receiver, AbsorbLayer layer, float3 position, float3 direction, float magnitude, DamageType damageType) =>
receiver.Capabilities.PublishAbsorb(new AbsorbEvent(position, direction, magnitude, damageType, layer));
// The one publisher of an absorbed impact: once per receiver, on its CapabilityEvents (an entity's, or the zone's
// for a rock), before the damage it reports.
private static void Publish(CapabilityEvents receiver, AbsorbLayer layer, float3 position, float3 direction, float magnitude, DamageType damageType) =>
receiver.PublishAbsorb(new AbsorbEvent(position, direction, magnitude, damageType, layer));
// A blast's impact on one entity: at the blast point (on the entity's height), travelling from the blast out through
// the entity's centre, or zero when they coincide.
private static void PublishBlast(Entity entity, float2 blast, AbsorbLayer layer, float magnitude, DamageType damageType)
// A blast's impact on one receiver: at the blast point (at the receiver's height), travelling from the blast out
// through the receiver's planar centre, or zero when they coincide. An entity and a rock go through it alike.
private static void PublishBlast(CapabilityEvents receiver, float2 centre, float height, float2 blast, AbsorbLayer layer, float magnitude, DamageType damageType)
{
var away = entity.Position.xz - blast;
var away = centre - blast;
var direction = lengthsq(away) > 0f ? normalize(away) : float2.zero;
Publish(entity, layer, float3(blast.x, entity.Position.y, blast.y), float3(direction.x, 0, direction.y), magnitude, damageType);
Publish(receiver, layer, float3(blast.x, height, blast.y), float3(direction.x, 0, direction.y), magnitude, damageType);
}
// Cut 12.4(b) (docs/fire-control-cut.md, "Where P is"): a contact or delayed blast needs a committed hit --
@ -1593,6 +1670,7 @@ public static class FireControl
ShotId = shot.ShotId,
Source = shot.Source,
Target = shot.Target,
Chunk = shot.Chunk,
Weapon = shot.Weapon,
Result = result,
Shielded = shielded,
@ -1607,7 +1685,13 @@ public static class FireControl
// Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the one owner of blast damage -- a blast is
// an area, not a bundle of rays (Q12-7 dissolved the ray model's remainder question along with the rays
// themselves). Splash's signature with the point made planar (R7). Callers: Apply's three fuse branches. Nothing draws: every candidate entity in radius takes its share, unconditionally.
// themselves). Splash's signature with the point made planar (R7). Callers: Apply's three fuse branches and
// Zone's mine step. Nothing draws: every candidate entity in radius takes its share, unconditionally.
//
// Mining cut 4a (docs/mining-cut-refresh.md, Q10 = A): this is also the one owner of blast area over rocks.
// After the entity pass, every unbroken rock whose disc the blast disc overlaps takes its lens share of the
// damage -- damage * lens / (pi r^2), the cells' own normaliser -- through Zone.Wear, and publishes one absorb
// on Zone.ChunkCapabilities first. Overlapping bodies each take their own overlap; shares off every body are lost.
//
// For each candidate (an entity whose centre lies within radius plus half its own hull diagonal --
// CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius, which Splash's centre-distance cull could not pass):
@ -1673,7 +1757,7 @@ public static class FireControl
{
if (shield.CanTakeHit(damageType, totalShare))
{
PublishBlast(entity, worldPlanar, AbsorbLayer.Shield, totalShare, damageType);
PublishBlast(entity.Capabilities, entity.Position.xz, entity.Position.y, worldPlanar, AbsorbLayer.Shield, totalShare, damageType);
shield.TakeHit(damageType, totalShare);
continue;
}
@ -1703,11 +1787,37 @@ public static class FireControl
for (var i = 0; i < amounts.Length; i++) hullTotal += amounts[i];
}
PublishBlast(entity, worldPlanar, AbsorbLayer.Hull, totalShare, damageType);
PublishBlast(entity.Capabilities, entity.Position.xz, entity.Position.y, worldPlanar, AbsorbLayer.Hull, totalShare, damageType);
entity.DamageHull(hullTotal);
}
if (!(radius > 0f) || zone.AsteroidBelts.Count == 0) return; // no area, or no rocks to cover
// Rocks, through the targeting index (no observer: entities stay out of it). Reach is exact on a rock's
// centre, so it is the blast radius plus the widest any rock can be. Walked in (field, index) order so the
// order of wear -- and any later dice on it -- is fixed.
var rocks = _rockScratch ??= new List<TargetCandidate>();
zone.Targets.Within(new TargetSearch(worldPlanar, radius + Zone.LargestChunkRadius(zone.Settings)), rocks);
rocks.RemoveAll(c => !c.Target.Chunk.HasValue);
rocks.Sort((a, b) =>
{
var byField = string.CompareOrdinal(a.Target.Chunk.Value.Field.Value, b.Target.Chunk.Value.Field.Value);
return byField != 0 ? byField : a.Target.Chunk.Value.Index.CompareTo(b.Target.Chunk.Value.Index);
});
var discArea = PI * radius * radius;
foreach (var rock in rocks)
{
var chunk = rock.Target.Chunk.Value;
if (!zone.ChunkExists(chunk)) continue;
var centre = zone.ChunkPose(chunk.Field, chunk.Index).xy;
var share = damage * CircleCircleOverlap(length(centre - worldPlanar), radius, zone.ChunkRadius(chunk)) / discArea;
if (!(share > 0f)) continue;
PublishBlast(zone.ChunkCapabilities, centre, 0f, worldPlanar, AbsorbLayer.Hull, share, damageType);
zone.Wear(chunk, share);
}
}
[ThreadStatic] private static List<TargetCandidate> _rockScratch;
// Whether the disc reaches any occupied hull cell of the entity: Detonate's own coverage test (the same
// ToSchematicPoint, SchematicCellSize and CircleSquareOverlap), so what triggers a mine and what its blast
// reaches are one geometry.
@ -1721,6 +1831,19 @@ public static class FireControl
return false;
}
// Mining cut 4a: the exact area shared by two discs at centre distance d -- the lens. Exactly zero when they are
// disjoint or tangent, and the smaller disc's whole area when one contains the other.
public static float CircleCircleOverlap(float d, float r1, float r2)
{
if (r1 <= 0f || r2 <= 0f || d >= r1 + r2) return 0f;
var small = min(r1, r2);
if (d <= abs(r1 - r2)) return PI * small * small;
var a = (d * d + r1 * r1 - r2 * r2) / (2f * d * r1);
var b = (d * d + r2 * r2 - r1 * r1) / (2f * d * r2);
var k = (-d + r1 + r2) * (d + r1 - r2) * (d - r1 + r2) * (d + r1 + r2);
return r1 * r1 * acos(clamp(a, -1f, 1f)) + r2 * r2 * acos(clamp(b, -1f, 1f)) - .5f * sqrt(max(0f, k));
}
// Cut 12.4(b) (docs/fire-control-cut.md, "Exact overlap, in float"): the exact area of a unit square,
// centred on `cell` (the unit square centred on its integer coordinate, the same convention every other
// frame rule uses), that lies inside a disc of radius `r` centred at `diskCentre` -- both in the same
@ -1876,9 +1999,7 @@ public static class FireControl
var aimPoint = ResolveAimPoint(target, hull, aimed, mount);
var a = dot(aimPoint, ell);
var pOnHull = 0f;
for (var i = 0; i < count; i++)
pOnHull += Phi((intervals[i].Hi - a) / sigma) - Phi((intervals[i].Lo - a) / sigma);
var pOnHull = Mass(new ReadOnlySpan<Interval>(intervals, 0, count), a, sigma);
var span = count > 0 ? intervals[count - 1].Hi - intervals[0].Lo : 0f;
@ -1894,6 +2015,27 @@ public static class FireControl
};
}
// The one Gaussian integrator: the mass a normal of `sigma` about `aim` puts inside the merged, disjoint
// `intervals`. A hull's shadow and a rock's disc (one interval about its aim) both read it.
private static float Mass(ReadOnlySpan<Interval> intervals, float aim, float sigma)
{
var mass = 0f;
foreach (var interval in intervals)
mass += Phi((interval.Hi - aim) / sigma) - Phi((interval.Lo - aim) / sigma);
return mass;
}
// A rock is a disc of radius R schematic cells aimed at its centre: one interval [-R, R] about the aim, span 2R.
// The one price of it, read by HitProbability's forecast and by Commit, at the rock's radius now.
private static float RockMass(Zone zone, ChunkId chunk, float precision, out float span)
{
var radius = zone.ChunkCells(chunk);
span = 2f * radius;
Span<Interval> disc = stackalloc Interval[1];
disc[0] = new Interval { Lo = -radius, Hi = radius };
return saturate(Mass(disc, 0f, Sigma(precision)));
}
// Phi(z) = 1/2 (1 + erf(z / sqrt(2))): the standard normal CDF, CultMath's erf the one function underneath
// every Gaussian-mass sum this cut computes.
private static float Phi(float z) => .5f * (1f + erf(z * (1f / SQRT2)));
@ -2054,6 +2196,9 @@ public struct PendingShot
public int ShotId;
public Entity Source;
public Entity Target;
// Mining Cut 4b: the rock a shot flies at, frozen at Fire; at most one of Target and Chunk is set. Unity reads
// Target only, so a rock shot draws untargeted until the Unity follow-up (mining-unity-chunk-presentation).
public ChunkId? Chunk;
public EquippedItem Weapon;
public EquippedItem Aimed;
// The mount the aim was at, when it was at a mount rather than an item (Entity.ResolvedAim at Fire), frozen with Aimed:
@ -2113,7 +2258,7 @@ public struct PendingShot
// A fused round with no target: where it bursts is decided at its commit (the first hull its aim line meets), so its
// damage reads the falloff there rather than at Fire.
public bool DeliveredAtCommit => Fuse != null && Target == null;
public bool DeliveredAtCommit => Fuse != null && Target == null && Chunk == null;
}
// What a round does when fired (FireControl.Solve): Direct resolves against its target, Burst detonates at a
@ -2147,7 +2292,7 @@ public struct FireSolution
public float FireRange;
// Whether the shooter holds valid targeting data on the target (Designated); the HUD and the agents read it here.
public bool Designated;
public Entity Engaged;
public TargetRef Engaged;
public float2 TravelDirection;
public float3 BurstPosition;
public WeaponFuse? Fuse;
@ -2313,6 +2458,7 @@ public sealed class ShotOutcome
public int ShotId;
public Entity Source;
public Entity Target;
public ChunkId? Chunk;
public EquippedItem Weapon;
// What the commit decided, typed: a shot rolled and missed, rolled and hit, or was a fused round with no
// target and so burst without rolling, or a proximity round whose roll missed but which detonates anyway

View file

@ -48,8 +48,7 @@ public sealed class BeltTargets : ITargetProvider
_zone = zone;
_field = field;
_data = data;
var size = settings.AsteroidSize;
_largestRadius = size.Exponent > 0 ? max(abs(size.Minimum), abs(size.Maximum)) : float.PositiveInfinity;
_largestRadius = Zone.LargestChunkRadius(settings);
var count = data.Asteroids.Length;
_periods = new double[count];

View file

@ -62,6 +62,9 @@ public class Zone
// the entities, and one provider per belt. The reticle, cycling and any range query over rocks go through it;
// nothing else scans a belt.
public readonly TargetingIndex Targets = new TargetingIndex();
// Mining cut 4a: the receiver of every rock impact FireControl.Detonate publishes. Notification only: a presenter
// subscribes once per zone, and nothing in ServerShared reads it.
public readonly CapabilityEvents ChunkCapabilities = new CapabilityEvents();
private readonly List<Sun> _suns = new List<Sun>();
private HashSet<CultRecordKey> _updatedOrbits = new HashSet<CultRecordKey>();
@ -674,6 +677,14 @@ public class Zone
return belt.DamagedSize(chunk.Index, wear.Damage, Settings);
}
// No rock of any belt, whole or worn, is wider than this (AsteroidSize's bound over every argument, so over the
// Asteroid.Size range the generator writes). The index's visibility bound and a blast's query reach both use it.
public static float LargestChunkRadius(PlanetSettings settings)
{
var size = settings.AsteroidSize;
return size.Exponent > 0 ? max(abs(size.Minimum), abs(size.Maximum)) : float.PositiveInfinity;
}
// Cut 2: the only writer of chunk wear. Returns whether this hit broke the chunk. The break threshold is
// strictly greater-than, matching the deleted per-tick miner's own comparison against accumulated damage:
// damage exactly equal to hitpoints does not yet break the chunk, only damage that exceeds them does.
@ -715,6 +726,20 @@ public class Zone
return AsteroidBelts[belt].Pose(index, time, parentPosition, size);
}
// Mining Cut 4b: the one owner of a chunk's planar velocity, the central difference of the pose function at zone
// time. Derived on every read, never stored: the intercept, the fire-time projection and the mount's sweep all
// read it here.
public float2 ChunkVelocity(ChunkId chunk)
{
const double h = .05;
var ahead = ChunkPose(chunk.Field, chunk.Index, _time + h).xy;
var behind = ChunkPose(chunk.Field, chunk.Index, _time - h).xy;
return (ahead - behind) / (float) (2 * h);
}
// A chunk's radius in schematic cells: what visibility and a shot's silhouette both measure it in.
public float ChunkCells(ChunkId chunk) => ChunkRadius(chunk) / SchematicCellSize;
// Mining Cut 3 (docs/mining-cut-refresh.md): how bright a chunk is -- Reflector's rule (cross-section × the light
// on it), with the field kind's reflectivity per schematic cell times the chunk's area in cells. Computed when
// asked; nothing about it is stored. A worn chunk shrinks and dims; a broken one, or one of a kindless belt, is dark.
@ -723,7 +748,7 @@ public class Zone
if (!ChunkExists(chunk)) return 0f;
var kind = _itemManager.ItemData.Get(((AsteroidBeltData) Planets[chunk.Field]).Kind);
if (kind == null) return 0f;
var cells = ChunkRadius(chunk) / _itemManager.GameplaySettings.SchematicCellSize;
var cells = ChunkCells(chunk);
return kind.CrossSection * PI * cells * cells * GetLight(ChunkPose(chunk.Field, chunk.Index).xy);
}

View file

@ -32,7 +32,7 @@ public sealed partial class MiningCut3Tests : IDisposable
Directory.Delete(_root, true);
}
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
internal static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
private static GameplaySettings GameSettings() => new GameplaySettings
{
@ -49,7 +49,7 @@ public sealed partial class MiningCut3Tests : IDisposable
// Every sun carries a well (the belts circle in it, c = 9), one sun whose light reaches 1000 units, and the chunk
// curves MiningCut2Tests uses, so sizes and wear vary over the fixture's range.
private static PlanetSettings PlanetSettings(float lightRadius = 1000f) => new PlanetSettings
internal static PlanetSettings PlanetSettings(float lightRadius = 1000f) => new PlanetSettings
{
WellStrength = new ExponentialCurve { Constant = 9f },
RocheLimit = new ExponentialCurve { Constant = 1f },
@ -66,7 +66,7 @@ public sealed partial class MiningCut3Tests : IDisposable
Keys = new[] { new float4(0, 1, 0, 0), new float4(1, .3f, 0, 0) }
};
private sealed class Scene
internal sealed class Scene
{
public CultCache Cache;
public ItemManager Items;
@ -81,16 +81,16 @@ public sealed partial class MiningCut3Tests : IDisposable
// A belt around the sun: its chunks orbit the origin at their own distances, so at zone time 0 a chunk of
// phase p sits at distance × (cos 2πp, sin 2πp). `kind` null leaves the belt kindless, for the zone to assign.
private sealed class BeltSpec
internal sealed class BeltSpec
{
public Asteroid[] Asteroids;
public FieldKindData Kind;
}
private static Asteroid Rock(float distance, float phase = 0f, float size = .5f) =>
internal static Asteroid Rock(float distance, float phase = 0f, float size = .5f) =>
new Asteroid { Distance = distance, Phase = phase, Size = size, RotationSpeed = .1f };
private Scene BuildScene(FieldKindData[] kinds = null, params BeltSpec[] belts) => BuildSceneAt(float2.zero, kinds, belts);
internal Scene BuildScene(FieldKindData[] kinds = null, params BeltSpec[] belts) => BuildSceneAt(float2.zero, kinds, belts);
// The same scene with its sun, and so every belt centre, at `sunAt`.
private Scene BuildSceneAt(float2 sunAt, FieldKindData[] kinds, params BeltSpec[] belts) =>
@ -165,10 +165,10 @@ public sealed partial class MiningCut3Tests : IDisposable
return scene;
}
private static FieldKindData Kind(string name, float crossSection, float weight = 1f) =>
internal static FieldKindData Kind(string name, float crossSection, float weight = 1f) =>
new FieldKindData { Name = name, CrossSection = crossSection, GenerationWeight = weight };
private static BeltSpec Belt(FieldKindData kind, params Asteroid[] asteroids) => new BeltSpec { Kind = kind, Asteroids = asteroids };
internal static BeltSpec Belt(FieldKindData kind, params Asteroid[] asteroids) => new BeltSpec { Kind = kind, Asteroids = asteroids };
// A chunk's planar position now (zone time never advances in these tests).
private static float2 At(Scene s, ChunkId chunk) => s.Zone.ChunkPose(chunk.Field, chunk.Index).xy;
@ -180,14 +180,14 @@ public sealed partial class MiningCut3Tests : IDisposable
return kind.CrossSection * PI * cells * cells * s.Zone.GetLight(At(s, chunk));
}
private static GearData Gear(string name, BehaviorData behavior) => new GearData
internal static GearData Gear(string name, BehaviorData behavior) => new GearData
{
Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { behavior }
};
private static EquippableItem Mint(Scene s, ItemData design) => new EquippableItem
internal static EquippableItem Mint(Scene s, ItemData design) => new EquippableItem
{
Data = s.Cache.RefOf<ItemData>(design),
Durability = design is EquippableItemData equippable ? equippable.Durability : 0,

View file

@ -0,0 +1,279 @@
/* 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.Linq;
using CultMath;
using UniRx;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Mining cut 4a (docs/mining-cut-refresh.md, Q10 = A): a blast's disc is shared by area over whatever it covers, hull
// cells or rocks, through FireControl.Detonate; each worn rock publishes one absorb on Zone.ChunkCapabilities. Reuses
// MiningCut3Tests' belt fixture (a partial of that class), so a filter on these tests names the methods below.
public sealed partial class MiningCut3Tests
{
// A one-rock belt: the rock at phase 0 sits on +x of the belt centre, `size` its authored size.
private (Scene scene, ChunkId rock) OneRock(float size = .5f, float distance = 200f)
{
var kind = Kind("Asteroid", 4f);
var s = BuildScene(new[] { kind }, Belt(kind, Rock(distance, 0f, size)));
return (s, new ChunkId(s.Belts[0], 0));
}
private static List<AbsorbEvent> Absorbs(Scene s, List<IDisposable> subs)
{
var list = new List<AbsorbEvent>();
subs.Add(s.Zone.ChunkCapabilities.Absorb.Subscribe(list.Add));
return list;
}
// The area of a disc at the origin of radius r1 and a disc at (d, 0) of radius r2, by midpoint integration of the
// chord overlap -- no formula of the code under test.
private static double NumericLens(double d, double r1, double r2)
{
var lo = Math.Max(-r1, d - r2);
var hi = Math.Min(r1, d + r2);
if (hi <= lo) return 0;
const int n = 400000;
var h = (hi - lo) / n;
var area = 0.0;
for (var i = 0; i < n; i++)
{
var x = lo + (i + .5) * h;
var a = Math.Sqrt(Math.Max(0, r1 * r1 - x * x));
var b = Math.Sqrt(Math.Max(0, r2 * r2 - (x - d) * (x - d)));
area += 2 * Math.Min(a, b) * h;
}
return area;
}
// The area of a disc centred (cx, cy) of radius r inside the axis-aligned rectangle, by the same integration.
private static double NumericDiscInRect(double cx, double cy, double r, double x0, double x1, double y0, double y1)
{
var lo = Math.Max(cx - r, x0);
var hi = Math.Min(cx + r, x1);
if (hi <= lo) return 0;
const int n = 400000;
var h = (hi - lo) / n;
var area = 0.0;
for (var i = 0; i < n; i++)
{
var x = lo + (i + .5) * h;
var half = Math.Sqrt(Math.Max(0, r * r - (x - cx) * (x - cx)));
area += Math.Max(0, Math.Min(cy + half, y1) - Math.Max(cy - half, y0)) * h;
}
return area;
}
[Theory]
[InlineData(5f, 3f, 4f)]
[InlineData(1f, 3f, 4f)]
[InlineData(6.5f, 3f, 4f)]
[InlineData(2.5f, 2f, 5f)]
[InlineData(3f, 3f, 3f)]
public void CircleCircleOverlapIsTheLens(float d, float r1, float r2)
{
var expected = NumericLens(d, r1, r2);
Assert.Equal(expected, FireControl.CircleCircleOverlap(d, r1, r2), 3);
Assert.Equal(expected, FireControl.CircleCircleOverlap(d, r2, r1), 3);
}
[Fact]
public void CircleCircleOverlapIsExactAtTheLimits()
{
Assert.Equal(0f, FireControl.CircleCircleOverlap(7f, 3f, 4f)); // tangent
Assert.Equal(0f, FireControl.CircleCircleOverlap(8f, 3f, 4f)); // disjoint
Assert.Equal(PI * 9f, FireControl.CircleCircleOverlap(1f, 3f, 4f), 3); // contained: the smaller disc
Assert.Equal(PI * 9f, FireControl.CircleCircleOverlap(0f, 4f, 3f), 3); // concentric
Assert.Equal(PI * 9f, FireControl.CircleCircleOverlap(1f, 3f, 4f), 3);
Assert.Equal(0f, FireControl.CircleCircleOverlap(0f, 0f, 3f));
}
[Fact]
public void ABlastBesideAnUntargetedRockWearsIt()
{
var (s, rock) = OneRock();
var at = At(s, rock);
var radius = 5f;
var offset = 3f;
var undamaged = s.Zone.ChunkRadius(rock);
FireControl.Detonate(s.Zone, at + float2(offset, 0), radius, 10f, DamageType.Kinetic);
var share = (float) (10.0 * NumericLens(offset, radius, undamaged) / (Math.PI * radius * radius));
Assert.True(share > 0f);
Assert.Equal(s.Zone.AsteroidBelts[rock.Field].DamagedSize(rock.Index, share, s.Zone.Settings), s.Zone.ChunkRadius(rock), 3);
}
[Fact]
public void ABlastSharesItsDiscOverCellsAndRocksByArea()
{
var (s, rock) = OneRock();
var rockAt = At(s, rock);
var rockRadius = s.Zone.ChunkRadius(rock);
var ship = SpawnShip(s, rockAt + float2(-12f, 0f));
var radius = 6f;
var blast = rockAt + float2(-5.5f, 0f);
var damage = 1f;
var subs = new List<IDisposable>();
var hullAbsorbs = new List<AbsorbEvent>();
subs.Add(ship.Capabilities.Absorb.Subscribe(hullAbsorbs.Add));
var rockAbsorbs = Absorbs(s, subs);
FireControl.Detonate(s.Zone, blast, radius, damage, DamageType.Kinetic);
foreach (var sub in subs) sub.Dispose();
// The hull is a 5 x 5 block of 2-unit cells about its position; the rock a disc; the two do not overlap.
var p = ship.Position.xz;
var area = Math.PI * radius * radius;
var hullArea = NumericDiscInRect(blast.x, blast.y, radius, p.x - 5, p.x + 5, p.y - 5, p.y + 5);
var rockArea = NumericLens(length(rockAt - blast), radius, rockRadius);
Assert.True(hullArea > 0 && rockArea > 0 && hullArea + rockArea < area, "fixture: both covered, some of the disc lost");
var hull = Assert.Single(hullAbsorbs);
var worn = Assert.Single(rockAbsorbs);
Assert.Equal(damage * hullArea / area, hull.Magnitude, 4);
Assert.Equal(damage * rockArea / area, worn.Magnitude, 4);
var lost = damage * (area - hullArea - rockArea) / area;
Assert.Equal(damage, hull.Magnitude + worn.Magnitude + lost, 4);
}
[Fact]
public void ARockWhoseCentreIsOutsideTheBlastIsStillWorn()
{
// The widest rock (size 1, radius 9) and a small blast: the blast radius 3 plus the rock's 9 exceeds the
// 8 between them, yet the centre is far outside the blast disc.
var (s, rock) = OneRock(size: 1f);
var at = At(s, rock);
var radius = 3f;
Assert.Equal(9f, s.Zone.ChunkRadius(rock), 3);
var subs = new List<IDisposable>();
var absorbs = Absorbs(s, subs);
FireControl.Detonate(s.Zone, at + float2(8f, 0f), radius, 10f, DamageType.Kinetic);
subs.ForEach(x => x.Dispose());
var worn = Assert.Single(absorbs);
Assert.Equal(10.0 * NumericLens(8, radius, 9) / (Math.PI * radius * radius), worn.Magnitude, 4);
Assert.True(s.Zone.ChunkRadius(rock) < 9f);
}
[Fact]
public void ATangentOrDistantBlastWearsNothing()
{
var (s, rock) = OneRock();
var at = At(s, rock);
var radius = s.Zone.ChunkRadius(rock);
var subs = new List<IDisposable>();
var absorbs = Absorbs(s, subs);
FireControl.Detonate(s.Zone, at + float2(2f * radius, 0f), radius, 10f, DamageType.Kinetic);
FireControl.Detonate(s.Zone, at + float2(60f, 0f), 5f, 10f, DamageType.Kinetic);
subs.ForEach(x => x.Dispose());
Assert.Empty(absorbs);
Assert.Equal(radius, s.Zone.ChunkRadius(rock));
}
[Fact]
public void ABrokenRockTakesNothingAndPublishesNothing()
{
var (s, rock) = OneRock();
var at = At(s, rock);
Assert.True(s.Zone.Wear(rock, 1e6f));
var subs = new List<IDisposable>();
var absorbs = Absorbs(s, subs);
FireControl.Detonate(s.Zone, at, 8f, 10f, DamageType.Kinetic);
subs.ForEach(x => x.Dispose());
Assert.Empty(absorbs);
Assert.False(s.Zone.ChunkExists(rock));
Assert.Equal(0f, s.Zone.ChunkRadius(rock));
}
[Fact]
public void EachWornRockPublishesOneAbsorb()
{
var kind = Kind("Asteroid", 4f);
// Two rocks side by side and a third out of reach, listed out of order of their distance.
var s = BuildScene(new[] { kind }, Belt(kind, Rock(200f, 0f, .5f), Rock(300f, 0f, .5f), Rock(205f, 0f, .5f)));
var a = new ChunkId(s.Belts[0], 0);
var b = new ChunkId(s.Belts[0], 2);
var far = new ChunkId(s.Belts[0], 1);
var blast = (At(s, a) + At(s, b)) / 2f;
var radius = 8f;
var damage = 10f;
var seen = new List<(AbsorbEvent e, float aRadius, float bRadius)>();
using var sub = s.Zone.ChunkCapabilities.Absorb.Subscribe(e => seen.Add((e, s.Zone.ChunkRadius(a), s.Zone.ChunkRadius(b))));
var undamagedA = s.Zone.ChunkRadius(a);
var undamagedB = s.Zone.ChunkRadius(b);
FireControl.Detonate(s.Zone, blast, radius, damage, DamageType.Kinetic);
Assert.Equal(2, seen.Count);
Assert.All(seen, x => Assert.Equal(AbsorbLayer.Hull, x.e.Layer));
Assert.All(seen, x => Assert.Equal(0f, x.e.Position.y));
// Walked in index order; each absorb lands before its own rock's wear, and the first rock's wear is already
// done when the second is published.
Assert.Equal(undamagedA, seen[0].aRadius);
Assert.Equal(undamagedB, seen[0].bRadius);
Assert.True(seen[1].aRadius < undamagedA);
Assert.Equal(undamagedB, seen[1].bRadius);
Assert.Equal(damage * NumericLens(length(At(s, a) - blast), radius, undamagedA) / (Math.PI * radius * radius), seen[0].e.Magnitude, 4);
Assert.Equal(damage * NumericLens(length(At(s, b) - blast), radius, undamagedB) / (Math.PI * radius * radius), seen[1].e.Magnitude, 4);
// The direction runs from the blast out through the rock.
var away = normalize(At(s, a) - blast);
Assert.Equal(away.x, seen[0].e.Direction.x, 4);
Assert.Equal(away.y, seen[0].e.Direction.z, 4);
Assert.Equal(s.Zone.AsteroidBelts[far.Field].UndamagedSize(far.Index, s.Zone.Settings), s.Zone.ChunkRadius(far));
}
// The walk is fixed: (field ordinal, index), whatever order the targeting index yields. Two belts, rocks scattered
// so the index's own order is not the sorted one; the absorbs come in sorted order.
[Fact]
public void RocksAreWornInFieldThenIndexOrder()
{
var kind = Kind("Asteroid", 4f);
var s = BuildScene(new[] { kind },
Belt(kind, Rock(200f, .7f, .5f), Rock(200f, .1f, .5f), Rock(200f, .4f, .5f)),
Belt(kind, Rock(200f, .9f, .5f), Rock(200f, .2f, .5f)));
var rocks = new[] { 0, 1, 2 }.Select(i => new ChunkId(s.Belts[0], i)).Concat(new[] { 0, 1 }.Select(i => new ChunkId(s.Belts[1], i))).ToList();
var blast = float2.zero;
var seen = new List<AbsorbEvent>();
using var sub = s.Zone.ChunkCapabilities.Absorb.Subscribe(seen.Add);
FireControl.Detonate(s.Zone, blast, 400f, 1000f, DamageType.Kinetic);
Assert.Equal(rocks.Count, seen.Count);
var expected = rocks.OrderBy(r => r.Field.Value, StringComparer.Ordinal).ThenBy(r => r.Index).ToList();
for (var i = 0; i < expected.Count; i++)
{
var away = normalize(At(s, expected[i]) - blast);
Assert.Equal(away.x, seen[i].Direction.x, 3);
Assert.Equal(away.y, seen[i].Direction.z, 3);
}
}
[Fact]
public void AMineBlastWearsRocks()
{
var (s, rock) = OneRock();
var at = At(s, rock);
var undamaged = s.Zone.ChunkRadius(rock);
var absorbs = new List<AbsorbEvent>();
using var sub = s.Zone.ChunkCapabilities.Absorb.Subscribe(absorbs.Add);
s.Zone.Lay(new Mine
{
Id = s.Zone.NextBodyId(), Body = new KinematicBody { Position = at + float2(2f, 0f), Velocity = float2.zero },
ArmingDelay = 0f, FuseDelay = 100f, Lifetime = 0f, BlastRadius = 6f, Damage = 10f,
DamageType = DamageType.Kinetic, Layer = null, LayerFaction = null
});
s.Zone.Update(.001f);
Assert.Empty(s.Zone.Mines);
Assert.Single(absorbs);
Assert.True(s.Zone.ChunkRadius(rock) < undamaged);
}
}

View file

@ -0,0 +1,458 @@
/* 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.Linq;
using CultMath;
using UniRx;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Mining Cut 4b (docs/mining-cut-refresh.md, Q12 = A): a shot at a rock goes through FireControl's one fire path
// and wears it; the rock is a disc. The fixture is MiningCut3Tests' belt scene: a shooter at the origin, one rock
// straight ahead (+z, phase .25) at `Distance`, a gun and a targeting system fitted on the shooter.
public sealed class MiningCut4bTests : IDisposable
{
private const float Distance = 100f;
private readonly MiningCut3Tests _fixtures = new MiningCut3Tests();
public void Dispose() => _fixtures.Dispose();
private sealed class Rig
{
public MiningCut3Tests.Scene Scene;
public Zone Zone => Scene.Zone;
public Ship Shooter;
public EquippedItem GunItem;
public Weapon Gun;
public ChunkId Rock;
public List<ShotOutcome> Resolved = new List<ShotOutcome>();
}
private static InstantWeaponData Behavior(bool launcher, float damage, float velocity, float spread, float effective = float.PositiveInfinity)
{
InstantWeaponData data = launcher ? new LockWeaponData
{
LockSpeed = MiningCut3Tests.Constant(1000), SensorImpact = MiningCut3Tests.Constant(1), LockAngle = MiningCut3Tests.Constant(180),
DirectionImpact = MiningCut3Tests.Constant(1), Decay = MiningCut3Tests.Constant(1)
} : new InstantWeaponData();
data.Damage = MiningCut3Tests.Constant(damage);
data.Range = MiningCut3Tests.Constant(500f);
data.MinRange = MiningCut3Tests.Constant(0);
data.EffectiveRange = MiningCut3Tests.Constant(effective);
data.DamageAtMaxRange = 0f;
data.Velocity = MiningCut3Tests.Constant(velocity);
data.Spread = MiningCut3Tests.Constant(spread);
data.DamageSpread = MiningCut3Tests.Constant(0);
data.Penetration = MiningCut3Tests.Constant(0);
data.Count = MiningCut3Tests.Constant(1);
data.BurstTime = MiningCut3Tests.Constant(0);
data.Cooldown = MiningCut3Tests.Constant(1000);
return data;
}
// `precision` 1 gives sigma 1 cell; accuracy 1 and a resolution that asks little of the info make PSensor 1.
private Rig Build(bool sensor = true, bool launcher = false, float damage = 10f, float velocity = 0f, float spread = 0f,
float precision = 1f, float effective = float.PositiveInfinity, WeaponFuse? fuse = null, float blastRadius = 0f,
float rockSize = 0f, float crossSection = 20f, bool target = true, float tracking = 1000000f)
{
var kind = MiningCut3Tests.Kind("Asteroid", crossSection);
var scene = _fixtures.BuildScene(new[] { kind }, MiningCut3Tests.Belt(kind, MiningCut3Tests.Rock(Distance, .25f, rockSize)));
var gear = MiningCut3Tests.Gear("Targeting", new TargetingSystemData
{
Accuracy = MiningCut3Tests.Constant(1f), Resolution = MiningCut3Tests.Constant(1000f),
Precision = MiningCut3Tests.Constant(precision), Tracking = MiningCut3Tests.Constant(tracking)
});
scene.Cache.Upsert(gear);
var gun = new WeaponItemData
{
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Fuse = fuse, BlastRadius = blastRadius,
Behaviors = { Behavior(launcher, damage, velocity, spread, effective) }
};
scene.Cache.Upsert(gun);
var shape = new Shape(5, 5);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
var hull = new HullData
{
Name = "Gunship", HullType = HullType.Ship, Shape = shape, Durability = 10, Mass = 1000,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
};
scene.Cache.Upsert(hull);
var shooter = new Ship(scene.Items, scene.Zone, MiningCut3Tests.Mint(scene, hull), new EntitySettings());
if (sensor) Assert.True(shooter.TryEquip(MiningCut3Tests.Mint(scene, scene.Eye)), "eye");
Assert.True(shooter.TryEquip(MiningCut3Tests.Mint(scene, gear)), "targeting");
var minted = MiningCut3Tests.Mint(scene, gun);
Assert.True(shooter.TryEquip(minted, new int2(0, 0)), "gun");
scene.Zone.Admit(shooter, piloted: false);
shooter.Position = float3.zero;
shooter.Direction = float2(0, 1);
shooter.Aim = float3(0, 0, 1);
shooter.Update(.05f); // the fitted sensor becomes active on its first tick; zone time does not advance
shooter.Position = float3.zero;
shooter.Velocity = float2.zero;
var item = shooter.Equipment.Single(x => x.EquippableItem == minted);
var rig = new Rig
{
Scene = scene, Shooter = shooter, GunItem = item, Gun = (Weapon) item.Behaviors.Single(b => b is Weapon),
Rock = new ChunkId(scene.Belts[0], 0)
};
scene.Zone.ShotResolved.Subscribe(outcome => rig.Resolved.Add(outcome));
if (target && sensor) Assert.True(shooter.SetTarget(rig.Rock), "target");
return rig;
}
private static float Phi(double z) => (float) (.5 * (1 + erf((float) (z / Math.Sqrt(2)))));
// The rock's radius in cells is the one the zone reports, sigma the targeting system's (floored at half a cell).
private static float Disc(Rig rig)
{
var sigma = max(1f / FireControl.Precision(rig.Shooter), .5f);
var radius = rig.Zone.ChunkCells(rig.Rock);
return Phi(radius / sigma) - Phi(-radius / sigma);
}
// PSensor as the rule states it, for a gear that wants only the detection threshold plus a thousandth of the rest.
private static float Sensor(Rig rig, float info)
{
var th = rig.Scene.Items.GameplaySettings.TargetDetectionInfoThreshold;
return saturate(unlerp(th, th + (1f - th) / 1000f, info));
}
// ==== one fire path ====
// Accuracy, PSensor, PSpread, Sigma and PMount price a rock exactly as a ship at equal range, precision and info:
// a two-cell ship (span 2, centre-aimed) and a rock one cell in radius (span 2) are the same disc to the gun, and
// an unauthored gun follows anything, so the mount is 1 for both.
[Fact]
public void ARockShotUsesTheSameFactors()
{
var rig = Build(spread: 4f, precision: 1f, crossSection: 1f);
var s = rig.Scene;
Assert.Equal(1f, rig.Zone.ChunkCells(rig.Rock), 4);
var info = rig.Shooter.ChunkInfo(rig.Rock);
Assert.True(info > .11f && info < 1f, $"the rock's info {info} should be visible and unsaturated");
var hull = new HullData { Name = "Pair", HullType = HullType.Ship, Shape = new Shape(2, 2), Durability = 10, Mass = 1000 };
foreach (var cell in hull.Shape.AllCoordinates) hull.Shape[cell] = true;
s.Cache.Upsert(hull);
var at = rig.Zone.ChunkPose(rig.Rock.Field, rig.Rock.Index).xy;
var ship = new Ship(s.Items, rig.Zone, MiningCut3Tests.Mint(s, hull), new EntitySettings());
rig.Zone.Admit(ship, piloted: false);
ship.Position = float3(at.x, 0, at.y);
ship.Direction = float2(0, 1);
rig.Shooter.EntityInfoGathered[ship] = info;
rig.Shooter.VisibleEntities.Add(ship);
var rock = FireControl.HitProbability(rig.Gun, rig.Shooter, rig.Rock);
var vessel = FireControl.HitProbability(rig.Gun, rig.Shooter, ship);
Assert.InRange(rock, .05f, .99f);
Assert.Equal(vessel, rock, 4);
}
[Fact]
public void AnUnseenRockCannotBeFiredOn()
{
var blind = Build(sensor: false);
Assert.False(FireControl.Designated(blind.Gun, blind.Shooter, blind.Rock, out var range));
Assert.Equal(Distance, range, 2);
Assert.Equal(0f, FireControl.HitProbability(blind.Gun, blind.Shooter, blind.Rock));
Assert.False(FireControl.Solve(blind.Gun, blind.GunItem, blind.Shooter, blind.Rock).Designated);
var sighted = Build();
Assert.True(FireControl.Designated(sighted.Gun, sighted.Shooter, sighted.Rock, out _));
Assert.True(FireControl.HitProbability(sighted.Gun, sighted.Shooter, sighted.Rock) > 0f);
}
// Q12 = A: a launcher (a LockWeapon) is never designated on a rock, locked or not.
[Fact]
public void LaunchersCannotFireOnARock()
{
var launcher = Build(launcher: true);
Assert.False(FireControl.Designated(launcher.Gun, launcher.Shooter, launcher.Rock, out _));
Assert.Equal(0f, FireControl.HitProbability(launcher.Gun, launcher.Shooter, launcher.Rock));
var plain = Build();
Assert.True(FireControl.Designated(plain.Gun, plain.Shooter, plain.Rock, out _));
}
// POnHull is the closed form Phi(R/sigma) - Phi(-R/sigma): a zero-spread gun with a full sensor reading is priced by the
// disc alone.
[Fact]
public void DiscMassIsTheClosedForm()
{
var rig = Build(spread: 0f, precision: 1f, rockSize: .5f);
var info = rig.Shooter.ChunkInfo(rig.Rock);
var expected = Sensor(rig, info) * Disc(rig);
Assert.InRange(Disc(rig), .5f, .99f);
Assert.Equal(expected, FireControl.HitProbability(rig.Gun, rig.Shooter, rig.Rock), 4);
}
// Commit's price at fire time is the forecast: for a rock still at the instant it is fired at, one price.
[Fact]
public void TheForecastIsTheCommitPriceForARock()
{
var rig = Build(spread: 4f, precision: 1f, velocity: 50f);
var forecast = FireControl.HitProbability(rig.Gun, rig.Shooter, rig.Rock);
var id = FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter);
Assert.True(rig.Zone.TryGetShot(id, out var shot));
Assert.Equal(rig.Rock, shot.Chunk);
Assert.Null(shot.Target);
Assert.Equal(forecast, FireControl.CommitProbability(shot, shot.FireTime, out _), 4);
}
// ==== resolution ====
private static void Run(Rig rig, float seconds)
{
for (var elapsed = 0f; elapsed < seconds; elapsed += .01f) rig.Zone.Update(.01f);
}
// A rock hit has no lane and no shield. Hits wear the rock by the shot's Damage after the falloff, and each
// publishes one absorb on the zone's ChunkCapabilities at the rock's surface facing the shot.
[Fact]
public void DirectHitsWearTheRock()
{
var rig = Build(damage: 3f, rockSize: .5f, precision: 1000f, effective: 50f);
var absorbs = new List<AbsorbEvent>();
var surface = new List<float>();
rig.Zone.ChunkCapabilities.Absorb.Subscribe(e =>
{
absorbs.Add(e);
var centre = rig.Zone.ChunkPose(rig.Rock.Field, rig.Rock.Index).xy;
surface.Add(length(float2(e.Position.x, e.Position.z) - centre) - rig.Zone.ChunkRadius(rig.Rock));
});
var before = rig.Zone.ChunkRadius(rig.Rock);
var factor = WeaponData.RangeFactor(0f, 50f, 500f, 0f, Distance);
Assert.InRange(factor, .01f, .99f);
for (var shot = 0; shot < 12; shot++)
{
FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter);
Run(rig, .02f);
}
var hits = rig.Resolved.Count(o => o.Hit);
Assert.InRange(hits, 1, 12);
Assert.Equal(hits, absorbs.Count);
Assert.All(rig.Resolved.Where(o => o.Hit), o => { Assert.Equal(0, o.Cell.x); Assert.Equal(0, o.Cell.y); Assert.False(o.Shielded); });
Assert.All(absorbs, e => Assert.Equal(3f * factor, e.Magnitude, 2));
Assert.All(surface, d => Assert.True(abs(d) < 1e-3f, $"absorb was {d} off the rock's surface"));
Assert.All(absorbs, e => Assert.Equal(AbsorbLayer.Hull, e.Layer));
var hp = MiningCut3Tests.PlanetSettings().AsteroidHitpoints.Evaluate(.5f);
var expected = MiningCut3Tests.PlanetSettings().AsteroidSize.Evaluate((hp - hits * 3f * factor) / hp * .5f);
Assert.Equal(expected, rig.Zone.ChunkRadius(rig.Rock), 3);
Assert.True(rig.Zone.ChunkRadius(rig.Rock) < before);
}
// A contact round that hits a rock detonates at its centre as it arrives, through Detonate: a bystander beside it
// takes the blast from the rock's centre, not from the muzzle or from a lane.
[Fact]
public void AContactRoundAtARockDetonatesAtItsCentre()
{
var rig = Build(damage: 10f, fuse: WeaponFuse.Contact, blastRadius: 6f, precision: 1000f, rockSize: .5f);
var s = rig.Scene;
var at = rig.Zone.ChunkPose(rig.Rock.Field, rig.Rock.Index).xy;
var bystander = new Ship(s.Items, rig.Zone, MiningCut3Tests.Mint(s, s.Hull), new EntitySettings());
rig.Zone.Admit(bystander, piloted: false);
bystander.Position = float3(at.x + 3f, 0, at.y);
var burstAt = new List<float>();
bystander.Capabilities.Absorb.Subscribe(e =>
{
var centre = rig.Zone.ChunkPose(rig.Rock.Field, rig.Rock.Index).xy;
burstAt.Add(length(float2(e.Position.x, e.Position.z) - centre));
});
for (var shot = 0; shot < 12; shot++)
{
FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter);
Run(rig, .02f);
}
Assert.InRange(rig.Resolved.Count(o => o.Hit), 1, 12);
Assert.NotEmpty(burstAt);
Assert.All(burstAt, d => Assert.True(d < 1e-2f, $"blast was {d} from the rock's centre"));
}
// A rock that no longer exists when its round arrives, broken in flight, resolves as a miss, like a target that
// left the zone: nothing is worn and nothing absorbs.
[Fact]
public void ARockThatBreaksMidFlightIsAMiss()
{
var rig = Build(velocity: 50f, precision: 1000f);
var absorbs = 0;
rig.Zone.ChunkCapabilities.Absorb.Subscribe(_ => absorbs++);
FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter);
Assert.Empty(rig.Resolved);
Assert.True(rig.Zone.Wear(rig.Rock, 1e6f));
Run(rig, 2.5f);
var outcome = Assert.Single(rig.Resolved);
Assert.Equal(ShotResult.Miss, outcome.Result);
Assert.Equal(0, absorbs);
Assert.False(rig.Zone.ChunkExists(rig.Rock));
}
// The shot's own dice: two zones built alike roll the same sequence of outcomes for the same shot ids.
[Fact]
public void RockDiceAreTheShotsOwn()
{
List<ShotResult> Roll()
{
var rig = Build(precision: 1f);
for (var shot = 0; shot < 20; shot++)
{
FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter);
Run(rig, .02f);
}
return rig.Resolved.Select(o => o.Result).ToList();
}
var first = Roll();
var second = Roll();
Assert.Equal(20, first.Count);
Assert.Contains(ShotResult.Hit, first);
Assert.Contains(ShotResult.Miss, first);
Assert.Equal(first, second);
}
// A rock's velocity is the pose function's derivative, derived on every read.
[Fact]
public void ARockMovesAtItsPoseVelocity()
{
var rig = Build();
var velocity = rig.Zone.ChunkVelocity(rig.Rock);
var ahead = rig.Zone.ChunkPose(rig.Rock.Field, rig.Rock.Index, rig.Zone.Time + .5).xy;
var now = rig.Zone.ChunkPose(rig.Rock.Field, rig.Rock.Index).xy;
Assert.True(length(velocity) > 1f);
Assert.True(length(velocity * .5f - (ahead - now)) < .05f * length(ahead - now));
}
// A rock is not a Ship: its evasion is 0, so Tracking (the metres of deviation a shooter forgives) never
// discounts the price of a rock. The same rock priced by a tight and by a loose tracker reads one price.
[Fact]
public void ARockEvadesNothing()
{
var tight = Build(spread: 4f, precision: 1f, tracking: 10f);
var loose = Build(spread: 4f, precision: 1f, tracking: 1000000f);
var tightPrice = FireControl.HitProbability(tight.Gun, tight.Shooter, tight.Rock);
var loosePrice = FireControl.HitProbability(loose.Gun, loose.Shooter, loose.Rock);
Assert.InRange(loosePrice, .05f, .99f);
Assert.Equal(loosePrice, tightPrice, 5);
}
// The lead is the intercept of the rock's pose velocity: the point is where the rock will be when a round of the
// gun's velocity gets there, derived here from the time of flight, not from the code under test.
[Fact]
public void AMovingRockIsLeadAtItsPoseVelocity()
{
var rig = Build(velocity: 50f);
var velocity = rig.Zone.ChunkVelocity(rig.Rock);
var now = rig.Zone.ChunkPose(rig.Rock.Field, rig.Rock.Index).xy;
Assert.True(length(velocity) > 1f);
var point = FireControl.PredictedIntercept(rig.Gun, rig.Shooter, rig.Rock);
var flight = length(point.xz - rig.Shooter.Position.xz) / 50f;
var there = now + velocity * flight;
Assert.True(length(point.xz - now) > .5f, "the aim leads the rock");
Assert.Equal(there.x, point.x, 2);
Assert.Equal(there.y, point.z, 2);
}
// A rock that breaks after its round committed a hit resolves as a miss through Step's gone rule: the frozen
// outcome says Hit, so only that rule stops the round from absorbing on, and wearing, a rock that is gone.
[Fact]
public void ARockThatBreaksAfterCommitIsAMissNotAFrozenHit()
{
var rig = Build(velocity: 50f, precision: 1000f);
var committed = new List<ShotOutcome>();
rig.Zone.ShotCommitted.Subscribe(committed.Add);
var absorbs = 0;
rig.Zone.ChunkCapabilities.Absorb.Subscribe(_ => absorbs++);
FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter);
for (var waited = 0f; committed.Count == 0 && waited < 3f; waited += .01f) rig.Zone.Update(.01f);
var frozen = Assert.Single(committed);
Assert.True(frozen.Hit, "fixture: the commit rolled a hit");
Assert.Empty(rig.Resolved);
Assert.True(rig.Zone.Wear(rig.Rock, 1e6f));
Run(rig, 2.5f);
var outcome = Assert.Single(rig.Resolved);
Assert.False(outcome.Hit);
Assert.Equal(0, absorbs);
}
private static void Wear(Rig rig, WeaponFuse fuse, int shots, out int hits, out List<AbsorbEvent> absorbs, out float worn)
{
var seen = new List<AbsorbEvent>();
rig.Zone.ChunkCapabilities.Absorb.Subscribe(seen.Add);
for (var shot = 0; shot < shots; shot++)
{
FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter);
Run(rig, .02f);
}
hits = rig.Resolved.Count(o => o.Hit);
absorbs = seen;
worn = seen.Sum(e => e.Magnitude);
}
// A contact round that hits a rock wears it exactly once, by the blast's share through Detonate: one absorb per hit,
// and the rock's wear is the sum of those absorbs' magnitudes, with no Damage added on top.
[Fact]
public void AContactRoundWearsARockExactlyOnce()
{
var rig = Build(damage: 10f, fuse: WeaponFuse.Contact, blastRadius: 6f, precision: 1000f, rockSize: .5f);
Wear(rig, WeaponFuse.Contact, 12, out var hits, out var absorbs, out var worn);
Assert.InRange(hits, 1, 12);
Assert.Equal(hits, absorbs.Count);
var belt = rig.Zone.AsteroidBelts[rig.Rock.Field];
Assert.True(rig.Zone.ChunkExists(rig.Rock));
Assert.Equal(belt.DamagedSize(rig.Rock.Index, worn, rig.Zone.Settings), rig.Zone.ChunkRadius(rig.Rock), 3);
}
// The same for a proximity round, which detonates at its burst point hit or miss: one absorb per resolved round
// that covers the rock, and no wear beyond them.
[Fact]
public void AProximityRoundWearsARockExactlyOnce()
{
var rig = Build(damage: 10f, fuse: WeaponFuse.Proximity, blastRadius: 6f, precision: 1000f, rockSize: .5f);
Wear(rig, WeaponFuse.Proximity, 12, out _, out var absorbs, out var worn);
Assert.Equal(12, rig.Resolved.Count);
Assert.NotEmpty(absorbs);
var belt = rig.Zone.AsteroidBelts[rig.Rock.Field];
Assert.True(rig.Zone.ChunkExists(rig.Rock));
Assert.Equal(belt.DamagedSize(rig.Rock.Index, worn, rig.Zone.Settings), rig.Zone.ChunkRadius(rig.Rock), 3);
}
// A rock's absorb has one height rule whoever reports it: a direct hit and a blast both publish at height 0, not at
// the shooter's.
[Fact]
public void ARockAbsorbHasOneHeightRule()
{
var rig = Build(damage: 3f, rockSize: .5f, precision: 1000f);
rig.Shooter.Position = float3(0, 7, 0);
var heights = new List<float>();
rig.Zone.ChunkCapabilities.Absorb.Subscribe(e => heights.Add(e.Position.y));
for (var shot = 0; shot < 6; shot++)
{
FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter);
Run(rig, .02f);
}
var direct = heights.Count;
FireControl.Detonate(rig.Zone, rig.Zone.ChunkPose(rig.Rock.Field, rig.Rock.Index).xy, 5f, 3f, DamageType.Kinetic);
Assert.True(direct > 0 && heights.Count > direct);
Assert.All(heights, h => Assert.Equal(0f, h));
}
}