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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-25 17:01:05 +02:00
commit e9339a5673
2 changed files with 161 additions and 187 deletions

View file

@ -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<float> 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<float> 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

View file

@ -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<LaneCell>.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<EquippedItem, List<int>> lanesOf = null;
Dictionary<EquippedItem, float> 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<EquippedItem, List<int>>();
itemKey ??= new Dictionary<EquippedItem, float>();
if (!lanesOf.TryGetValue(item, out var list)) lanesOf[item] = list = new List<int>();
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<EquippedItem> sharedItems = null;
if (lanesOf != null)
foreach (var kv in lanesOf)
if (kv.Value.Count > 1)
(sharedItems ??= new List<EquippedItem>()).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<LaneCell>.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<EquippedItem, List<int>> lanesOf, List<EquippedItem> sharedItems,
Dictionary<EquippedItem, float> 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<EquippedItem>(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<int>.Shared.Rent(laneCount);
var rem = ArrayPool<float>.Shared.Rent(laneCount);
var finished = ArrayPool<bool>.Shared.Rent(laneCount);
var blocked = ArrayPool<bool>.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<EquippedItem, int>();
var pool = new Dictionary<EquippedItem, List<(int lane, float amount)>>();
var resolved = new HashSet<EquippedItem>();
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<EquippedItem, List<(int lane, float amount)>> 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<float>.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<float>(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<float>.Shared.Return(amounts);
}
}
void Deposit(int li, EquippedItem item)
{
pools ??= new Dictionary<EquippedItem, List<(int, float)>>();
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<EquippedItem>(pools.Keys)) Resolve(item);
}
}
return hullDamage;
}
finally
{
ArrayPool<int>.Shared.Return(pointer);
ArrayPool<float>.Shared.Return(rem);
ArrayPool<bool>.Shared.Return(finished);
ArrayPool<bool>.Shared.Return(blocked);
}
}
// Self's fix (2026-09-25): the ONE public membership query behind both Commit's placement and Apply's