diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 1581e3457..446a720b9 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -1221,7 +1221,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 +1266,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 +1278,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 -- @@ -1607,7 +1608,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 +1680,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 +1710,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 +1754,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 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..86dfbff0c 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. diff --git a/tests/Aetheria.Shared.Tests/MiningCut4aTests.cs b/tests/Aetheria.Shared.Tests/MiningCut4aTests.cs new file mode 100644 index 000000000..8db4e3ad4 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MiningCut4aTests.cs @@ -0,0 +1,253 @@ +/* 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)); + } + + [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); + } +}