From 152f97ba0c7cf4180b44d90f1da4d0fe44f01209 Mon Sep 17 00:00:00 2001 From: Nibu Date: Thu, 12 Nov 2026 21:26:52 +0000 Subject: [PATCH 1/2] Mining 4b: a shot at a rock goes through FireControl's one fire path and wears it; the rock is a disc Designated/Bears/Solve/HitProbability/TravelDirection/PredictedIntercept take TargetRef; PendingShot and ShotOutcome carry Chunk; Zone.ChunkVelocity is the one rock velocity owner; Silhouette's Gaussian sum is the one integrator the rock disc also reads; Apply wears the rock or detonates at its centre; a rock broken in flight is a miss. Deletes the untargeted-round stopgap. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 232 +++++++++++++++------ Assets/Scripts/ServerShared/Zone.cs | 20 +- 2 files changed, 185 insertions(+), 67 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 1581e3457..af1e21ba8 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, at the rock's surface facing the shot + // and before the wear shrinks it. A contact or delayed round detonates at the rock's centre as it arrives (the + // arming rule kept), through Detonate, the one area owner. + private static void ApplyRockHit(Zone zone, PendingShot shot, ChunkId rock) + { + if (!shot.Outcome.Hit) return; + + var centre = zone.ChunkPose(rock.Field, rock.Index).xy; + if (shot.Fuse == null) + { + var direction = float3(shot.TravelDirection.x, 0, shot.TravelDirection.y); + var surface = centre - shot.TravelDirection * zone.ChunkRadius(rock); + zone.ChunkCapabilities.PublishAbsorb(new AbsorbEvent(float3(surface.x, shot.FireOrigin.y, surface.y), direction, shot.Damage, shot.DamageType, AbsorbLayer.Hull)); + zone.Wear(rock, shot.Damage); + return; + } + var point = Armed(shot.FireOrigin, shot.TravelDirection, shot.BlastRadius, float3(centre.x, shot.FireOrigin.y, centre.y)).xz; + Detonate(zone, point, shot.BlastRadius, shot.Damage, shot.DamageType); + } + // Cut 12.3 (docs/fire-control-cut.md, "How a direct hit travels"): replaces Entity.ApplyHit's 0.5-step // march and Entity.DamageSchematic's even split over the whole hit shape. Spread is width (Q12-2 = A): // 2n+1 parallel lanes, sharing the shot's damage evenly over whichever lanes actually meet metal -- the @@ -1593,6 +1669,7 @@ public static class FireControl ShotId = shot.ShotId, Source = shot.Source, Target = shot.Target, + Chunk = shot.Chunk, Weapon = shot.Weapon, Result = result, Shielded = shielded, @@ -1876,9 +1953,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 +1969,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 +2150,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 +2212,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 +2246,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 +2412,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/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 6ba9ef7d7..f77a39bd1 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -715,6 +715,24 @@ public class Zone return AsteroidBelts[belt].Pose(index, time, parentPosition, size); } + // Mining Cut 4b: the one owner of a chunk's planar velocity, the central difference of the pose function at zone + // time. Derived on every read, never stored: the intercept, the fire-time projection and the mount's sweep all + // read it here. + public float2 ChunkVelocity(ChunkId chunk) + { + const double h = .05; + var ahead = ChunkPose(chunk.Field, chunk.Index, _time + h).xy; + var behind = ChunkPose(chunk.Field, chunk.Index, _time - h).xy; + return (ahead - behind) / (float) (2 * h); + } + + // A chunk's radius in schematic cells: what visibility and a shot's silhouette both measure it in. + public float ChunkCells(ChunkId chunk) => ChunkRadius(chunk) / SchematicCellSize; + + // Mining Cut 4b: absorbed impacts on chunks, published by FireControl once per rock hit -- the chunk's own + // Entity.Capabilities, since a chunk is not an Entity. Presentations subscribe; nothing here decides. + public readonly CapabilityEvents ChunkCapabilities = new CapabilityEvents(); + // Mining Cut 3 (docs/mining-cut-refresh.md): how bright a chunk is -- Reflector's rule (cross-section × the light // on it), with the field kind's reflectivity per schematic cell times the chunk's area in cells. Computed when // asked; nothing about it is stored. A worn chunk shrinks and dims; a broken one, or one of a kindless belt, is dark. @@ -723,7 +741,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); } From f43f6938b0134917aae08690c4395af20e1e9ed7 Mon Sep 17 00:00:00 2001 From: Nibu Date: Thu, 12 Nov 2026 21:26:52 +0000 Subject: [PATCH 2/2] Mining 4b tests: same factors, unseen rock, launchers, disc mass, one price, wear, contact blast, mid-flight break, dice Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 20 +- .../Aetheria.Shared.Tests/MiningCut4bTests.cs | 337 ++++++++++++++++++ 2 files changed, 347 insertions(+), 10 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/MiningCut4bTests.cs 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/MiningCut4bTests.cs b/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs new file mode 100644 index 000000000..b139b904e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut4bTests.cs @@ -0,0 +1,337 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Mining Cut 4b (docs/mining-cut-refresh.md, Q12 = A): a shot at a rock goes through FireControl's one fire path +// and wears it; the rock is a disc. The fixture is MiningCut3Tests' belt scene: a shooter at the origin, one rock +// straight ahead (+z, phase .25) at `Distance`, a gun and a targeting system fitted on the shooter. +public sealed class MiningCut4bTests : IDisposable +{ + private const float Distance = 100f; + private readonly MiningCut3Tests _fixtures = new MiningCut3Tests(); + + public void Dispose() => _fixtures.Dispose(); + + private sealed class Rig + { + public MiningCut3Tests.Scene Scene; + public Zone Zone => Scene.Zone; + public Ship Shooter; + public EquippedItem GunItem; + public Weapon Gun; + public ChunkId Rock; + public List 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) + { + var kind = MiningCut3Tests.Kind("Asteroid", crossSection); + var scene = _fixtures.BuildScene(new[] { kind }, MiningCut3Tests.Belt(kind, MiningCut3Tests.Rock(Distance, .25f, rockSize))); + var gear = MiningCut3Tests.Gear("Targeting", new TargetingSystemData + { + Accuracy = MiningCut3Tests.Constant(1f), Resolution = MiningCut3Tests.Constant(1000f), + Precision = MiningCut3Tests.Constant(precision), Tracking = MiningCut3Tests.Constant(1000000f) + }); + scene.Cache.Upsert(gear); + var gun = new WeaponItemData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, BlastRadius = blastRadius, + Behaviors = { Behavior(launcher, damage, velocity, spread, effective) } + }; + scene.Cache.Upsert(gun); + + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var hull = new HullData + { + Name = "Gunship", HullType = HullType.Ship, Shape = shape, Durability = 10, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + scene.Cache.Upsert(hull); + var shooter = new Ship(scene.Items, scene.Zone, MiningCut3Tests.Mint(scene, hull), new EntitySettings()); + if (sensor) Assert.True(shooter.TryEquip(MiningCut3Tests.Mint(scene, scene.Eye)), "eye"); + Assert.True(shooter.TryEquip(MiningCut3Tests.Mint(scene, gear)), "targeting"); + var minted = MiningCut3Tests.Mint(scene, gun); + Assert.True(shooter.TryEquip(minted, new int2(0, 0)), "gun"); + scene.Zone.Admit(shooter, piloted: false); + shooter.Position = float3.zero; + shooter.Direction = float2(0, 1); + shooter.Aim = float3(0, 0, 1); + + shooter.Update(.05f); // the fitted sensor becomes active on its first tick; zone time does not advance + shooter.Position = float3.zero; + shooter.Velocity = float2.zero; + var item = shooter.Equipment.Single(x => x.EquippableItem == minted); + var rig = new Rig + { + Scene = scene, Shooter = shooter, GunItem = item, Gun = (Weapon) item.Behaviors.Single(b => b is Weapon), + Rock = new ChunkId(scene.Belts[0], 0) + }; + scene.Zone.ShotResolved.Subscribe(outcome => rig.Resolved.Add(outcome)); + if (target && sensor) Assert.True(shooter.SetTarget(rig.Rock), "target"); + return rig; + } + + private static float Phi(double z) => (float) (.5 * (1 + erf((float) (z / Math.Sqrt(2))))); + + // The rock's radius in cells is the one the zone reports, sigma the targeting system's (floored at half a cell). + private static float Disc(Rig rig) + { + var sigma = max(1f / FireControl.Precision(rig.Shooter), .5f); + var radius = rig.Zone.ChunkCells(rig.Rock); + return Phi(radius / sigma) - Phi(-radius / sigma); + } + + // PSensor as the rule states it, for a gear that wants only the detection threshold plus a thousandth of the rest. + private static float Sensor(Rig rig, float info) + { + var th = rig.Scene.Items.GameplaySettings.TargetDetectionInfoThreshold; + return saturate(unlerp(th, th + (1f - th) / 1000f, info)); + } + + // ==== one fire path ==== + + // Accuracy, PSensor, PSpread, Sigma and PMount price a rock exactly as a ship at equal range, precision and info: + // a two-cell ship (span 2, centre-aimed) and a rock one cell in radius (span 2) are the same disc to the gun, and + // an unauthored gun follows anything, so the mount is 1 for both. + [Fact] + public void ARockShotUsesTheSameFactors() + { + var rig = Build(spread: 4f, precision: 1f, crossSection: 1f); + var s = rig.Scene; + Assert.Equal(1f, rig.Zone.ChunkCells(rig.Rock), 4); + var info = rig.Shooter.ChunkInfo(rig.Rock); + Assert.True(info > .11f && info < 1f, $"the rock's info {info} should be visible and unsaturated"); + + var hull = new HullData { Name = "Pair", HullType = HullType.Ship, Shape = new Shape(2, 2), Durability = 10, Mass = 1000 }; + foreach (var cell in hull.Shape.AllCoordinates) hull.Shape[cell] = true; + s.Cache.Upsert(hull); + var at = rig.Zone.ChunkPose(rig.Rock.Field, rig.Rock.Index).xy; + var ship = new Ship(s.Items, rig.Zone, MiningCut3Tests.Mint(s, hull), new EntitySettings()); + rig.Zone.Admit(ship, piloted: false); + ship.Position = float3(at.x, 0, at.y); + ship.Direction = float2(0, 1); + rig.Shooter.EntityInfoGathered[ship] = info; + rig.Shooter.VisibleEntities.Add(ship); + + var rock = FireControl.HitProbability(rig.Gun, rig.Shooter, rig.Rock); + var vessel = FireControl.HitProbability(rig.Gun, rig.Shooter, ship); + Assert.InRange(rock, .05f, .99f); + Assert.Equal(vessel, rock, 4); + } + + [Fact] + public void AnUnseenRockCannotBeFiredOn() + { + var blind = Build(sensor: false); + Assert.False(FireControl.Designated(blind.Gun, blind.Shooter, blind.Rock, out var range)); + Assert.Equal(Distance, range, 2); + Assert.Equal(0f, FireControl.HitProbability(blind.Gun, blind.Shooter, blind.Rock)); + Assert.False(FireControl.Solve(blind.Gun, blind.GunItem, blind.Shooter, blind.Rock).Designated); + + var sighted = Build(); + Assert.True(FireControl.Designated(sighted.Gun, sighted.Shooter, sighted.Rock, out _)); + Assert.True(FireControl.HitProbability(sighted.Gun, sighted.Shooter, sighted.Rock) > 0f); + } + + // Q12 = A: a launcher (a LockWeapon) is never designated on a rock, locked or not. + [Fact] + public void LaunchersCannotFireOnARock() + { + var launcher = Build(launcher: true); + Assert.False(FireControl.Designated(launcher.Gun, launcher.Shooter, launcher.Rock, out _)); + Assert.Equal(0f, FireControl.HitProbability(launcher.Gun, launcher.Shooter, launcher.Rock)); + var plain = Build(); + Assert.True(FireControl.Designated(plain.Gun, plain.Shooter, plain.Rock, out _)); + } + + // POnHull is the closed form Phi(R/sigma) - Phi(-R/sigma): a zero-spread gun with a full sensor reading is priced by the + // disc alone. + [Fact] + public void DiscMassIsTheClosedForm() + { + var rig = Build(spread: 0f, precision: 1f, rockSize: .5f); + var info = rig.Shooter.ChunkInfo(rig.Rock); + var expected = Sensor(rig, info) * Disc(rig); + Assert.InRange(Disc(rig), .5f, .99f); + Assert.Equal(expected, FireControl.HitProbability(rig.Gun, rig.Shooter, rig.Rock), 4); + } + + // Commit's price at fire time is the forecast: for a rock still at the instant it is fired at, one price. + [Fact] + public void TheForecastIsTheCommitPriceForARock() + { + var rig = Build(spread: 4f, precision: 1f, velocity: 50f); + var forecast = FireControl.HitProbability(rig.Gun, rig.Shooter, rig.Rock); + var id = FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter); + Assert.True(rig.Zone.TryGetShot(id, out var shot)); + Assert.Equal(rig.Rock, shot.Chunk); + Assert.Null(shot.Target); + Assert.Equal(forecast, FireControl.CommitProbability(shot, shot.FireTime, out _), 4); + } + + // ==== resolution ==== + + private static void Run(Rig rig, float seconds) + { + for (var elapsed = 0f; elapsed < seconds; elapsed += .01f) rig.Zone.Update(.01f); + } + + // A rock hit has no lane and no shield. Hits wear the rock by the shot's Damage after the falloff, and each + // publishes one absorb on the zone's ChunkCapabilities at the rock's surface facing the shot. + [Fact] + public void DirectHitsWearTheRock() + { + var rig = Build(damage: 3f, rockSize: .5f, precision: 1000f, effective: 50f); + var absorbs = new List(); + 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)); + } +}