Merge branch 'eureka/aetheria-release-mining-4b' into eureka/aetheria-release-mining-4a

This commit is contained in:
Nibu 2026-11-12 21:29:08 +00:00
commit dab89ecd63
4 changed files with 532 additions and 77 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, 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);
zone.ChunkCapabilities.PublishAbsorb(new AbsorbEvent(float3(surface.x, shot.FireOrigin.y, surface.y), direction, shot.Damage, shot.DamageType, AbsorbLayer.Hull));
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
@ -1594,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,
@ -1922,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;
@ -1940,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)));
@ -2100,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:
@ -2159,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
@ -2193,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;
@ -2359,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

@ -726,6 +726,24 @@ 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 4b: absorbed impacts on chunks, published by FireControl once per rock hit -- the chunk's own
// Entity.Capabilities, since a chunk is not an Entity. Presentations subscribe; nothing here decides.
public readonly CapabilityEvents ChunkCapabilities = new CapabilityEvents();
// 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.
@ -734,7 +752,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,337 @@
/* 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)
{
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(1000000f)
});
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));
}
}