From 2b3278ba3d9dd954deed41a36c52cda4bf668f93 Mon Sep 17 00:00:00 2001 From: Nibu Date: Thu, 12 Nov 2026 22:42:48 +0000 Subject: [PATCH] Mining cut 4: rocks take blasts and shots through the one fire path (#18) --- Assets/Scripts/ServerShared/FireControl.cs | 304 +++++++++--- .../ServerShared/Targeting/BeltTargets.cs | 3 +- Assets/Scripts/ServerShared/Zone.cs | 27 +- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 20 +- .../Aetheria.Shared.Tests/MiningCut4aTests.cs | 279 +++++++++++ .../Aetheria.Shared.Tests/MiningCut4bTests.cs | 458 ++++++++++++++++++ 6 files changed, 999 insertions(+), 92 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/MiningCut4aTests.cs create mode 100644 tests/Aetheria.Shared.Tests/MiningCut4bTests.cs diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 1581e3457..6cd860ab3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -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.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(); + 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 _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(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 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 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 diff --git a/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs index 3e12536c3..a64e0c624 100644 --- a/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs +++ b/Assets/Scripts/ServerShared/Targeting/BeltTargets.cs @@ -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]; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 6ba9ef7d7..d0c5b5330 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -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 _suns = new List(); private HashSet _updatedOrbits = new HashSet(); @@ -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); } diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index dc6ffd628..76a67472a 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -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(design), Durability = design is EquippableItemData equippable ? equippable.Durability : 0, diff --git a/tests/Aetheria.Shared.Tests/MiningCut4aTests.cs b/tests/Aetheria.Shared.Tests/MiningCut4aTests.cs new file mode 100644 index 000000000..7d43c86d3 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut4aTests.cs @@ -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 Absorbs(Scene s, List subs) + { + var list = new List(); + 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(); + var hullAbsorbs = new List(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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); + } +} diff --git a/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs b/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs new file mode 100644 index 000000000..351e875a2 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs @@ -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 Resolved = new List(); + } + + 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(); + var surface = new List(); + 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(); + 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 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(); + 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 absorbs, out float worn) + { + var seen = new List(); + 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(); + 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)); + } +}