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); }