From 567579136a76ec3012ccb7758ff504904adf1f7a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 25 Sep 2026 16:16:53 +0200 Subject: [PATCH] Cut 12.3 fix batch: proportional absorption for multi-cell items shared between lanes Operator ruling (2026-09-25): "proportional absorption; go". A multi-cell item struck by several lanes of one shot now absorbs their combined incoming damage (up to its own remaining durability) and each lane's leftover is in proportion to what it brought, replacing the old behaviour where whichever lane Apply happened to visit first drained the item and the next lane inherited whatever was left. Entity.Absorb is split into ArmorAbsorb (per-cell, never shared) and ItemAbsorb (the single-lane path), so FireControl.Apply can call armour immediately and defer item resolution only for a cell whose item is reachable by more than one lane this shot. FireControl.Apply gains a geometric survey (which items are reachable by >1 lane, decided before any damage is applied) and takes the untouched sequential walk (ApplySequential) whenever nothing is shared, or a new coordinated walk (ApplyWithSharedItems) that deposits each lane's post-armour remainder into a shared item's pool and resolves it once every contributing lane has arrived, splitting the leftover proportionally (sum-then-split, order-independent by construction). A deterministic tie-break (smallest first-contact Entry) breaks the rare "opposite order" deadlock two non-convex shared items can create between two lanes. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 54 ++++-- Assets/Scripts/ServerShared/FireControl.cs | 216 ++++++++++++++++++++- 2 files changed, 251 insertions(+), 19 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 5b95dd6df..86ec06d90 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -329,14 +329,12 @@ public abstract class Entity ? TargetItem.Value : null; - // 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) + // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of + // what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so + // Cut 12.3's fix batch (proportional multi-lane item absorption, below) can call the armour phase once per + // cell -- always local to one lane, since armour never spans cells -- while deferring the item phase for a + // cell whose item is shared with another lane in the same shot. ArmorDamage fires only for incoming > 0. + public float ArmorAbsorb(int2 cell, float damage) { var d = damage; @@ -348,21 +346,43 @@ public abstract class Entity d = max(d - prevArmor, 0); } - if (d > 0.1f) + 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) + { + var d = damage; + + if (d > 0.1f && item != null) { - 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); - } + 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 (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. + public float Absorb(int2 cell, float damage) + { + var d = ArmorAbsorb(cell, damage); + return ItemAbsorb(GearOccupancy[cell.x, cell.y], 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 diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 77250d825..98ca726e6 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -663,6 +663,55 @@ public static class FireControl // 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; + + // 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); + + 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++) { @@ -677,10 +726,173 @@ public static class FireControl } hullDamage += rem; } - ArrayPool.Shared.Return(buffers[li]); + } + 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. + // + // 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) + { + 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]; + for (var li = 0; li < laneCount; li++) + { + finished[li] = walked[li] == 0; + if (!finished[li]) + { + rem[li] = damagePerLane; + impactEntry[li] = buffers[li][0].Entry; + } } - target.DamageHull(hullDamage); + 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)>(); + } + + var hullDamage = 0f; + + void FinishLane(int li) + { + finished[li] = true; + hullDamage += rem[li]; + } + + 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 (lane, amount) = contributions[k]; + if (amount > 0f) target.ItemDamage.OnNext((item, amount)); + rem[lane] = amount - amount * fraction; + } + resolved.Add(item); + for (var k = 0; k < contributions.Count; k++) + { + var lane = contributions[k].lane; + blocked[lane] = false; + pointer[lane]++; + if (pointer[lane] >= walked[lane]) FinishLane(lane); + } + } + + 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) + { + FinishLane(li); + moved = true; + break; + } + var cell = buffers[li][i].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; + } + } + return moved; + } + + bool AllFinished() + { + for (var li = 0; li < laneCount; li++) + if (!finished[li]) return false; + return true; + } + + while (!AllFinished()) + { + var progressed = false; + for (var li = 0; li < laneCount; li++) + if (!finished[li] && !blocked[li] && AdvanceLane(li)) progressed = true; + + 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); + } + } + + return hullDamage; } // Self's fix (2026-09-25): the ONE public membership query behind both Commit's placement and Apply's