diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 0e721d733..0c3c35368 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -278,9 +278,9 @@ public class EntityInstance : MonoBehaviour } // Cut 3 (docs/fire-control-cut.md): DamageSchematic and both HullCollider subscriptions (Splash, Hit) - // are deleted -- FireControl decides hits now and Entity.ApplyHit/DamageSchematic (ServerShared) apply - // them, the single owner in place of what used to exist once per Unity effect. HullColliders no longer - // publish Hit/Splash at all (R8). + // are deleted -- FireControl decides hits now and applies them through Entity.Absorb/DamageHull + // (ServerShared, Cut 12.3; DamageSchematic and ApplyHit are gone), the single owner in place of what + // used to exist once per Unity effect. HullColliders no longer publish Hit/Splash at all (R8). LookAtPoint = new GameObject($"{entity.Name} Look Point").transform; diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index b30177b98..5b95dd6df 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -329,52 +329,55 @@ public abstract class Entity ? TargetItem.Value : null; - // Cut 3 (docs/fire-control-cut.md): the damage rule moved verbatim from EntityInstance.cs's Unity - // HullCollider subscription (DamageSchematic) -- same arithmetic, same order, same thresholds. The only - // thing that changed is the owner: this entity's own schematic, armor and hull, not a Unity component - // reacting to a physics collision. - public void DamageSchematic(float damage, Shape hitShape) + // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- moved verbatim from DamageSchematic's own + // per-cell body (Cut 3), now called once per lane cell (FireControl.Apply) instead of once per hit-shape + // cell with an even split. Armor absorbs up to its own value, then the occupying item once the remainder + // exceeds .1f, and whatever is left over is returned to the caller for the hull's own share (or the next + // cell down the lane, for a lane that keeps carrying a remainder is not this function's concern -- Apply's + // own loop does that). ArmorDamage/ItemDamage fire only for incoming > 0 -- a cell a spent lane still walks + // past (rem already 0) takes no event and no recorded hit. + public float Absorb(int2 cell, float damage) { - var hullData = ItemManager.GetData(Hull) as HullData; - foreach (var v in hitShape.Coordinates) - hitShape[v] = hitShape[v] && hullData.Shape[v]; + var d = damage; - float hullDamage = 0; - var damagePerCell = damage / hitShape.Coordinates.Length; - foreach (var v in hitShape.Coordinates) + if (d > 0f) { - var d = damagePerCell; - - // Subtract surface damage from armor, passing on the remainder to the item and then to the hull - var prev = Armor[v.x, v.y]; - Armor[v.x, v.y] = max(prev - d, 0); - ArmorDamage.OnNext((v, d)); - d = max(d - prev, 0); - - if (d > 0.1f) - { - var item = GearOccupancy[v.x, v.y]; - if (item != null) - { - prev = item.EquippableItem.Durability; - item.EquippableItem.Durability = max(prev - d, 0); - ItemDamage.OnNext((item, d)); - d = max(d - prev, 0); - } - } - - hullDamage += d; + var prevArmor = Armor[cell.x, cell.y]; + Armor[cell.x, cell.y] = max(prevArmor - d, 0); + ArmorDamage.OnNext((cell, d)); + d = max(d - prevArmor, 0); } - if (hullDamage > .1f) + if (d > 0.1f) { - Hull.Durability -= hullDamage; - HullDamage.OnNext(hullDamage); + var item = GearOccupancy[cell.x, cell.y]; + if (item != null) + { + var prevItem = item.EquippableItem.Durability; + item.EquippableItem.Durability = max(prevItem - d, 0); + ItemDamage.OnNext((item, d)); + d = max(d - prevItem, 0); + } + } + + return d; + } + + // Cut 12.3: moved verbatim from DamageSchematic's own tail (Cut 3) -- the >.1f threshold and the one + // HullDamage event are unchanged. FireControl.Apply calls this once per resolved hit, with the summed + // remainder every lane's own walk left over (Q12-3 = A: a lane's remainder goes to the hull wherever the + // lane ends -- penetration exhausted, a gap, or the far side). + public void DamageHull(float damage) + { + if (damage > .1f) + { + Hull.Durability -= damage; + HullDamage.OnNext(damage); } } // Cut 12.1 (docs/fire-control-cut.md): the one schematic-frame owner. Maps a world-planar vector into this - // entity's own schematic frame -- x = starboard, y = bow -- so ApplyHit's penetration march and + // entity's own schematic frame -- x = starboard, y = bow -- so FireControl.Apply's lane walk and // FireControl.Splash's directional half both read the same transform instead of each carrying their own // copy of forward/right. public float2 ToSchematic(float2 worldPlanar) @@ -384,40 +387,6 @@ public abstract class Entity return float2(dot(worldPlanar, right), dot(worldPlanar, forward)); } - // Cut 3: the shape construction moved from EntityInstance.cs's Unity HullCollider subscription -- the hit - // cell is the rolled Cell FireControl already chose, not a UV texture coordinate. - // Cut 12.2 (docs/fire-control-cut.md): `bearing` is already in this entity's own schematic frame -- the - // committed ShotOutcome.Bearing FireControl.Commit computed once, at the commit tick -- so the ToSchematic - // call this used to make internally (12.1) is gone; ApplyHit no longer touches a world direction or this - // entity's own Direction at all. - public void ApplyHit(Entity source, int2 cell, float spread, float penetration, float damage, float2 bearing) - { - IncomingHit.OnNext(source); - - var hullData = ItemManager.GetData(Hull) as HullData; - var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height); - hitShape[cell] = true; - - for (var i = 0; i < (int) floor(spread + .5f); i++) - hitShape = hitShape.Expand(); - - if (penetration > .5f) - { - var penetrationVector = normalize(bearing); - - var penetrationPoint = (float2) cell + float2(.5f); - var penetrationDistance = 0f; - while (penetrationDistance < penetration && hullData.Shape[int2(penetrationPoint)]) - { - penetrationDistance += .5f; - hitShape[int2(penetrationPoint)] = true; - penetrationPoint += penetrationVector * .5f; - } - } - - DamageSchematic(damage, hitShape); - } - // Another entity's stance toward THIS one, as far as this entity can perceive it: unknown (null) // until this entity detects them, mirroring the existing detection model (VisibleEntities, driven // by EntityInfoGathered crossing TargetDetectionInfoThreshold). Detected but not yet hostility-rated diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 3f121017f..5d88219d3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -192,11 +192,15 @@ public static class FireControl // Commit's own path (CommitProbability -> Silhouette, uncalled from here) still needs sil.Intervals alive // for the lateral draw that follows it, so it keeps its own owned array; only the forecast pools. // S3 fix batch (Hands, 2026-09-25) correction: "the forecast path no longer allocates on a gated-in call" - // overclaimed -- a gated-in HitProbability/Inspect call still allocates roughly 120/280 bytes respectively - // (measured, Release), from Accuracy/Resolution's own GetBehavior walking Entity.Equipment and each - // item's Behaviors (both ReactiveCollection, whose enumerator is heap-allocated per foreach), not from - // anything Silhouette or this pooling touches. That cost predates Cut 12.2 and belongs to Entity's own - // collection choice; it is out of this cut's scope. Cut 10's own guard is what actually matters + // overclaimed -- a gated-in HitProbability call still allocates roughly 120 bytes (measured, Release; ~280 + // for Inspect, which reads one more gated stat). 12.3 fix batch correction: that cost is exactly three + // GetBehavior calls (Accuracy, Resolution via PSensor, Precision), at ~40 B each -- each + // one walks Entity.Equipment, a ReactiveCollection whose enumerator is heap-allocated per foreach. It is + // NOT `Behaviors` (the earlier wording here was wrong): EquippedItem.Behaviors is a plain array, allocates + // nothing to enumerate. With an item aimed at, ResolveAimPoint -> CellsOf also allocates (a List, then + // ToArray) -- about 944 B per HitProbability, on top of the three GetBehavior calls. None of this is + // Silhouette's or this pooling's cost; it predates Cut 12.2 and belongs to Entity's own collection choice + // and to CellsOf, out of this cut's scope. Cut 10's own guard is what actually matters // operationally: HitProbability and CommitProbability both bail before Forecast is ever called for the // common gated-out case (GatedOutHitProbabilityAllocatesNothing pins that at exactly 0). private static (Silhouette Silhouette, float PSpread) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range) @@ -583,6 +587,15 @@ public static class FireControl // R4: the commit is authoritative and immutable from here on -- this only performs what Commit already // decided. The one owner of the shield-absorbs-or-hull-takes-it branch (it used to exist seven times, // once per Unity effect). + // 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, one cell apart along the committed bearing's own lateral axis, sharing the shot's + // damage evenly over whichever lanes actually meet metal -- the centre lane, at the committed Lateral, + // always does (Commit already proved it, R3). Each lane is Lane's own contiguous cell run from its own + // facing cell, clipped to the penetration depth (a cell is reached when entry - impact.entry < penetration; + // the impact cell is always reached, replacing the deleted `> .5f` threshold at zero cost). Whatever a + // lane does not spend -- penetration exhausted, a gap, or the far side -- goes to the hull where the lane + // ends (Q12-3 = A), summed across every lane into one DamageHull call. private static void Apply(PendingShot shot) { if (!shot.Outcome.Hit) return; @@ -597,10 +610,50 @@ public static class FireControl return; } + var target = shot.Target; + // Cut 12.3: moved here from Entity.ApplyHit's own first line -- the shield branches above still return + // before this runs, unchanged from 12.2. + target.IncomingHit.OnNext(shot.Source); + // Cut 12.2 (R4): the committed geometry, frozen. Apply reads no position or facing of its own -- // shot.Outcome.Bearing is already the schematic-frame bearing Commit computed at the commit tick, so // turning the target after commit changes nothing here (TurningAfterCommitChangesNothing). - shot.Target.ApplyHit(shot.Source, shot.Outcome.Cell, shot.DamageSpread, shot.Penetration, shot.Damage, shot.Outcome.Bearing); + var hullData = target.ItemManager.GetData(target.Hull) as HullData; + var bearing = shot.Outcome.Bearing; + var n = (int) floor(shot.DamageSpread + .5f); // today's rounding, Entity.cs:389 before this cut + var laneCount = 2 * n + 1; + + var buffers = new LaneCell[laneCount][]; + var walked = new int[laneCount]; + var metalLanes = 0; + for (var li = 0; li < laneCount; li++) + { + buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + walked[li] = Lane(hullData, bearing, shot.Outcome.Lateral + (li - n), buffers[li]); + if (walked[li] > 0) metalLanes++; + } + + // The centre lane (li == n) always meets metal: it walks from the committed Lateral, and Commit's own + // guard already proved Lane finds an occupied cell there (R3) -- metalLanes is never 0. + var damagePerLane = shot.Damage / metalLanes; + var hullDamage = 0f; + for (var li = 0; li < laneCount; li++) + { + if (walked[li] > 0) + { + var rem = damagePerLane; + var impactEntry = buffers[li][0].Entry; + for (var i = 0; i < walked[li]; i++) + { + if (i > 0 && buffers[li][i].Entry - impactEntry >= shot.Penetration) break; + rem = target.Absorb(buffers[li][i].Cell, rem); + } + hullDamage += rem; + } + ArrayPool.Shared.Return(buffers[li]); + } + + target.DamageHull(hullDamage); } private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now) @@ -626,12 +679,16 @@ public static class FireControl // shot. Nothing here draws: a mine or an airburst round always damages everything it catches, the same // as the Physics.OverlapSphere queries this replaces always did. Applies the shield-absorb-or-hull-takes- // it branch (F5, docs/stats-and-power-cut.md) per target, then -- for a target the shield didn't fully - // absorb -- DamageSchematic over the directional half of that target's own hull that faces the blast, the - // rule moved verbatim from the Splash subscription EntityInstance.cs carried before Cut 3 deleted it - // (git show b7743789^:Assets/Scripts/Gameplay/EntityInstance.cs), made planar (R7): the blast-to-target - // direction is measured in the zone's (x,z) plane and rotated into each target's own facing by its - // Direction, the same rotation Entity.ApplyHit's penetration march uses, in place of a Unity - // InverseTransformDirection. + // absorb -- an even split (still today's rule; 12.3 only retired the direct-hit lane's even split, not + // Splash's) over the directional half of that target's own hull that faces the blast, the rule moved + // verbatim from the Splash subscription EntityInstance.cs carried before Cut 3 deleted it (git show + // b7743789^:Assets/Scripts/Gameplay/EntityInstance.cs), made planar (R7): the blast-to-target direction is + // measured in the zone's (x,z) plane and rotated into each target's own facing by its Direction, the same + // rotation Entity's own schematic frame (ToSchematic) uses. + // Cut 12.3: Entity.DamageSchematic is deleted with the direct-hit march it shared a file with -- Splash + // now calls Entity.Absorb per footprint cell directly and sums the leftover into one DamageHull call, + // arithmetically identical to DamageSchematic's own body (same per-cell split, same order, same + // thresholds). Untouched otherwise; deleted in 12.4 along with the rest of the ray model. public static void Splash(Zone zone, float3 position, float radius, float damage, DamageType damageType) { foreach (var target in zone.Entities) @@ -659,7 +716,12 @@ public static class FireControl if (dot(normalize((float2) v - hullData.Shape.CenterOfMass), localDirection) < 0) hitShape[v] = true; - target.DamageSchematic(damage, hitShape); + var cells = hitShape.Coordinates; + var damagePerCell = damage / cells.Length; + var hullDamage = 0f; + foreach (var v in cells) + hullDamage += target.Absorb(v, damagePerCell); + target.DamageHull(hullDamage); } } @@ -868,7 +930,7 @@ public static class FireControl } // The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source - // of truth for where an equipped item's footprint lands, the same table Entity.DamageSchematic reads. + // of truth for where an equipped item's footprint lands, the same table Entity.Absorb reads. private static int2[] CellsOf(Entity target, EquippedItem item) { var hullData = target.ItemManager.GetData(target.Hull) as HullData;