Mining cut 4: rocks take blasts and shots through the one fire path #18
6 changed files with 999 additions and 92 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
279
tests/Aetheria.Shared.Tests/MiningCut4aTests.cs
Normal file
279
tests/Aetheria.Shared.Tests/MiningCut4aTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
458
tests/Aetheria.Shared.Tests/MiningCut4bTests.cs
Normal file
458
tests/Aetheria.Shared.Tests/MiningCut4bTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue