From e9339a567330a4b3bd6cfdef3953dcccf5e56468 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 17:01:05 +0200 Subject: [PATCH] Cut 12.3 fix batch: one apply path for every item (operator ruling) Replace the survey-then-branch (ApplySequential vs. ApplyWithSharedItems) with a single pooled walk. Lanes are clipped to penetration once, right after Lanes returns; every item -- single-cell or multi-cell -- pools whatever lanes reach it and resolves the moment no other unfinished lane can still reach it down its own remaining clipped walk, with no separate reachability survey. A cyclic dependency between two non-convex items resolves every currently open pool at once, order-independent by construction. Entity.ItemAbsorb becomes the one absorption function, gated on the pooled total; Absorb (Splash's only remaining caller) now funnels through it with a one-contribution pool. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 54 ++-- Assets/Scripts/ServerShared/FireControl.cs | 288 +++++++++------------ 2 files changed, 158 insertions(+), 184 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 86ec06d90..9b49d9666 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -349,38 +349,52 @@ public abstract class Entity return d; } - // Cut 12.3 fix batch (proportional multi-cell absorption): the item phase, split out of Absorb's own - // second half so a caller coordinating several lanes into one shared item (FireControl.Apply) can resolve - // that item's absorption once, from the lanes' combined incoming damage, instead of once per lane against - // whatever durability the previous lane left behind. Takes the item directly (not a cell) because the - // caller may already know it is resolving a specific item for several different cells/lanes at once. - // ItemDamage fires only once the post-armour remainder exceeds .1f, same threshold as before the split. - public float ItemAbsorb(EquippedItem item, float damage) + // Cut 12.3 fix batch (one apply path, operator ruling 2026-09-25 "do not expect items taking up multiple + // cells to be an exception, this should be one code path"): the ONE function that decides an item's own + // absorption, whatever the item's shape or however many lanes of one shot reach it. `incoming` holds every + // contributing lane's own post-armour remainder for this resolve, one lane's worth included -- a + // single-lane item is this rule's own degenerate case, not a separate function or a separate threshold. + // The existing .1f threshold is decided once, on the POOLED total, never per contribution (a pooled path + // that gated each contribution separately would let two shares under .1f each slip past an item that a + // single .08f solo hit would already have stopped at). Absorbed durability is split back across `incoming` + // in place, pro-rata to what each lane brought (a guarded divide: total is already >.1f whenever this + // divides, but 0 is still handled explicitly rather than assumed). ItemDamage fires once per contributing + // lane, reporting its own INCOMING share -- not the post-clamp amount, the same convention ArmorAbsorb + // already uses -- and only when that lane's own share is itself > 0, so a zero-deposit contributor (a + // lane whose armour ate its whole share before reaching this item) never fires a phantom event. + public void ItemAbsorb(EquippedItem item, Span incoming) { - var d = damage; + if (item == null) return; - if (d > 0.1f && item != null) + var total = 0f; + for (var i = 0; i < incoming.Length; i++) total += incoming[i]; + if (total <= 0.1f) return; + + var before = item.EquippableItem.Durability; + var absorbed = min(total, before); + item.EquippableItem.Durability = max(before - absorbed, 0f); + var fraction = total > 0f ? absorbed / total : 0f; + for (var i = 0; i < incoming.Length; i++) { - var prevItem = item.EquippableItem.Durability; - item.EquippableItem.Durability = max(prevItem - d, 0); - ItemDamage.OnNext((item, d)); - d = max(d - prevItem, 0); + if (incoming[i] > 0f) ItemDamage.OnNext((item, incoming[i])); + incoming[i] -= incoming[i] * fraction; } - - return d; } // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- armour up to its own value, then the // occupying item, then whatever is left over is returned to the caller for the hull's own share (or the // next cell down the lane; a lane that keeps carrying a remainder is not this function's concern -- - // Apply's own loop does that). This is the single-lane path: FireControl.Apply calls ArmorAbsorb/ItemAbsorb - // directly instead, only when a cell's item is shared between lanes and needs their combined incoming - // damage resolved together; every other caller (an unshared cell, Splash) still goes through Absorb - // unchanged, so this function's own behaviour is untouched by the fix batch. + // FireControl.Apply's own pooled walk does that). This is the single-lane path: it feeds ItemAbsorb a + // pool of exactly one contribution, the same degenerate case Apply's own multi-lane pools reduce to when + // only one lane ever reaches an item. Splash is Absorb's only remaining caller (12.4 deletes Splash). public float Absorb(int2 cell, float damage) { var d = ArmorAbsorb(cell, damage); - return ItemAbsorb(GearOccupancy[cell.x, cell.y], d); + var item = GearOccupancy[cell.x, cell.y]; + if (item == null) return d; + Span incoming = stackalloc float[1] { d }; + ItemAbsorb(item, incoming); + return incoming[0]; } // Cut 12.3: moved verbatim from DamageSchematic's own tail (Cut 3) -- the >.1f threshold and the one diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 98ca726e6..b81a7a2d3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -622,6 +622,12 @@ public static class FireControl // Commit's placement (Lane, the n = 0 case) and Apply's damage split (Lanes, every case) read one function, // and resolves the resulting rare cross-lane collision by ownership rather than by a competing formula -- // see `Lanes`. + // Cut 12.3 fix batch (2026-09-25, operator ruling "one code path" -- docs/fire-control-cut.md, Cut 12.3 + // status): the earlier fix batch's own survey-then-branch (ApplySequential vs. ApplyWithSharedItems) was a + // split authority -- a second reachability derivation for "is this item shared" alongside Lanes' own + // membership rule, the identical shape of failure as 12.2's empty lane and 12.3's own double strike. There + // is now one walk, `ApplyPooled`, in which every item pools the deposits of whatever lanes reach it, a + // pool of one lane included -- a single-cell item is this rule's own degenerate case, not a fork. private static void Apply(PendingShot shot) { if (!shot.Outcome.Hit) return; @@ -656,6 +662,21 @@ public static class FireControl for (var li = 0; li < laneCount; li++) buffers[li] = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); var walked = Lanes(hullData, bearing, shot.Outcome.Lateral, n, laneSpacing, buffers); + + // Cut 12.3 fix batch ("one code path"): the ONLY penetration derivation in this whole walk -- clip each + // lane's own cell run to its penetration depth right here, once, immediately after Lanes returns. + // Everything downstream reads `walked` as the lane's entire reach; nothing below ever re-tests a cell's + // entry against penetration again, and there is no separate survey of which cells or items a lane can + // reach -- ApplyPooled's own walk discovers that live, from `walked` alone. + for (var li = 0; li < laneCount; li++) + { + if (walked[li] == 0) continue; + var impactEntry = buffers[li][0].Entry; + var clipped = 1; + while (clipped < walked[li] && buffers[li][clipped].Entry - impactEntry < shot.Penetration) clipped++; + walked[li] = clipped; + } + var metalLanes = 0; for (var li = 0; li < laneCount; li++) if (walked[li] > 0) metalLanes++; @@ -664,133 +685,57 @@ public static class FireControl // guard already proved Lane finds an occupied cell there (R3) -- metalLanes is never 0. var damagePerLane = shot.Damage / metalLanes; - // Cut 12.3 fix batch (2026-09-25, operator: "proportional absorption; go"): survey which items, if - // any, are within reach of more than one lane's own (penetration-clipped) walk -- cells are already - // disjoint between lanes (Lanes' own ownership rule), so only a multi-cell item can ever be reached by - // two different lanes at all. This is a purely geometric question (GearOccupancy and the penetration - // cutoff, neither reads remaining damage), so it can be decided up front, before any Absorb call, and - // the overwhelmingly common case -- no item shared this shot -- takes the untouched sequential walk - // below with no extra allocation. - Dictionary> lanesOf = null; - Dictionary itemKey = null; - for (var li = 0; li < laneCount; li++) - { - if (walked[li] == 0) continue; - var impactEntry0 = buffers[li][0].Entry; - for (var i = 0; i < walked[li]; i++) - { - if (i > 0 && buffers[li][i].Entry - impactEntry0 >= shot.Penetration) break; - var cell = buffers[li][i].Cell; - var item = target.GearOccupancy[cell.x, cell.y]; - if (item == null) continue; - lanesOf ??= new Dictionary>(); - itemKey ??= new Dictionary(); - if (!lanesOf.TryGetValue(item, out var list)) lanesOf[item] = list = new List(); - if (!list.Contains(li)) list.Add(li); - if (!itemKey.TryGetValue(item, out var key) || buffers[li][i].Entry < key) - itemKey[item] = buffers[li][i].Entry; - } - } - List sharedItems = null; - if (lanesOf != null) - foreach (var kv in lanesOf) - if (kv.Value.Count > 1) - (sharedItems ??= new List()).Add(kv.Key); - - var hullDamage = sharedItems == null - ? ApplySequential(target, shot, buffers, walked, laneCount, damagePerLane) - : ApplyWithSharedItems(target, shot, buffers, walked, laneCount, damagePerLane, lanesOf, sharedItems, itemKey); + var hullDamage = ApplyPooled(target, buffers, walked, laneCount, damagePerLane); for (var li = 0; li < laneCount; li++) ArrayPool.Shared.Return(buffers[li]); target.DamageHull(hullDamage); } - // Cut 12.3's original walk, untouched: every lane in isolation, near to far, clipped to its own - // penetration depth, remainder to the hull. Taken whenever no item this shot is shared between lanes - // (the fix batch's own "exact when nothing is shared" contract) -- byte-for-byte the same calls, in the - // same order, as before the fix batch. - private static float ApplySequential(Entity target, PendingShot shot, LaneCell[][] buffers, int[] walked, int laneCount, float damagePerLane) - { - 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; - } - } - return hullDamage; - } - - // Cut 12.3 fix batch (2026-09-25, operator ruling "proportional absorption; go" -- docs/fire-control-cut.md, - // Cut 12.3 status): taken only when >=1 item is reachable by more than one lane of this shot. Walks every - // lane through its own private cells exactly as ApplySequential does (armour is never shared, so it is - // never coordinated -- Entity.ArmorAbsorb runs the moment a lane reaches a cell, same as before). A lane - // that reaches a SHARED item's cell instead deposits its own post-armour remainder into that item's pool - // and stalls; once every lane that can ever reach the item has deposited, the item absorbs the pool's SUM - // (up to its own remaining durability) and each depositing lane resumes with a leftover in proportion to - // what it brought -- a sum-then-split, so the result cannot depend on which lane happened to arrive first - // (the defect this fix batch replaces: sequential Absorb let the first lane drain the item and hand the - // second lane's own damage a leftover carved from an already-emptied item). One ItemDamage event fires per - // depositing lane, its own INCOMING contribution -- the same "incoming, not the post-clamp amount" - // convention Entity.ItemAbsorb and Entity.ArmorAbsorb already use for a single lane (an overkill hit - // against a nearly-dead item still reports the whole swing, not what little durability was left to take) -- - // matching the one-event-per-Absorb-call cardinality the unshared path already has. A lane's item - // durability floor and armour-first ordering are otherwise unchanged: this function only ever changes WHEN - // an item's absorption is decided and IN WHAT PROPORTION the leftover is split, never the - // armour-then-item-then-hull order along a single lane's own path. + // Cut 12.3 fix batch (2026-09-25, operator rulings "proportional absorption; go" and "one code path" -- + // docs/fire-control-cut.md, Cut 12.3 status): every lane walks its own already-clipped cells near to far. + // Armour is never shared between lanes (Lanes' own disjointness rule), so Entity.ArmorAbsorb runs the + // instant a lane reaches a cell, exactly as before. A lane that reaches an ITEM cell instead deposits its + // own post-armour remainder into that item's pool and blocks there -- a pool of one lane included, so a + // single-cell item is this rule's own degenerate case, not a separate fast path. A pool resolves the + // moment no other unfinished lane can still reach that item down its own remaining (already-clipped) walk + // -- for an item only one lane can ever touch, that is immediately, the same instant it is deposited. Once + // resolved the item's pool is gone, so a non-convex item a lane crosses a second time (or a second lane + // arriving after a first pool already resolved) opens a brand new pool under the identical rule -- there is + // no separate "solo absorb after a forced resolve" behaviour anywhere in this function. // - // The opposite-order case: two lanes can cross two different shared (multi-cell, non-convex) items in - // reversed relative order -- lane A reaches item1 before item2, lane B reaches item2 before item1. Neither - // item can then be resolved strictly in each lane's own causal order (item1 needs lane B's arrival, which - // needs item2 resolved first; item2 needs lane A's arrival, which needs item1 resolved first) -- every lane - // still in play is stalled on some item, a genuine deadlock, not a scheduling accident. Broken - // deterministically and order-independently by resolving the stalled, not-yet-resolved item with the - // smallest key -- the minimum Entry (along-bearing depth) at which ANY lane can reach it, fixed purely by - // hull geometry and computed once up front, never by which lane the loop below happened to visit first -- - // using only the deposits collected so far. A lane that has not yet arrived at that item resumes normally; - // when its own walk later reaches the same (now-resolved) item, it finds `resolved` already contains it - // and takes the ordinary single-lane ItemAbsorb path against whatever durability the forced resolution - // left, the same path an unshared item always takes. This one deterministic tie-break is the only place a - // lane's own local ordering can end up inverted, and only for the geometrically exotic shape that makes - // strict per-lane causal order unsatisfiable for every lane at once. - private static float ApplyWithSharedItems(Entity target, PendingShot shot, LaneCell[][] buffers, int[] walked, - int laneCount, float damagePerLane, Dictionary> lanesOf, List sharedItems, - Dictionary itemKey) + // Cycle rule (Self's default, recorded as such in the map): two lanes can cross two different shared + // (multi-cell, non-convex) items in reversed relative order -- lane A reaches item1 before item2, lane B + // reaches item2 before item1 -- so neither pool can ever become ready in each lane's own causal order and + // every remaining lane ends up blocked. When that happens (no lane can advance and no pool is + // independently ready), every CURRENTLY OPEN pool resolves at once, together, from whatever deposits it + // already holds. Each blocked lane sits in exactly one open pool, so this batch always clears every blocked + // lane in one pass, needs no ordering key, and is mirror-symmetric by construction (nothing about which + // lane is "left" or "right" ever enters the tie-break). If somehow no pool is open when every lane is + // blocked, that is a real defect -- rem would otherwise be silently dropped -- so this throws instead of + // breaking out of the loop. + private static float ApplyPooled(Entity target, LaneCell[][] buffers, int[] walked, int laneCount, float damagePerLane) { - var sharedSet = new HashSet(sharedItems); - var pointer = new int[laneCount]; - var rem = new float[laneCount]; - var impactEntry = new float[laneCount]; - var finished = new bool[laneCount]; - var blocked = new bool[laneCount]; + // Pooled bookkeeping (this file's own established convention -- ArrayPool is already how Apply rents + // its own per-lane cell buffers): a shot that never crosses an item is the overwhelmingly common case, + // and must not pay a per-shot heap allocation for lane state it never needs to keep past this call. + var pointer = ArrayPool.Shared.Rent(laneCount); + var rem = ArrayPool.Shared.Rent(laneCount); + var finished = ArrayPool.Shared.Rent(laneCount); + var blocked = ArrayPool.Shared.Rent(laneCount); + try + { for (var li = 0; li < laneCount; li++) { + pointer[li] = 0; + blocked[li] = false; finished[li] = walked[li] == 0; - if (!finished[li]) - { - rem[li] = damagePerLane; - impactEntry[li] = buffers[li][0].Entry; - } - } - - var remainingContributors = new Dictionary(); - var pool = new Dictionary>(); - var resolved = new HashSet(); - foreach (var item in sharedItems) - { - remainingContributors[item] = lanesOf[item].Count; - pool[item] = new List<(int, float)>(); + rem[li] = finished[li] ? 0f : damagePerLane; } + // Lazily allocated: a shot that never crosses an item (the overwhelmingly common case) never allocates + // a pool at all. + Dictionary> pools = null; var hullDamage = 0f; void FinishLane(int li) @@ -799,67 +744,77 @@ public static class FireControl hullDamage += rem[li]; } + // "No other unfinished lane still has that item among its remaining clipped cells" -- read directly off + // each other lane's own remaining walk (from just past wherever it currently sits), never from a + // precomputed reachability table. A lane already IN this item's own pool has already arrived and does + // not need to arrive again. + bool StillReachableByAnotherLane(EquippedItem item, List<(int lane, float amount)> contributors) + { + for (var li2 = 0; li2 < laneCount; li2++) + { + if (finished[li2]) continue; + var alreadyContributed = false; + for (var k = 0; k < contributors.Count; k++) + if (contributors[k].lane == li2) { alreadyContributed = true; break; } + if (alreadyContributed) continue; + + var start = blocked[li2] ? pointer[li2] + 1 : pointer[li2]; + for (var i = start; i < walked[li2]; i++) + if (target.GearOccupancy[buffers[li2][i].Cell.x, buffers[li2][i].Cell.y] == item) return true; + } + return false; + } + void Resolve(EquippedItem item) { - var contributions = pool[item]; - var totalIncoming = 0f; - for (var k = 0; k < contributions.Count; k++) totalIncoming += contributions[k].amount; - var durabilityBefore = item.EquippableItem.Durability; - var absorbed = min(totalIncoming, durabilityBefore); - if (absorbed > 0f) item.EquippableItem.Durability = max(durabilityBefore - absorbed, 0f); - // ItemDamage reports each lane's own INCOMING contribution, not its clamped/actual-absorbed share -- - // the same "incoming, not the post-clamp amount" convention Entity.ItemAbsorb and Entity.ArmorAbsorb - // already use for a single lane (an overkill hit against a nearly-dead item still reports the whole - // swing, not what little durability was left to take). One event per contributing lane, matching - // the one-event-per-Absorb-call cardinality the unshared path already has. - var fraction = totalIncoming > 0f ? absorbed / totalIncoming : 0f; - for (var k = 0; k < contributions.Count; k++) + var contributors = pools[item]; + pools.Remove(item); // a later visit to this same item opens a fresh pool under the same rule + var amounts = ArrayPool.Shared.Rent(contributors.Count); + try { - var (lane, amount) = contributions[k]; - if (amount > 0f) target.ItemDamage.OnNext((item, amount)); - rem[lane] = amount - amount * fraction; + for (var k = 0; k < contributors.Count; k++) amounts[k] = contributors[k].amount; + target.ItemAbsorb(item, new Span(amounts, 0, contributors.Count)); + for (var k = 0; k < contributors.Count; k++) + { + var lane = contributors[k].lane; + rem[lane] = amounts[k]; + blocked[lane] = false; + pointer[lane]++; + if (pointer[lane] >= walked[lane]) FinishLane(lane); + } } - resolved.Add(item); - for (var k = 0; k < contributions.Count; k++) + finally { - var lane = contributions[k].lane; - blocked[lane] = false; - pointer[lane]++; - if (pointer[lane] >= walked[lane]) FinishLane(lane); + ArrayPool.Shared.Return(amounts); } } + void Deposit(int li, EquippedItem item) + { + pools ??= new Dictionary>(); + if (!pools.TryGetValue(item, out var list)) pools[item] = list = new List<(int, float)>(); + list.Add((li, rem[li])); + blocked[li] = true; + if (!StillReachableByAnotherLane(item, list)) Resolve(item); + } + bool AdvanceLane(int li) { var moved = false; while (!finished[li] && !blocked[li]) { - var i = pointer[li]; - if (i > 0 && buffers[li][i].Entry - impactEntry[li] >= shot.Penetration) + if (pointer[li] >= walked[li]) { FinishLane(li); moved = true; break; } - var cell = buffers[li][i].Cell; + var cell = buffers[li][pointer[li]].Cell; rem[li] = target.ArmorAbsorb(cell, rem[li]); moved = true; var item = target.GearOccupancy[cell.x, cell.y]; - if (item != null && sharedSet.Contains(item) && !resolved.Contains(item)) - { - pool[item].Add((li, rem[li])); - remainingContributors[item]--; - blocked[li] = true; - if (remainingContributors[item] == 0) Resolve(item); - break; - } - if (item != null) rem[li] = target.ItemAbsorb(item, rem[li]); - pointer[li]++; - if (pointer[li] >= walked[li]) - { - FinishLane(li); - break; - } + if (item != null) Deposit(li, item); + else pointer[li]++; } return moved; } @@ -879,20 +834,25 @@ public static class FireControl if (!progressed) { - EquippedItem victim = null; - var victimKey = float.PositiveInfinity; - foreach (var item in sharedItems) - { - if (resolved.Contains(item) || pool[item].Count == 0) continue; - var key = itemKey[item]; - if (key < victimKey) { victimKey = key; victim = item; } - } - if (victim == null) break; // unreachable: every unfinished lane is blocked on some pool with >=1 deposit - Resolve(victim); + if (pools == null || pools.Count == 0) + throw new InvalidOperationException( + "FireControl.ApplyPooled: every remaining lane is blocked but no item pool is open -- " + + "a lane's own damage would be lost rather than reaching an item or the hull."); + // Cycle rule: resolve every currently open pool at once, from whatever it already holds -- a + // snapshot of the keys, since Resolve mutates `pools` as it goes. + foreach (var item in new List(pools.Keys)) Resolve(item); } } return hullDamage; + } + finally + { + ArrayPool.Shared.Return(pointer); + ArrayPool.Shared.Return(rem); + ArrayPool.Shared.Return(finished); + ArrayPool.Shared.Return(blocked); + } } // Self's fix (2026-09-25): the ONE public membership query behind both Commit's placement and Apply's