Aetheria/Assets/Scripts/ServerShared/FireControl.cs

2349 lines
138 KiB
C#
Raw Permalink Normal View History

/* 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/. */
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
using System;
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
using System.Buffers;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
using System.Collections.Generic;
using System.Linq;
using CultMath;
using static CultMath.math;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
using Random = CultMath.Random;
// Cut 1 (docs/fire-control-cut.md): geometry -- whether a weapon bears on a point is decided here and
// nowhere else -- no prefab, transform, ArticulationPoint or renderer may influence it (R6, R7). Mount
// direction and arc are both derived per read from catalog data and the item's authored rotation; neither
// is ever stored on the entity.
// Cut 2 adds the reveal rule (R5) and the resolved targeting-system stats an entity fires with, both
// likewise derived on every read and never cached.
public static class FireControl
{
// The planar unit vector a hardpoint's weapon points along, derived solely from the item's authored
// rotation (R6, the same convention reaction thrusters already use) -- never from a live Unity
// transform. This is the item-rotation path Behavior.Direction now uses unconditionally, after the
// HardpointTransforms readback branch is deleted.
public static float3 MountDirection(EquippedItem item)
{
var itemDirection = item.Entity.Direction.Rotate(item.EquippableItem.Rotation);
return normalize(float3(itemDirection.x, 0, itemDirection.y));
}
// Full arc width in degrees: the per-hardpoint override when authored (nonzero), else
// GameplaySettings.FiringArc. Zero on the hardpoint means "use the default," not "no arc."
public static float ArcFor(EquippedItem item)
{
var hardpoint = item.Hardpoint;
var arc = hardpoint?.FiringArc ?? 0f;
return arc > 0 ? arc : item.ItemManager.GameplaySettings.FiringArc;
}
// Planar bearing test (R7: the simulation is 2D, the 3D is set dressing -- target height never enters
// this). An arc of 360 degrees or more passes unconditionally, which is how a turret hardpoint
// (authored FiringArc: 360, Q1) tracks all the way around without a second type.
Fire control Cut 5: close the rules that landed on one path and not its twin Soul's pass over the whole campaign confirmed eleven findings. Seven were code defects of one shape -- a rule implemented on one path and left off the path beside it -- and this is all seven. 5.1 Tracking falls back to GameplaySettings.UnaidedTracking (new, 10) instead of zero, and Commit drops the zero branch that turned a Tracking-less shooter into a hard wall. Unaided fire was meant to be really, really bad; it was 334 shots and no hits against a target drifting at 5 u/s. 5.2 A shot the shield cannot absorb now breaks it. The decision is frozen into ShotOutcome.ShieldBroken at commit and performed in Apply, so a shield that recharges mid-flight cannot retroactively survive. Splash already did this; the discrete roll never did, which made the whole shield-break mechanic unreachable for every weapon in the catalog. 5.3 ConstantWeapon gates on ArcAllowsFire alongside StanceAllowsFire. A side-mounted beam was firing forward. 5.4 The point-blank bearing case moves out of Weapon.ArcAllowsFire and into InArc, which used to normalize the zero vector into NaN. The trigger said fire and the probability said impossible. 5.5 A destroyed item drops out of IsRevealed's ranking, so Precision stops concentrating hits onto a dead subsystem's footprint. 5.6 Zone subscribes Entity.Death and removes the corpse itself. That removal lived only in Unity's loot-drop subscription, so headless nothing ever left the zone, shots landed on corpses and no fight could end. 5.7 PendingShot drops PredictedIntercept and FlightTime, written and read nowhere. 208 tests pass (197 plus eleven new). Known defect, recorded rather than hidden: the committed mutation harness is unreliable. Across two runs it failed to restore FireControl.cs and Weapon.cs, leaving live mutations in the working tree -- including its own 5.1 mutation, which is how a green suite briefly reported two failures. Its verdicts are therefore evidence only where re-run from a verified-clean tree. Two anchors (Zone.cs's ObserveAdd line, Weapon.cs's InArc return) fail to match at all, and 5.3's ConstantWeaponObeysArc survives its own mutation, so that rule is written but not pinned. Cut 5b owns the repair; nothing here is trusted on the harness's word, and the suite result above was taken by hand from a hash-verified tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:56:40 +02:00
// Cut 5, 5.4 (Soul finding 11): a planar bearing shorter than 1e-6 -- point-blank range, target and firer
// co-located -- returns true rather than falling through to normalize's NaN. This used to live as a
// separate special case in Weapon.ArcAllowsFire; one bearing test now owns it, so a target at a weapon's
// own exact position can never fail to bear regardless of caller.
public static bool InArc(EquippedItem weapon, float3 toTarget)
{
var arc = ArcFor(weapon);
if (arc >= 360f) return true;
Fire control Cut 5: close the rules that landed on one path and not its twin Soul's pass over the whole campaign confirmed eleven findings. Seven were code defects of one shape -- a rule implemented on one path and left off the path beside it -- and this is all seven. 5.1 Tracking falls back to GameplaySettings.UnaidedTracking (new, 10) instead of zero, and Commit drops the zero branch that turned a Tracking-less shooter into a hard wall. Unaided fire was meant to be really, really bad; it was 334 shots and no hits against a target drifting at 5 u/s. 5.2 A shot the shield cannot absorb now breaks it. The decision is frozen into ShotOutcome.ShieldBroken at commit and performed in Apply, so a shield that recharges mid-flight cannot retroactively survive. Splash already did this; the discrete roll never did, which made the whole shield-break mechanic unreachable for every weapon in the catalog. 5.3 ConstantWeapon gates on ArcAllowsFire alongside StanceAllowsFire. A side-mounted beam was firing forward. 5.4 The point-blank bearing case moves out of Weapon.ArcAllowsFire and into InArc, which used to normalize the zero vector into NaN. The trigger said fire and the probability said impossible. 5.5 A destroyed item drops out of IsRevealed's ranking, so Precision stops concentrating hits onto a dead subsystem's footprint. 5.6 Zone subscribes Entity.Death and removes the corpse itself. That removal lived only in Unity's loot-drop subscription, so headless nothing ever left the zone, shots landed on corpses and no fight could end. 5.7 PendingShot drops PredictedIntercept and FlightTime, written and read nowhere. 208 tests pass (197 plus eleven new). Known defect, recorded rather than hidden: the committed mutation harness is unreliable. Across two runs it failed to restore FireControl.cs and Weapon.cs, leaving live mutations in the working tree -- including its own 5.1 mutation, which is how a green suite briefly reported two failures. Its verdicts are therefore evidence only where re-run from a verified-clean tree. Two anchors (Zone.cs's ObserveAdd line, Weapon.cs's InArc return) fail to match at all, and 5.3's ConstantWeaponObeysArc survives its own mutation, so that rule is written but not pinned. Cut 5b owns the repair; nothing here is trusted on the harness's word, and the suite result above was taken by hand from a hash-verified tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:56:40 +02:00
var planarToTarget = float3(toTarget.x, 0, toTarget.z);
if (lengthsq(planarToTarget) < 1e-6f) return true;
var planarTarget = normalize(planarToTarget);
return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc / 2f));
}
// The one place a weapon's payload is read: its blast radius, and the fuse it detonates with. A weapon
// detonates only with both a fuse and a radius above zero; either alone is inert and resolves as a direct
// hit (nothing polices that), so a null return means "not fused".
public static WeaponFuse? FuseOf(EquippedItem item, out float blastRadius)
{
var data = item?.Data as WeaponItemData;
blastRadius = data?.BlastRadius > 0f ? data.BlastRadius.Value : 0f;
return blastRadius > 0f ? data.Fuse : null;
}
// The one gate on the trigger (operator rulings fire-on-solutions, controls-solution-trigger, 2026-10-06). A gun
// fires on its solution without the aim, else free along the aim if the aim is inside its arc, else it holds.
// A fused weapon fires whatever the arc says: a target it cannot bear on is no reason to keep it silent, the
// round flies along the arc-clamped aim and bursts at the target's range (Fire; operator ruling 2026-09-30,
// "the user is hoping that the arc gets the weapon close enough to still splash some damage"). A mine layer
// lays a body, not a round, and is never arc-gated. The price of a free shot at a target that does not bear is
// HitProbability's: zero.
public static bool ArcPermitsFire(Weapon weapon, Entity shooter)
{
if (FuseOf(weapon.Item, out _) != null || IsMineLayer(weapon)) return true;
var solution = Solution(weapon, shooter, shooter.Target.Value);
return solution.Bears || solution.Free;
}
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so
// a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The
// answer is Solve's own -- there is no second copy of the arming test.
public static bool Refuses(Weapon weapon, Entity shooter) => Refuses(weapon, shooter, shooter.Target.Value.Entity);
// The same answer against a target the shooter's pilot has commanded but not yet applied.
public static bool Refuses(Weapon weapon, Entity shooter, Entity target) =>
!IsMineLayer(weapon) && Solve(weapon, weapon.Item, shooter, target).Outcome == FireOutcome.Refused;
// A mine layer lays a body, not a round: it is never refused, never arc-gated, and Fire routes it to Lay.
public static bool IsMineLayer(Weapon weapon) => weapon.WeaponData is MineLayerData;
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// The agents' trigger decision (Combat, TurretController), one weapon at a time (operator ruling 2026-09-30).
// A weapon that fires at what it hits fires when the shot is worth taking. A fused weapon fires whenever its
// target is designated, whatever the arc: the round is sent out of arc on purpose (Solve). An agent never
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
// fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and
// never one Solve refuses: a refused weapon is not a weapon it can use.
// The hit-probability threshold is the shooter's doctrine (RoleDoctrine.MinHitProbability), or the turret's global.
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability) =>
AgentFires(weapon, shooter, target, minHitProbability, null);
// The same decision priced on the aim the pilot commands this step (Combat), not the shooter's applied aim slot.
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability, AimTarget aim) =>
AgentFires(weapon, shooter, target, minHitProbability, (AimTarget?) aim);
private static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability, AimTarget? aim)
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
{
// A mine layer lays only at a designated target in range, never at nothing and never on a hit probability.
if (IsMineLayer(weapon)) return Solve(weapon, weapon.Item, shooter, target).Designated;
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
if (FuseOf(weapon.Item, out _) == null)
return HitProbability(weapon, shooter, target, aim) >= minHitProbability;
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
var solution = Solve(weapon, weapon.Item, shooter, target);
return solution.Designated && solution.Outcome != FireOutcome.Refused;
}
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming
// (Entity.Aim -- the player's mouse or a turret's tracking), restricted by the mount's arc. The
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// arc is InArc's own cone: an aim inside it is used as is, one outside it is turned to the nearest edge of
// the same cone. There is no second bearing test here; the edge is where InArc stops passing. A shooter
// with no aim set (Aim zero) aims down the mount, the same fallback InArc takes for a zero bearing.
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// The simulation had no aim clamp before this: the Unity barrel's ArticulationPoint clamps the picture,
// and a target outside the arc merely priced the shot at zero.
public static float2 AimDirection(EquippedItem item, Entity shooter)
{
var mount = MountDirection(item).xz;
var look = shooter.Aim.xz;
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
if (lengthsq(look) < 1e-6f) return mount;
look = normalize(look);
if (InArc(item, float3(look.x, 0, look.y))) return look;
var half = radians(ArcFor(item) / 2f);
var turn = mount.x * look.y - mount.y * look.x >= 0f ? half : -half;
return float2(mount.x * cos(turn) - mount.y * sin(turn), mount.x * sin(turn) + mount.y * cos(turn));
}
// The one owner of a gun's solution against its subject (the designated target; ruling controls-solution-subject).
// Bears: the gun can engage the subject, so the player fires on it without the aim. Free: the aim is inside the
// arc, so the gun can fire along it. Direction: where a round from this gun travels and where its barrel points --
// the subject's intercept when it bears, else the arc-clamped aim. A chunk subject bears through its pose and
// flies the aim, as an untargeted round (Subject is null for it). Derived on every read and never stored: turrets
// traverse instantly in the simulation. Traverse is the weapon's Tracking (ruling mount-arc-gun-traverse), priced in
// TrackingRate; this holds no slew state, and a rig's slew is presentation. It never prices; HitProbability is the one price.
public static GunSolution Solution(Weapon weapon, Entity shooter, TargetRef subject)
{
var bears = false;
if (subject.Entity != null) bears = Bears(weapon, shooter, subject.Entity, out _);
else if (subject.Chunk is ChunkId chunk)
{
var at = shooter.Zone.ChunkPose(chunk.Field, chunk.Index).xy;
bears = InArc(weapon.Item, float3(at.x, 0, at.y) - shooter.Position);
}
return new GunSolution
{
Subject = subject.Entity,
Bears = bears,
Free = InArc(weapon.Item, shooter.Aim),
Direction = bears && subject.Entity != null
? TravelDirection(weapon, shooter, subject.Entity)
: AimDirection(weapon.Item, shooter)
};
}
// The aiming read, and the only reader of live gear beside the book (rulings visibility-per-capability-reveal,
// quiet-gear-identifiable-hull-layout-known, reveal-ends-on-perception): `observer` can aim at `item` when its own
// book holds a reveal of it (Entity.HasReveal: it saw the item act) and the item is still fitted to its ship and
// not destroyed. The book says what was seen; the live item says whether it can still be aimed at. Derived on
// every read, never cached; watching a ship reveals no gear. Any aiming choice that reads an item's class or
// stats (Combat's target-item choice, its dps) asks this; it is the item gate of Entity's aim slot (TrySelectTargetItem,
// ResolvedAim). A mount is aimed by TrySelectMount, which reads no item at all.
public static bool IsRevealed(Entity observer, EquippedItem item) =>
!item.EquippableItem.IsDestroyed && item.Entity.Equipment.Contains(item) && observer.HasReveal(item.Entity, item);
// Cut 2, Q4: the stats an entity actually fires with. A working targeting system (equipped, online --
// not destroyed, not shut down) supplies its own resolved stats; anything else falls back to the
// authored unaided figures, which make aiming without one a last resort rather than an alternative.
// Cut 3's roll is the only planned reader.
public static float Accuracy(Entity entity)
{
var system = entity.GetBehavior<TargetingSystem>();
return system != null && system.Item.Active.Value
? system.Accuracy
: entity.ItemManager.GameplaySettings.UnaidedAccuracy;
}
public static float Resolution(Entity entity)
{
var system = entity.GetBehavior<TargetingSystem>();
return system != null && system.Item.Active.Value ? system.Resolution : 1f;
}
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
// Cut 6d (docs/fire-control-cut.md): falls back to GameplaySettings.UnaidedPrecision, the same shape
// Accuracy/Tracking already fall back to their own Unaided* floors. The old coin-flip rule could get away
// with a bare 0 here (0 meant "never draw the aimed-item branch," a legitimate value under that rule); the
// dart-throw kernel reads Precision as a grouping tightness, and 0 would ask Sigma for an infinite, division-
// guarded group -- not the authored "spraying at the silhouette" floor the spec asks for.
public static float Precision(Entity entity)
{
var system = entity.GetBehavior<TargetingSystem>();
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
return system != null && system.Item.Active.Value ? system.Precision : entity.ItemManager.GameplaySettings.UnaidedPrecision;
}
Fire control Cut 5: close the rules that landed on one path and not its twin Soul's pass over the whole campaign confirmed eleven findings. Seven were code defects of one shape -- a rule implemented on one path and left off the path beside it -- and this is all seven. 5.1 Tracking falls back to GameplaySettings.UnaidedTracking (new, 10) instead of zero, and Commit drops the zero branch that turned a Tracking-less shooter into a hard wall. Unaided fire was meant to be really, really bad; it was 334 shots and no hits against a target drifting at 5 u/s. 5.2 A shot the shield cannot absorb now breaks it. The decision is frozen into ShotOutcome.ShieldBroken at commit and performed in Apply, so a shield that recharges mid-flight cannot retroactively survive. Splash already did this; the discrete roll never did, which made the whole shield-break mechanic unreachable for every weapon in the catalog. 5.3 ConstantWeapon gates on ArcAllowsFire alongside StanceAllowsFire. A side-mounted beam was firing forward. 5.4 The point-blank bearing case moves out of Weapon.ArcAllowsFire and into InArc, which used to normalize the zero vector into NaN. The trigger said fire and the probability said impossible. 5.5 A destroyed item drops out of IsRevealed's ranking, so Precision stops concentrating hits onto a dead subsystem's footprint. 5.6 Zone subscribes Entity.Death and removes the corpse itself. That removal lived only in Unity's loot-drop subscription, so headless nothing ever left the zone, shots landed on corpses and no fight could end. 5.7 PendingShot drops PredictedIntercept and FlightTime, written and read nowhere. 208 tests pass (197 plus eleven new). Known defect, recorded rather than hidden: the committed mutation harness is unreliable. Across two runs it failed to restore FireControl.cs and Weapon.cs, leaving live mutations in the working tree -- including its own 5.1 mutation, which is how a green suite briefly reported two failures. Its verdicts are therefore evidence only where re-run from a verified-clean tree. Two anchors (Zone.cs's ObserveAdd line, Weapon.cs's InArc return) fail to match at all, and 5.3's ConstantWeaponObeysArc survives its own mutation, so that rule is written but not pinned. Cut 5b owns the repair; nothing here is trusted on the harness's word, and the suite result above was taken by hand from a hash-verified tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:56:40 +02:00
// Cut 5, 5.1 (Soul finding 3): falls back to GameplaySettings.UnaidedTracking, exactly the shape Accuracy
// falls back to UnaidedAccuracy. Tracking is now always positive -- Commit no longer carries a zero case.
public static float Tracking(Entity entity)
{
var system = entity.GetBehavior<TargetingSystem>();
Fire control Cut 5: close the rules that landed on one path and not its twin Soul's pass over the whole campaign confirmed eleven findings. Seven were code defects of one shape -- a rule implemented on one path and left off the path beside it -- and this is all seven. 5.1 Tracking falls back to GameplaySettings.UnaidedTracking (new, 10) instead of zero, and Commit drops the zero branch that turned a Tracking-less shooter into a hard wall. Unaided fire was meant to be really, really bad; it was 334 shots and no hits against a target drifting at 5 u/s. 5.2 A shot the shield cannot absorb now breaks it. The decision is frozen into ShotOutcome.ShieldBroken at commit and performed in Apply, so a shield that recharges mid-flight cannot retroactively survive. Splash already did this; the discrete roll never did, which made the whole shield-break mechanic unreachable for every weapon in the catalog. 5.3 ConstantWeapon gates on ArcAllowsFire alongside StanceAllowsFire. A side-mounted beam was firing forward. 5.4 The point-blank bearing case moves out of Weapon.ArcAllowsFire and into InArc, which used to normalize the zero vector into NaN. The trigger said fire and the probability said impossible. 5.5 A destroyed item drops out of IsRevealed's ranking, so Precision stops concentrating hits onto a dead subsystem's footprint. 5.6 Zone subscribes Entity.Death and removes the corpse itself. That removal lived only in Unity's loot-drop subscription, so headless nothing ever left the zone, shots landed on corpses and no fight could end. 5.7 PendingShot drops PredictedIntercept and FlightTime, written and read nowhere. 208 tests pass (197 plus eleven new). Known defect, recorded rather than hidden: the committed mutation harness is unreliable. Across two runs it failed to restore FireControl.cs and Weapon.cs, leaving live mutations in the working tree -- including its own 5.1 mutation, which is how a green suite briefly reported two failures. Its verdicts are therefore evidence only where re-run from a verified-clean tree. Two anchors (Zone.cs's ObserveAdd line, Weapon.cs's InArc return) fail to match at all, and 5.3's ConstantWeaponObeysArc survives its own mutation, so that rule is written but not pinned. Cut 5b owns the repair; nothing here is trusted on the harness's word, and the suite result above was taken by hand from a hash-verified tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:56:40 +02:00
return system != null && system.Item.Active.Value ? system.Tracking : entity.ItemManager.GameplaySettings.UnaidedTracking;
}
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
// Two trackings, two units. Tracking(Entity) above is the ship's targeting gear in METRES: how much changing of
// vector a shooter forgives (PDeviation's tolerance). TrackingRate is a RATE in degrees per second: how fast the
// gun's mount can swing, and it alone prices the angular term. The ship's metres never forgive the mount and the
// gun's rate never forgives the jink (ruling jink-per-ship).
//
// How fast the target sweeps across the shooter's sky, in degrees per second: the component of the relative
// velocity transverse to the line of sight over the range, floored at one metre so a target on top of the
// shooter does not read infinite. Planar, allocation-free, and the one place this angle is computed.
public static float AngularVelocity(float2 offset, float2 relativeVelocity)
{
var range = max(length(offset), 1f);
return degrees(abs(offset.x * relativeVelocity.y - offset.y * relativeVelocity.x) / (range * range));
}
public static float AngularVelocity(Entity source, Entity target) =>
AngularVelocity((target.Position - source.Position).xz, target.Velocity - source.Velocity);
// The gun's rate times the ship's gear over the unaided baseline (ruling tracking-per-gun, "gun times ship"): the
// one product of the two. An unauthored gun is +infinity and so is its rate whatever the gear reads: a mount with no
// limit follows anything, and infinity times a gear that reads zero is not a number.
public static float TrackingRate(Weapon weapon, Entity source) =>
float.IsPositiveInfinity(weapon.Tracking)
? weapon.Tracking
: weapon.Tracking * Tracking(source) / source.ItemManager.GameplaySettings.UnaidedTracking;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
// Cut 3, the risk this map names explicitly: Combat.cs used to run its own first_order_intercept call to
// aim, while the roll measured deviation against a separately-computed prediction -- two functions
// claiming to answer the same question will eventually disagree, and an AI would then aim at one point and
// be judged against another. This is the one function; CombatState (and FireControl.Fire below) both call
// it instead of touching CultMath.first_order_intercept directly. Planar: source velocity is never fed in
// (no shooter here leads its own motion into the shot), matching every existing call site this replaces.
public static float3 PredictedIntercept(Weapon weapon, Entity source, Entity target)
{
var targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y);
return weapon.Velocity > .01f
? first_order_intercept(source.Position, float3.zero, weapon.Velocity, target.Position, targetVelocity)
: target.Position;
}
// Cut 3, R1/R3: the targeting solution (0b table) -- pure, no draw, recomputed fresh on every call. AI
// reads this to decide whether a shot is worth taking; the HUD reads it to draw a number; Fire below reads
// it once, at the instant the trigger is pulled, and freezes the result into the shot (R10, Q6). Two reads
// a tick apart may differ; nothing here or anywhere else caches one.
Cut 10: the hot path stops looking like, and costing like, a diagnostic The diagnostics refactor (72c0109c) kept the debug HUD and the simulation on one fire-control model by routing HitProbability THROUGH Inspect. That was the right invariant implemented at the wrong level: every AI and turret evaluation, per weapon per tick, built the full diagnostic struct and ran IsRevealed's sort, the aim-point cell walk and HullKernel's allocations and per-cell exp -- and only then checked whether the target was even in range. A Soul pass measured 9.87 us and 448 B per call on paths that used to bail in nanoseconds. Operator: it made the hot path look like a diagnostic one. One model is now shared at the level of its factors. HitProbability runs its cheap gates first and bails, exactly as it did before the refactor, and computes PSensor, PSpread and POnHull only for a shot that is actually possible. Inspect calls the same four factor functions and fills the struct unconditionally, because a HUD wants to see why a shot is impossible. The HUD panel and its one test consumer are unchanged. Two tests make the design enforced rather than conventional: - HitProbabilityMatchesInspect: exact equality across 21 configurations covering every gate the hot path bails on plus Precision and info sweeps. Dies when a factor changes in only one caller. - GatedOutHitProbabilityAllocatesNothing: 1000 out-of-range calls allocate zero bytes. Dies when HitProbability is routed back through Inspect. Both mutations verified RED and distinguished from a compile failure, with restores checked against a hash taken before mutating. 231 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:18:12 +02:00
// Cut 10 (docs/fire-control-cut.md): the hot path. Every AI and turret calls this per weapon per tick,
// and the common case is a target out of range or out of arc -- so the cheap gates run first and bail,
// exactly as they did before the diagnostics refactor. The factors are computed only for a shot that is
// actually possible, and they come from the same functions Inspect uses: one fire-control model, shared
// at the level of its factors rather than by routing the hot path through a diagnostic struct.
// HitProbabilityMatchesInspect pins that the two can never disagree.
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Cut 12.2 (docs/fire-control-cut.md): the shooter-side share of the probability -- everything Fire
// freezes (R10), gated exactly as HitProbability gates. Nothing about the target's facing or silhouette
// enters here; that is Silhouette's and PSpread's job, priced fresh by HitProbability below and again,
// live, by Commit at the commit tick. 0 the moment any gate closes.
private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _, out _, out _);
// The `out range` overload is HitProbability's own gate call -- one computation of `target.Position -
// source.Position`, not two. HitProbability used to recompute its own copy for PSpread, which is exactly
// the kind of split authority Cut 3 named as the risk (two places computing the same vector, free to
// drift or to have their subtraction order flipped in only one of them).
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
private static float PFire(Weapon weapon, Entity source, Entity target, out float range) =>
PFire(weapon, source, target, out range, out _, out _);
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// The one answer to "does the shooter hold valid targeting data on this target": visible, in range and locked
// (for a LockWeapon). PFire, Solve and the agents' trigger decision all read it here, and nowhere else.
// F8 correction (Soul's fix batch, 2026-09-24): `range` is computed for any non-null target, before any
// gate -- Fire reads it for flightTime, and flightTime must reflect the real distance even when a gate (not
// visible, out of range) already denies the target. Planar (R7: the simulation is 2D): the range gate, the
// burst reach and the range clamp all measure the same distance, so a target's height never moves one of
// them without the others.
public static bool Designated(Weapon weapon, Entity source, Entity target, out float range)
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
{
range = 0f;
if (target == null) return false;
range = length((target.Position - source.Position).xz);
return source.VisibleEntities.Contains(target) && range >= weapon.MinRange && range <= weapon.Range &&
!(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked);
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
}
// Whether this gun can engage this target: designated and inside the arc. The one test of it; PFire prices a shot
// at zero unless it holds, and Solution reads it to decide whether the gun fires on the target. `designated` is
// the answer above, kept apart for Solve's burst flight: a designated target out of arc still sets the burst range.
public static bool Bears(Weapon weapon, Entity source, Entity target, out float range) =>
Bears(weapon, source, target, out range, out _);
private static bool Bears(Weapon weapon, Entity source, Entity target, out float range, out bool designated)
{
designated = Designated(weapon, source, target, out range);
return designated && InArc(weapon.Item, target.Position - source.Position);
}
// `designated` is the answer above. `inArc` is Bears, asked only once a target is designated.
// PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at
// (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
// out of arc it bursts at the target's range, along the aim. The HUD reads Designated and the
// arc from Solve and Inspect and decides nothing.
private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc)
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
{
inArc = Bears(weapon, source, target, out range, out designated);
if (!inArc) return 0f;
Cut 10: the hot path stops looking like, and costing like, a diagnostic The diagnostics refactor (72c0109c) kept the debug HUD and the simulation on one fire-control model by routing HitProbability THROUGH Inspect. That was the right invariant implemented at the wrong level: every AI and turret evaluation, per weapon per tick, built the full diagnostic struct and ran IsRevealed's sort, the aim-point cell walk and HullKernel's allocations and per-cell exp -- and only then checked whether the target was even in range. A Soul pass measured 9.87 us and 448 B per call on paths that used to bail in nanoseconds. Operator: it made the hot path look like a diagnostic one. One model is now shared at the level of its factors. HitProbability runs its cheap gates first and bails, exactly as it did before the refactor, and computes PSensor, PSpread and POnHull only for a shot that is actually possible. Inspect calls the same four factor functions and fills the struct unconditionally, because a HUD wants to see why a shot is impossible. The HUD panel and its one test consumer are unchanged. Two tests make the design enforced rather than conventional: - HitProbabilityMatchesInspect: exact equality across 21 configurations covering every gate the hot path bails on plus Precision and info sweeps. Dies when a factor changes in only one caller. - GatedOutHitProbabilityAllocatesNothing: 1000 out-of-range calls allocate zero bytes. Dies when HitProbability is routed back through Inspect. Both mutations verified RED and distinguished from a compile failure, with restores checked against a hash taken before mutating. 231 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:18:12 +02:00
var settings = source.ItemManager.GameplaySettings;
var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
return Accuracy(source) * PSensor(settings, Resolution(source), info);
}
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
// Cut 12.2 fix batch (F6, Soul, 2026-09-24): the one place a bearing, a silhouette and PSpread are
// assembled into a live forecast -- HitProbability and Inspect both call this instead of each carrying
// its own copy. They used to each build their own bearing from TravelDirection and the target's facing;
// a mutant that read the SHOOTER's facing instead of the target's survived in HitProbability's own copy
// (M11) while the identical mistake in Inspect's copy was caught (M22), because two functions computing
// the same thing are two places for the same bug to hide and only be found once.
// Cost fix batch (Self, 2026-09-24): Forecast reads only sil.POnHull and sil.Span, never sil.Intervals, so
// it rents the Silhouette's own scratch array from ArrayPool<Interval>.Shared and returns it before
// returning -- Silhouette itself (given this pooled buffer) allocates nothing, measured directly.
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
// 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 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<TargetingSystem> calls (Accuracy, Resolution via PSensor, Precision), at ~40 B each -- each
// one walks Entity.Equipment, a ReactiveCollection<T> 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 or a mount aimed at, ResolveAimPoint also allocates: CellsOf (a List, then
// ToArray) about 944 B per HitProbability, measured; MountCells (a LINQ Select, then ToArray) not measured. Both are 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).
// Evasion fix (evasion-term): the same call also prices how far the target can and does move off the line of
// fire, along the same travel direction, so the live price and the HUD read one evasion.
private static (Silhouette Silhouette, float PSpread, float Evasion) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range, AimTarget aim)
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
{
var travel = TravelDirection(weapon, source, target);
var evasion = Evasion(target, travel);
var bearing = Bearing(target, travel);
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
var buffer = ArrayPool<Interval>.Shared.Rent(targetHull.Shape.Coordinates.Length);
float pSpread;
Silhouette sil;
try
{
sil = Silhouette(target, targetHull, aim.Item, bearing, precision, buffer, aim.Mount);
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
var settings = source.ItemManager.GameplaySettings;
pSpread = PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize);
}
finally
{
ArrayPool<Interval>.Shared.Return(buffer);
}
// sil.Intervals is the returned buffer -- cleared here so nothing outside this function can read it
// after it goes back to the pool and another caller starts overwriting it.
sil.Intervals = null;
return (sil, pSpread, evasion);
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
}
public static float HitProbability(Weapon weapon, Entity source, Entity target) =>
HitProbability(weapon, source, target, (AimTarget?) null);
// Priced on `aim` rather than the source's aim slot: the aim its pilot commands this step.
public static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget aim) =>
HitProbability(weapon, source, target, (AimTarget?) aim);
// A null aim reads source.ResolvedAim, after the gate, so a gated-out shot reads nothing more.
private static float HitProbability(Weapon weapon, Entity source, Entity target, AimTarget? aim)
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
{
var pFire = PFire(weapon, source, target, out var range);
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
if (pFire <= 0f) return 0f;
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range, aim ?? source.ResolvedAim);
return pFire * PMount(AngularVelocity(source, target), TrackingRate(weapon, source)) * pSpread * sil.POnHull * PDeviation(evasion, Tracking(source));
2026-09-20 21:58:44 +02:00
}
Cut 10: the hot path stops looking like, and costing like, a diagnostic The diagnostics refactor (72c0109c) kept the debug HUD and the simulation on one fire-control model by routing HitProbability THROUGH Inspect. That was the right invariant implemented at the wrong level: every AI and turret evaluation, per weapon per tick, built the full diagnostic struct and ran IsRevealed's sort, the aim-point cell walk and HullKernel's allocations and per-cell exp -- and only then checked whether the target was even in range. A Soul pass measured 9.87 us and 448 B per call on paths that used to bail in nanoseconds. Operator: it made the hot path look like a diagnostic one. One model is now shared at the level of its factors. HitProbability runs its cheap gates first and bails, exactly as it did before the refactor, and computes PSensor, PSpread and POnHull only for a shot that is actually possible. Inspect calls the same four factor functions and fills the struct unconditionally, because a HUD wants to see why a shot is impossible. The HUD panel and its one test consumer are unchanged. Two tests make the design enforced rather than conventional: - HitProbabilityMatchesInspect: exact equality across 21 configurations covering every gate the hot path bails on plus Precision and info sweeps. Dies when a factor changes in only one caller. - GatedOutHitProbabilityAllocatesNothing: 1000 out-of-range calls allocate zero bytes. Dies when HitProbability is routed back through Inspect. Both mutations verified RED and distinguished from a compile failure, with restores checked against a hash taken before mutating. 231 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:18:12 +02:00
// Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes
// every factor unconditionally, gates included, because a HUD wants to see why a shot is impossible --
// which is precisely why the hot path must not be routed through it. Both read the same factor
// functions below, and PBase is HitProbability's own answer, so the gates and the product have one
// owner and the HUD cannot grow a second, subtly different model.
2026-09-20 21:58:44 +02:00
public static FireControlDiagnostic Inspect(Weapon weapon, Entity source, Entity target)
{
var settings = source.ItemManager.GameplaySettings;
var diagnostic = new FireControlDiagnostic
{
HasTarget = target != null,
Accuracy = Accuracy(source),
Resolution = Resolution(source),
Precision = Precision(source),
Tracking = Tracking(source),
MinRange = weapon.MinRange,
MaxRange = weapon.Range
};
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
var solution = Solve(weapon, weapon.Item, source, target);
diagnostic.Outcome = solution.Outcome;
diagnostic.BurstReach = solution.BurstReach;
2026-09-20 21:58:44 +02:00
if (target == null) return diagnostic;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
var toTarget = target.Position - source.Position;
diagnostic.Range = length(toTarget.xz);
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
diagnostic.Designated = solution.Designated;
2026-09-20 21:58:44 +02:00
diagnostic.InArc = InArc(weapon.Item, toTarget);
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
Cut 10: the hot path stops looking like, and costing like, a diagnostic The diagnostics refactor (72c0109c) kept the debug HUD and the simulation on one fire-control model by routing HitProbability THROUGH Inspect. That was the right invariant implemented at the wrong level: every AI and turret evaluation, per weapon per tick, built the full diagnostic struct and ran IsRevealed's sort, the aim-point cell walk and HullKernel's allocations and per-cell exp -- and only then checked whether the target was even in range. A Soul pass measured 9.87 us and 448 B per call on paths that used to bail in nanoseconds. Operator: it made the hot path look like a diagnostic one. One model is now shared at the level of its factors. HitProbability runs its cheap gates first and bails, exactly as it did before the refactor, and computes PSensor, PSpread and POnHull only for a shot that is actually possible. Inspect calls the same four factor functions and fills the struct unconditionally, because a HUD wants to see why a shot is impossible. The HUD panel and its one test consumer are unchanged. Two tests make the design enforced rather than conventional: - HitProbabilityMatchesInspect: exact equality across 21 configurations covering every gate the hot path bails on plus Precision and info sweeps. Dies when a factor changes in only one caller. - GatedOutHitProbabilityAllocatesNothing: 1000 out-of-range calls allocate zero bytes. Dies when HitProbability is routed back through Inspect. Both mutations verified RED and distinguished from a compile failure, with restores checked against a hash taken before mutating. 231 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:18:12 +02:00
diagnostic.Info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
diagnostic.InfoDemandCeiling = InfoDemandCeiling(settings, diagnostic.Resolution);
diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info);
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
diagnostic.PFire = diagnostic.Accuracy * diagnostic.PSensor;
var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range, source.ResolvedAim);
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
diagnostic.PSpread = pSpread;
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
diagnostic.POnHull = sil.POnHull;
diagnostic.Evasion = evasion;
diagnostic.PEvasion = PDeviation(evasion, diagnostic.Tracking);
diagnostic.AngularVelocity = AngularVelocity(source, target);
diagnostic.TrackingRate = TrackingRate(weapon, source);
diagnostic.PMount = PMount(diagnostic.AngularVelocity, diagnostic.TrackingRate);
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
diagnostic.PBase = HitProbability(weapon, source, target);
2026-09-20 21:58:44 +02:00
return diagnostic;
}
Cut 10: the hot path stops looking like, and costing like, a diagnostic The diagnostics refactor (72c0109c) kept the debug HUD and the simulation on one fire-control model by routing HitProbability THROUGH Inspect. That was the right invariant implemented at the wrong level: every AI and turret evaluation, per weapon per tick, built the full diagnostic struct and ran IsRevealed's sort, the aim-point cell walk and HullKernel's allocations and per-cell exp -- and only then checked whether the target was even in range. A Soul pass measured 9.87 us and 448 B per call on paths that used to bail in nanoseconds. Operator: it made the hot path look like a diagnostic one. One model is now shared at the level of its factors. HitProbability runs its cheap gates first and bails, exactly as it did before the refactor, and computes PSensor, PSpread and POnHull only for a shot that is actually possible. Inspect calls the same four factor functions and fills the struct unconditionally, because a HUD wants to see why a shot is impossible. The HUD panel and its one test consumer are unchanged. Two tests make the design enforced rather than conventional: - HitProbabilityMatchesInspect: exact equality across 21 configurations covering every gate the hot path bails on plus Precision and info sweeps. Dies when a factor changes in only one caller. - GatedOutHitProbabilityAllocatesNothing: 1000 out-of-range calls allocate zero bytes. Dies when HitProbability is routed back through Inspect. Both mutations verified RED and distinguished from a compile failure, with restores checked against a hash taken before mutating. 231 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:18:12 +02:00
// Cut 6c, 6c.1 (operator ruling 2026-09-19): Resolution stays a benefit -- higher is better -- so the
// formula takes its reciprocal to derive the actual info ceiling instead of reading Resolution as that
// ceiling directly. Resolution 1 (the unaided fallback) yields a ceiling of 1: needs complete
// information, the floor by construction. The max guards a zero or negative authored Resolution; no
// authored value should ever reach it.
private static float InfoDemandCeiling(GameplaySettings settings, float resolution) =>
settings.TargetDetectionInfoThreshold + (1f - settings.TargetDetectionInfoThreshold) / max(resolution, 1e-3f);
private static float PSensor(GameplaySettings settings, float resolution, float info) =>
saturate(unlerp(settings.TargetDetectionInfoThreshold, InfoDemandCeiling(settings, resolution), info));
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Whether the weapon's own barrel-dispersion cone reaches the target's actual silhouette at this range:
// weapon hardware, range-dependent, aim-point-blind. 1 for any zero-spread weapon. Cut 12.2: span comes
// from the same Silhouette pOnHull reads, so the two factors can never disagree about the target's size
// (the old bounding half-extent, max(Width, Height), is gone).
private static float PSpread(float spread, float span, float range, float cellSize)
Cut 10: the hot path stops looking like, and costing like, a diagnostic The diagnostics refactor (72c0109c) kept the debug HUD and the simulation on one fire-control model by routing HitProbability THROUGH Inspect. That was the right invariant implemented at the wrong level: every AI and turret evaluation, per weapon per tick, built the full diagnostic struct and ran IsRevealed's sort, the aim-point cell walk and HullKernel's allocations and per-cell exp -- and only then checked whether the target was even in range. A Soul pass measured 9.87 us and 448 B per call on paths that used to bail in nanoseconds. Operator: it made the hot path look like a diagnostic one. One model is now shared at the level of its factors. HitProbability runs its cheap gates first and bails, exactly as it did before the refactor, and computes PSensor, PSpread and POnHull only for a shot that is actually possible. Inspect calls the same four factor functions and fills the struct unconditionally, because a HUD wants to see why a shot is impossible. The HUD panel and its one test consumer are unchanged. Two tests make the design enforced rather than conventional: - HitProbabilityMatchesInspect: exact equality across 21 configurations covering every gate the hot path bails on plus Precision and info sweeps. Dies when a factor changes in only one caller. - GatedOutHitProbabilityAllocatesNothing: 1000 out-of-range calls allocate zero bytes. Dies when HitProbability is routed back through Inspect. Both mutations verified RED and distinguished from a compile failure, with restores checked against a hash taken before mutating. 231 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:18:12 +02:00
{
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
if (spread <= 0) return 1f;
var halfExtent = .5f * span * cellSize;
Cut 10: the hot path stops looking like, and costing like, a diagnostic The diagnostics refactor (72c0109c) kept the debug HUD and the simulation on one fire-control model by routing HitProbability THROUGH Inspect. That was the right invariant implemented at the wrong level: every AI and turret evaluation, per weapon per tick, built the full diagnostic struct and ran IsRevealed's sort, the aim-point cell walk and HullKernel's allocations and per-cell exp -- and only then checked whether the target was even in range. A Soul pass measured 9.87 us and 448 B per call on paths that used to bail in nanoseconds. Operator: it made the hot path look like a diagnostic one. One model is now shared at the level of its factors. HitProbability runs its cheap gates first and bails, exactly as it did before the refactor, and computes PSensor, PSpread and POnHull only for a shot that is actually possible. Inspect calls the same four factor functions and fills the struct unconditionally, because a HUD wants to see why a shot is impossible. The HUD panel and its one test consumer are unchanged. Two tests make the design enforced rather than conventional: - HitProbabilityMatchesInspect: exact equality across 21 configurations covering every gate the hot path bails on plus Precision and info sweeps. Dies when a factor changes in only one caller. - GatedOutHitProbabilityAllocatesNothing: 1000 out-of-range calls allocate zero bytes. Dies when HitProbability is routed back through Inspect. Both mutations verified RED and distinguished from a compile failure, with restores checked against a hash taken before mutating. 231 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:18:12 +02:00
var angularRadius = degrees(atan(halfExtent / range));
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
return saturate(angularRadius / (spread / 2f));
Cut 10: the hot path stops looking like, and costing like, a diagnostic The diagnostics refactor (72c0109c) kept the debug HUD and the simulation on one fire-control model by routing HitProbability THROUGH Inspect. That was the right invariant implemented at the wrong level: every AI and turret evaluation, per weapon per tick, built the full diagnostic struct and ran IsRevealed's sort, the aim-point cell walk and HullKernel's allocations and per-cell exp -- and only then checked whether the target was even in range. A Soul pass measured 9.87 us and 448 B per call on paths that used to bail in nanoseconds. Operator: it made the hot path look like a diagnostic one. One model is now shared at the level of its factors. HitProbability runs its cheap gates first and bails, exactly as it did before the refactor, and computes PSensor, PSpread and POnHull only for a shot that is actually possible. Inspect calls the same four factor functions and fills the struct unconditionally, because a HUD wants to see why a shot is impossible. The HUD panel and its one test consumer are unchanged. Two tests make the design enforced rather than conventional: - HitProbabilityMatchesInspect: exact equality across 21 configurations covering every gate the hot path bails on plus Precision and info sweeps. Dies when a factor changes in only one caller. - GatedOutHitProbabilityAllocatesNothing: 1000 out-of-range calls allocate zero bytes. Dies when HitProbability is routed back through Inspect. Both mutations verified RED and distinguished from a compile failure, with restores checked against a hash taken before mutating. 231 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:18:12 +02:00
}
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): the shot's world-planar travel
// direction -- PredictedIntercept minus the source's own position, planar (R7). Frozen into PendingShot
// at Fire (R10); HitProbability and Inspect recompute it fresh, at the current source/target positions,
// for their forecast. Below a length of 1e-6 (point-blank, source and intercept coincide) it falls back
// to world +z, the same fallback Apply used before this cut (`:463`).
public static float2 TravelDirection(Weapon weapon, Entity source, Entity target)
{
var toIntercept = (PredictedIntercept(weapon, source, target) - source.Position).xz;
return lengthsq(toIntercept) < 1e-6f ? float2(0, 1) : normalize(toIntercept);
}
// Cut 12.2: the only place a world direction meets a target's facing. One function, so nothing else may
// compute a bearing (the authority map's forbidden-writer rule) -- Commit and the live forecast both read
// this, never a second copy of the formula.
private static float2 Bearing(Entity target, float2 travelDirection) => normalize(target.ToSchematic(travelDirection));
// Cut 12.2: the lateral axis a bearing implies, fixed once here. Silhouette and Lane both read it instead
// of each carrying their own copy of the sign.
private static float2 Lateral(float2 bearing) => float2(-bearing.y, bearing.x);
Cut 10: the hot path stops looking like, and costing like, a diagnostic The diagnostics refactor (72c0109c) kept the debug HUD and the simulation on one fire-control model by routing HitProbability THROUGH Inspect. That was the right invariant implemented at the wrong level: every AI and turret evaluation, per weapon per tick, built the full diagnostic struct and ran IsRevealed's sort, the aim-point cell walk and HullKernel's allocations and per-cell exp -- and only then checked whether the target was even in range. A Soul pass measured 9.87 us and 448 B per call on paths that used to bail in nanoseconds. Operator: it made the hot path look like a diagnostic one. One model is now shared at the level of its factors. HitProbability runs its cheap gates first and bails, exactly as it did before the refactor, and computes PSensor, PSpread and POnHull only for a shot that is actually possible. Inspect calls the same four factor functions and fills the struct unconditionally, because a HUD wants to see why a shot is impossible. The HUD panel and its one test consumer are unchanged. Two tests make the design enforced rather than conventional: - HitProbabilityMatchesInspect: exact equality across 21 configurations covering every gate the hot path bails on plus Precision and info sweeps. Dies when a factor changes in only one caller. - GatedOutHitProbabilityAllocatesNothing: 1000 out-of-range calls allocate zero bytes. Dies when HitProbability is routed back through Inspect. Both mutations verified RED and distinguished from a compile failure, with restores checked against a hash taken before mutating. 231 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:18:12 +02:00
// Cut 12.2 fix batch (S1, Soul's second pass): the one function that projects a unit cell onto an axis --
// the exact support of the axis-aligned unit square centred on c along `axis`, half-width
// h = (|axis.x| + |axis.y|) / 2. Silhouette's shadow (axis = ell) and Lane's own admission test (axis =
// ell) now read this SAME arithmetic instead of Lane re-deriving its own copy of centre/h -- the two used
// to differ only in that Lane's copy was a plain re-derivation, not a second formula, but "not a second
// formula" is exactly why they never drifted apart in exact arithmetic and exactly why the real defect was
// never here: it was the second, independent SlabAlongB gate below, deleted with this fix.
private static Interval Extent(float2 c, float2 axis)
{
var h = (abs(axis.x) + abs(axis.y)) / 2f;
var centre = dot(c, axis);
return new Interval { Lo = centre - h, Hi = centre + h };
}
// The one deviation shape: the share of a shot that survives `deviation` metres of miss against a shooter that
// forgives `tolerance` metres. Every deviation-like price (flight, evasion, a missile's miss) goes through it.
public static float PDeviation(float deviation, float tolerance) =>
tolerance <= 0f ? (deviation <= 0f ? 1f : 0f) : saturate(1f - deviation / tolerance);
// The mount's factor: the share of a shot that survives a target sweeping across the sky at `angularVelocity`
// degrees per second against a gun whose mount follows `rate` degrees per second. Half at the rate, a sixteenth at
// twice it: 0.5^((omega/rate)^2). An infinite rate (an unauthored gun) gives exactly 1; a rate of zero follows
// nothing that moves.
public static float PMount(float angularVelocity, float rate)
{
if (rate <= 0f) return angularVelocity <= 0f ? 1f : 0f;
var ratio = angularVelocity / rate;
return pow(.5f, ratio * ratio);
}
// The commit price's deviation: what the target has already moved off the frozen forecast, plus what it can and
// does move off the line of fire (Evasion). The second part applies at any flight time, so an instant weapon
// (flight time 0, realized deviation 0) still feels a jinking target.
2026-09-20 21:58:44 +02:00
public static float DeviationProbability(PendingShot shot, float now, out float deviation)
{
var elapsed = now - shot.FireTime;
var predicted = shot.FireTargetPosition + shot.FireTargetVelocity * elapsed;
deviation = shot.Target == null ? 0f : length((shot.Target.Position - predicted).xz) + Evasion(shot.Target, shot.TravelDirection);
return shot.Target == null ? 1f : PDeviation(deviation, shot.Tracking);
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
}
// How far a target can and does move off the line of fire in the solution window: the term DeviationProbability
// and HitProbability both add. Behaviour (its observed unpredictability across the line of sight) realises the
// evasion and capability (its envelope's reach) caps it, so a coasting ship evades nothing whatever it could do
// and a steady burn or a dither much faster than the window averages out. A target that is not a Ship has no
// envelope. This is the one call guns and munitions make.
public static float Evasion(Entity target, float2 lineOfSight)
{
var ship = target as Ship;
return ship == null
? 0f
: Evasion(ship.Envelope, ship.Direction, ship.Manoeuvre, lineOfSight, target.ItemManager.GameplaySettings.SolutionWindow);
}
// The pure core, allocation-free. n is perpendicular to the line of sight; u is the half-width the observed
// innovation spreads over the window (0.5 * rms * T^2); the answer is that spread, capped by the half-width of
// what the envelope can reach across the line of sight. Zero spread returns before any reach is evaluated.
public static float Evasion(in ManoeuvreEnvelope envelope, float2 heading, in ManoeuvreTrack track, float2 lineOfSight, float window)
{
var los = lengthsq(lineOfSight) < 1e-12f ? float2(0, 1) : normalize(lineOfSight);
var n = float2(-los.y, los.x);
var spread = .5f * track.LateralRms(n) * window * window;
if (spread <= 0f) return 0f;
return min(.5f * (Reach(envelope, heading, n, window) + Reach(envelope, heading, -n, window)), spread);
}
// The largest displacement from the coasting path along `direction` within `window`, for a ship that turns
// kinematically (controls-turn-inertia) and thrusts within its body-frame box. Eight faces (the four thrust axes
// and the four corners of the box), each turned toward the direction at full rate and thrusting whenever it
// points within 90 degrees of it, then held; and the no-turn support of the box. Matched a brute-force search
// within 0.1% (map M26).
public static float Reach(in ManoeuvreEnvelope e, float2 heading, float2 direction, float window)
{
if (lengthsq(heading) < 1e-12f || lengthsq(direction) < 1e-12f) return 0f;
var forward = normalize(heading);
var right = forward.Rotate(ItemRotation.Clockwise);
var wanted = normalize(direction);
var beta = atan2(dot(wanted, right), dot(wanted, forward));
// The box's support along the wanted direction with no turning: thrust is always free to fire the right sign.
var c = cos(beta);
var s = sin(beta);
var best = .5f * ((c > 0f ? e.Forward * c : -e.Reverse * c) + (s > 0f ? e.Right * s : -e.Left * s)) * window * window;
best = max(best, ReachFace(e.Forward, 0f, beta, e, window));
best = max(best, ReachFace(e.Reverse, MathF.PI, beta, e, window));
best = max(best, ReachFace(e.Right, MathF.PI / 2f, beta, e, window));
best = max(best, ReachFace(e.Left, -MathF.PI / 2f, beta, e, window));
best = max(best, ReachFace(sqrt(e.Forward * e.Forward + e.Right * e.Right), atan2(e.Right, e.Forward), beta, e, window));
best = max(best, ReachFace(sqrt(e.Forward * e.Forward + e.Left * e.Left), atan2(-e.Left, e.Forward), beta, e, window));
best = max(best, ReachFace(sqrt(e.Reverse * e.Reverse + e.Right * e.Right), atan2(e.Right, -e.Reverse), beta, e, window));
best = max(best, ReachFace(sqrt(e.Reverse * e.Reverse + e.Left * e.Left), atan2(-e.Left, -e.Reverse), beta, e, window));
return best;
}
// One face of acceleration `a`, at body angle `faceAngle` (clockwise from forward), turned to point along the
// wanted direction at body angle `beta`, at the rate its side of the envelope allows; thrust only while the face
// points within 90 degrees of the wanted direction, so the integrand is (T - t) a cos(angle between).
private static float ReachFace(float a, float faceAngle, float beta, in ManoeuvreEnvelope e, float window)
{
if (a <= 0f) return 0f;
var turn = Wrap(beta - faceAngle);
var rate = turn > 0f ? e.Clockwise : e.CounterClockwise;
var phi0 = abs(turn);
if (rate <= 1e-4f) return phi0 <= 1e-6f ? .5f * a * window * window : 0f;
var turnTime = phi0 / rate;
var onTime = phi0 <= MathF.PI / 2f ? 0f : (phi0 - MathF.PI / 2f) / rate;
if (onTime >= window) return 0f;
var endTime = min(turnTime, window);
float Integral(float t)
{
var u = phi0 - rate * t;
return -(window - t) * sin(u) / rate - cos(u) / (rate * rate);
}
var turning = endTime > onTime ? a * (Integral(endTime) - Integral(onTime)) : 0f;
var holding = turnTime < window ? .5f * a * (window - turnTime) * (window - turnTime) : 0f;
return turning + holding;
}
private static float Wrap(float angle)
{
while (angle > MathF.PI) angle -= 2f * MathF.PI;
while (angle <= -MathF.PI) angle += 2f * MathF.PI;
return angle;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
}
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// The one decision about what a round does, made from the shooter's state and a target and nothing else:
// no draw, no side effect. Fire freezes it into a PendingShot; Inspect shows it to the HUD; the weapons ask
// it whether a round is refused before they spend anything -- so a refusal is decided once, by the same
// code that would have flown the round, and the HUD cannot forecast a burst the trigger will not fire.
// Direct: a round that resolves against its target (a weapon without a fuse). Burst: a fused round, with
// BurstReach the planar distance from the shooter at which the payload is delivered (a direct round's target, a fused
// round's burst point, 0 for an untargeted direct round), which Fire's range factor reads for every outcome. Refused: a fused round whose blast
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// radius (its arming distance) exceeds the weapon's Range; it cannot be fired.
public static FireSolution Solve(Weapon weapon, EquippedItem item, Entity source, Entity target)
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
{
var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc);
// Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the
// weapon's data fields alone, resolved and frozen here like every other payload field -- never decided
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// later by a Unity projectile, and never by a label. Solve decides "detonates" once: a fuse is frozen
// only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius
// is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below
// still reads as "resolve this shot with Apply."
var fuse = FuseOf(item, out var blastRadius);
// Operator rulings 2026-09-30. A fused round flies along the shooter's aim (AimDirection, clamped to the
// mount's arc) and carries no target -- so nothing at resolution asks whether one is still around --
// unless its target is designated (visible, in range, locked) and in arc:
// * no valid targeting data (no target, or one out of range, not visible or not locked) counts as no
// lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing;
// * valid data but out of arc bursts at the target's range, which the range gate has already held to
// Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash.
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
// Either way the point frozen here is the farthest the round can burst: whatever its fuse, it detonates at the
// first hull on its aim line short of it (CommitBurst; operator ruling 2026-09-30, "a no-lock round stops at
// the first hull"), and bomb fishing bursts at this point only when the line is clear.
// Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot
// without a fuse keeps its target as before.
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// Arming distance (operator ruling 2026-09-30): the blast radius, measured from the shooter's position and
// blind to hull size -- a large hull can take splash from its own close burst. A round never bursts nearer
// than that, so a burst that would land nearer is pushed out along its flight line to it. A round fired at a
// valid target inside the radius is pushed out but stays a targeted round: it keeps its target, so a target
// that leaves the zone cancels it like any other. A round whose Range is shorter than the arming distance
// cannot be fired at all: it is refused, per round, where Range is read after Execute refreshed it (a
// burst's later rounds and a power-starved Range included).
var engaged = fuse != null && !(designated && inArc) ? null : target;
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
var origin = source.Position;
var travelDirection = Solution(weapon, source, target).Direction;
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
var arming = fuse != null ? blastRadius : 0f;
var burstPosition = engaged != null ? engaged.Position : origin;
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
var flightDistance = 0f;
if (fuse != null && engaged != null)
{
// Nothing bursts beyond max range: an intercept a receding target leads the round past Range is
// pulled back along its own line to Range.
var intercept = PredictedIntercept(weapon, source, engaged);
var interceptReach = length((intercept - origin).xz);
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
var point = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept;
burstPosition = Armed(origin, travelDirection, arming, point);
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
flightDistance = max(fireRange, arming);
}
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
else if (fuse != null)
{
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
var reach = designated ? fireRange : weapon.Range;
burstPosition = Armed(origin, travelDirection, arming, origin + float3(travelDirection.x, 0, travelDirection.y) * reach);
flightDistance = max(reach, arming);
}
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
else if (engaged != null) flightDistance = fireRange;
var solution = new FireSolution
{
Outcome = weapon.Range < arming ? FireOutcome.Refused : fuse != null ? FireOutcome.Burst : FireOutcome.Direct,
PFire = pFire,
FireRange = fireRange,
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
Designated = designated,
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
Engaged = engaged,
TravelDirection = travelDirection,
BurstPosition = burstPosition,
BurstReach = length((burstPosition - origin).xz),
FlightDistance = flightDistance,
Fuse = fuse,
BlastRadius = blastRadius
};
return solution;
}
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
// The arming rule, once: a burst point nearer to `origin` than `arming` (planar, blind to hull size) is pushed
// out along the unit planar `direction` to it. Solve applies it to the point it plans; the detonation of a
// contact or delayed blast, whose point is found on the hull only at resolution, applies it to that point.
private static float3 Armed(float3 origin, float2 direction, float arming, float3 point) =>
length((point - origin).xz) < arming ? origin + float3(direction.x, 0, direction.y) * arming : point;
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes flight time, freezes the
// payload snapshot (R10, Q6 -- the gun that fired it, not a re-read later), and queues a PendingShot for
// Zone.Step to age and eventually resolve. Returns the ShotId so the caller's OnFire event can carry it to
// presentation. Cut 5, 5.7 (Soul finding 12): does not store a predicted intercept -- Commit judges
// deviation against a straight-line projection from FireTargetPosition/FireTargetVelocity, never the
// intercept, so a stored copy decided nothing and PendingShot no longer carries one.
// Cut 4 (docs/fire-control-cut.md): damageOverride lets a continuous weapon fire a shot for less than its
// full Damage -- ConstantWeapon.Execute rolls one of these per GameplaySettings.BeamResolveInterval, for
// Damage * interval, through this exact same freeze-and-queue path (a flight time of zero, since a beam's
// authored Velocity is 0, commits and resolves in the tick it fires -- R4's short-flight degradation,
// unchanged). Every other caller keeps reading the frozen weapon.Damage it always did.
// Rounds per step (ruling rounds-per-step): a gun fires every round its cooldown allows in a step, so a round is born
// `age` seconds before the step's end, where the shooter and its target were then (carried back along their velocities).
// FireTime, the origin, the target's frozen pose and the arrival and commit times all follow, so a presenter draws the round
// from where it left and the commit measures the target's stray from the line it was fired along.
public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null, float age = 0f)
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
{
var zone = source.Zone;
if (IsMineLayer(weapon))
{
Lay(weapon, item, source);
return 0;
}
Mining Cut 3: a chunk is a target, detected by reflected light - Entity.Target is one slot of TargetRef, read-only from outside (IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is taken only while the entity can see it, so a rock in darkness cannot be picked. Every handler, agent, turret and test writes through SetTarget. - Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at, computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1. Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection threshold through the chunk path. - The lost-track rule reads through the chunk path: Activate's reconciliation and a per-tick check of the one slot drop a chunk target that has gone dark. - Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range among active weapons -- the one reach rule for cycling and programmatic picks. - Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items, the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk), turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4). TargetRange and the arc gate read a chunk's planar position. - ActionGameManager: the reticle and next/previous offer visible ships and visible chunks in reach; nearest stays enemies-only; the target panel, indicator and fire-control debug take TargetRef. EntityInstance reads .Entity. - MiningCut3Tests pin parity, darkness, monotone brightness, wear and break, reach, the chunk query, target survival and loss, TargetRef equality, lock, and field-kind assignment at generation and first load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:39 +02:00
// Mining Cut 3: a chunk target fires as an untargeted round until Cut 4 routes shots at chunks.
var target = source.Target.Value.Entity;
var now = zone.Time - age;
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time
// re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share --
// those are priced live at Commit, against the target's facing then, not now (Bearing timing, R10).
// Solve is the one range and the one burst decision Inspect and the weapons' refusal check read too.
var solution = Solve(weapon, item, source, target);
if (solution.Outcome == FireOutcome.Refused) return 0;
// The mount factor is frozen with the shooter-side probability: nothing at commit time re-reads the sweep.
var pFire = solution.PFire * (solution.Engaged != null ? PMount(AngularVelocity(source, solution.Engaged), TrackingRate(weapon, source)) : 1f);
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
var fireRange = solution.FireRange;
var engaged = solution.Engaged;
var travelDirection = solution.TravelDirection;
var burstPosition = solution.BurstPosition;
var fuse = solution.Fuse;
var blastRadius = solution.BlastRadius;
var origin = source.Position - float3(source.Velocity.x, 0, source.Velocity.y) * age;
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
var targetVelocity = float3.zero;
var targetPosition = origin;
if (engaged != null)
{
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y);
targetPosition = engaged.Position - targetVelocity * age;
}
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f;
var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon;
var aim = source.ResolvedAim;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
var shot = new PendingShot
{
ShotId = zone.NextShotId(),
Source = source,
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
Target = engaged,
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
Weapon = item,
Aimed = aim.Item,
AimedMount = aim.Mount,
Fire control Cut 4, part 1: FireControl gains Splash and a beam roll FireControl.Fire takes an optional damageOverride so a continuous weapon can queue a shot for less than its full Damage. FireControl.Splash is the one new rule this cut adds: an unconditional area effect (mines, airburst) applying the shield-absorb-or-hull-takes-it branch per target, then DamageSchematic over the directional half of each 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^). ConstantWeapon.Execute rolls once per GameplaySettings.BeamResolveInterval (new setting) for Damage*interval, through the same FireControl.Fire/Step pair a discrete shot uses -- a beam's authored Velocity is 0, so the flight commits and resolves in the same tick it fires (R4's short-flight degradation). A beam is a sequence of rolls, not a continuous truth. Fixes a real defect found while testing the above: both InstantWeapon.Execute (Cut 3) and the new ConstantWeapon.Execute wrote `OnFire?.Invoke(FireControl. Fire(...))` inline. C#'s null-conditional short-circuits the whole expression, argument included, whenever the event has no subscriber -- true of any headless run with no Unity EntityInstance wired up (docs/headless-playground-cut.md) -- so a shot silently never fired. Both now evaluate FireControl.Fire into a local before the null-conditional invoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:16:57 +02:00
Damage = damageOverride ?? weapon.Damage,
FireMinRange = weapon.MinRange,
FireEffectiveRange = weapon.EffectiveRange,
FireDamageAtMaxRange = weapon.WeaponData.DamageAtMaxRange,
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
Penetration = weapon.Penetration,
DamageSpread = weapon.DamageSpread,
DamageType = weapon.WeaponData.DamageType,
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
PFire = pFire,
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
Tracking = Tracking(source),
Precision = Precision(source),
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
TravelDirection = travelDirection,
Spread = weapon.Spread,
FireRange = fireRange,
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
FireTime = now,
Speed = weapon.Velocity,
MaxRange = weapon.Range,
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
FireOrigin = origin,
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
FireTargetPosition = targetPosition,
FireTargetVelocity = targetVelocity,
ArrivalTime = now + flightTime,
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// The commit horizon exists so the target's facing can be read late (Bearing timing). A round fired at
// nothing has no facing to read, so it commits at Fire: its burst point is decided from the poses of
// the moment it leaves, and a Contact round short of max range can arrive as early as it truly does.
CommitTime = fuse != null && engaged == null ? now : now + max(0f, flightTime - commitHorizon),
BurstPosition = burstPosition,
Fuse = fuse,
BlastRadius = blastRadius,
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
Committed = false
};
// A round whose delivery point Solve plans reads the falloff here, at that point (BurstReach); a no-lock fused round's
// point is decided at its commit (CommitBurst), which reads it there. Each shot reads it once.
if (!shot.DeliveredAtCommit) Deliver(ref shot, solution.BurstReach);
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
// The queue is in fire-time order, ties in shot-id order (ids rise as shots are made): Step walks it oldest first. Rounds
// of one step are born shooter by shooter, so a later shooter's earlier round goes in before an earlier shooter's later one.
var queue = zone.PendingShots;
var place = queue.Count;
while (place > 0 && queue[place - 1].FireTime > shot.FireTime) place--;
queue.Insert(place, shot);
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
return shot.ShotId;
}
// The one place a shot's damage meets range: the weapon's falloff at the planar distance its payload is delivered at,
// over the stats frozen at Fire. A delivery is never farther than the Range it was fired with (a burst is pulled back to
// it), so rounding in its distance must not read as beyond it.
private static void Deliver(ref PendingShot shot, float reach) =>
shot.Damage *= WeaponData.RangeFactor(shot.FireMinRange, shot.FireEffectiveRange, shot.MaxRange, shot.FireDamageAtMaxRange, min(reach, shot.MaxRange));
// Lays a mine for a mine layer: freezes the payload as evaluated now (stats, the item's blast radius, the
// layer and its faction) and hands the body to Zone.Lay, the one adder. It launches at the shooter's velocity
// plus the mount direction turned by a dice angle within +-Spread/2, times Velocity; the dice are the mine's
// own (the zone's DropSeed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price:
// InstantWeapon has already paid. A layer whose item has no blast radius lays nothing.
public static void Lay(Weapon weapon, EquippedItem item, Entity source)
{
var zone = source.Zone;
var data = (MineLayerData) weapon.WeaponData;
if (FuseOf(item, out var blastRadius) == null)
{
zone.Log?.Invoke("A mine layer has no blast radius and lays nothing.");
return;
}
var id = zone.NextBodyId();
var half = weapon.Spread / 2f;
var angle = radians(SimulationDice.For(zone.DropSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half));
var s = sin(angle);
var c = cos(angle);
var mount = MountDirection(item).xz;
var direction = float2(mount.x * c - mount.y * s, mount.x * s + mount.y * c);
zone.Lay(new Mine
{
Id = id,
Body = new KinematicBody { Position = source.Position.xz, Velocity = source.Velocity + direction * weapon.Velocity },
ArmingDelay = data.ArmingDelay,
FuseDelay = data.FuseDelay,
Lifetime = data.Lifetime,
BlastRadius = blastRadius,
// Mines are flat (ruling effective-range-authoring-ruled): a mine layer carries no falloff authoring
// (StatValidation.ValidateMineLayerFlat), so its figure is the AI's and the panel's at every distance it can engage.
Damage = weapon.Damage,
DamageType = weapon.WeaponData.DamageType,
Layer = source,
LayerFaction = source.Faction
});
}
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
// Cut 3, R4: ages every shot in the zone, commits the ones that have reached their horizon and resolves
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// the ones that have arrived. Called from Zone.Update after the entity loop. A shot whose target has left
// the zone resolves as a miss and is removed outright (0b table), whichever stage it is at. A round outlives
// its shooter (operator ruling 2026-09-30): the shooter is only an identity a shot carries, never asked
// for at resolution, so a shooter that died or left the zone in flight changes nothing about the shot.
// Resolution reads the zone and the target; there is no credit or loot for a shooter to receive.
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
// A shot's queue state is settled before anything that can run user code: it is committed in the list before
// ShotCommitted fires and removed before its damage is applied (damage observers) or ShotResolved fires, so an
// observer that throws costs that one notification, or the rest of that blast, and cannot leave the shot to be
// committed, resolved or applied again on every later tick.
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
public static void Step(Zone zone, float dt)
{
var shots = zone.PendingShots;
if (shots.Count == 0) return;
var now = zone.Time;
// Commits walk the queue in fire-time order, oldest first (Fire keeps it in it). Shots queued while the walk runs are
// left to the next step.
List<(float Arrival, int Id)> arrived = null;
for (int i = 0, end = shots.Count; i < end;)
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
{
var shot = shots[i];
var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
if (targetGone)
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
{
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// Cut 11 (Soul C4): a shot whose target has left the zone resolves as a miss whichever
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4 Nine tests, each stating as behaviour a rule a real Stryker mutant survived: - A shield that absorbs must eventually run dry, on both the discrete path and Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit survived because earlier tests asserted only that the hull was untouched. - Splash damages the side the blast came from, across five facings and both sides. Flipping the sign of `right` survived every suite, and it survives at the default facing (0, 1) too: the flip only changes the damaged side when a ship faces more along world x than z. So the test spins the target through facings where it bites. The current code is correct; it can no longer regress. - Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the zone from the seed and survived, because the die test checks one zone only. - A shot whose target left the zone resolves as a miss. That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome only for uncommitted shots, so a hit committed inside the horizon and then orphaned by its target dying was republished as a hit -- a hit marker on a corpse. The resolution is now a fresh miss; the committed outcome is left untouched, since R4 makes it immutable. The fixture puts the shooter off the origin on purpose: at float3.zero, `target - source` equals `target + source`, which is how four position mutants survived. 240 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
// stage it is at (0b table). The resolution is published as a fresh miss rather than by
// rewriting shot.Outcome: a committed outcome is immutable (R4) and stays a true record of what
// the commit decided, while ShotResolved reports what actually happened -- nothing, because the
// target is gone. Republishing the committed outcome here used to put a hit marker on a corpse.
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now);
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
shots.RemoveAt(i);
end--;
Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4 Nine tests, each stating as behaviour a rule a real Stryker mutant survived: - A shield that absorbs must eventually run dry, on both the discrete path and Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit survived because earlier tests asserted only that the hull was untouched. - Splash damages the side the blast came from, across five facings and both sides. Flipping the sign of `right` survived every suite, and it survives at the default facing (0, 1) too: the flip only changes the damaged side when a ship faces more along world x than z. So the test spins the target through facings where it bites. The current code is correct; it can no longer regress. - Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the zone from the seed and survived, because the die test checks one zone only. - A shot whose target left the zone resolves as a miss. That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome only for uncommitted shots, so a hit committed inside the horizon and then orphaned by its target dying was republished as a hit -- a hit marker on a corpse. The resolution is now a fresh miss; the committed outcome is left untouched, since R4 makes it immutable. The fixture puts the shooter off the origin on purpose: at float3.zero, `target - source` equals `target + source`, which is how four position mutants survived. 240 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:06:33 +02:00
if (!shot.Committed) zone.ShotCommitted.OnNext(miss);
zone.ShotResolved.OnNext(miss);
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
continue;
}
if (!shot.Committed && now >= shot.CommitTime)
{
shot.Outcome = Commit(zone, ref shot, now);
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
shot.Committed = true;
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// A contact burst short of max range arrives when the round has flown that far, not at the frozen
// max-range time (CommitBurst). Never later than the frozen time.
if (shot.Outcome.Result == ShotResult.Burst) shot.ArrivalTime = min(shot.ArrivalTime, now + shot.Outcome.ArrivalIn);
// Written back before ShotCommitted fires: a presenter re-reading the record there (Zone.TryGetShot) sees
// the committed ArrivalTime a contact burst has just shortened.
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
shots[i] = shot;
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
zone.ShotCommitted.OnNext(shot.Outcome);
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
}
if (shot.Committed && now >= shot.ArrivalTime)
{
(arrived ??= new List<(float, int)>()).Add((shot.ArrivalTime, shot.ShotId));
}
i++;
}
if (arrived == null) return;
// The rounds that arrive in this step apply in arrival order, ties in shot-id order (ids rise as shots are made), so
// which of them breaks a shield or lands a kill follows when each hit, not the order the shooters were stepped in or the
// order their rounds left.
arrived.Sort((a, b) => a.Arrival != b.Arrival ? a.Arrival.CompareTo(b.Arrival) : a.Id.CompareTo(b.Id));
foreach (var (_, id) in arrived)
{
var i = shots.FindIndex(q => q.ShotId == id);
if (i < 0) continue;
var shot = shots[i];
var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);
if (targetGone)
{
// As in the commit walk: a shot whose target left the zone in this step resolves as a miss.
var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now);
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
shots.RemoveAt(i);
zone.ShotResolved.OnNext(miss);
continue;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
}
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of
// which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage
// branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here
// the same way; only Apply decides what that means.
// Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one
// that throws would resolve, and hurt, again on every later tick.
shots.RemoveAt(i);
Apply(zone, shot);
zone.ShotResolved.OnNext(shot.Outcome);
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
}
}
// R3/R4: the one roll. p_base was frozen at fire, PFire carrying the mount factor; the only thing measured live is how far the target has
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
// actually strayed, by now, from where a straight-line projection of its fire-time velocity said it would
// be -- forgiven by the targeting system's Tracking, likewise frozen at fire.
//
// Cut 7: the short-circuit below (`p > 0f &&`) is a guard, NOT an invariant. It used to be one -- a shot
// gated to zero at fire had to draw nothing so the shared stream advanced exactly as if it had never
// queued -- but Cut 6b gave every shot its own generator, seeded from (zone, shot id) and discarded here,
// so whether a gated shot draws is unobservable from anywhere. The claim this comment used to make is
// gone with the stream it was about, and the test that asserted it became vacuous the same moment; what
// replaced it pins the live rule instead (combat touches no shared stream, on any path).
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Cut 12.2 (docs/fire-control-cut.md, "Bearing timing"): steps 1-5. Everything the shooter decided is
// frozen in `shot` (R10); everything the target is doing -- position (deviation) and now also facing
// (bearing) -- is read live, here, at the commit tick. The Cut 7 guard extends to cover the new factors:
// when PFire x pDeviation is already 0, no Silhouette is built. Shared by Commit's own roll and by the
// debug HUD's forecast (TheHudEstimateIsTheCommitPrice) -- one function, one commit-time price.
public static float CommitProbability(PendingShot shot, float now, out Silhouette sil)
{
sil = default;
if (shot.Target == null) return 0f;
var pDeviation = DeviationProbability(shot, now, out _);
var p = shot.PFire * pDeviation;
if (p <= 0f) return 0f;
var bearing = Bearing(shot.Target, shot.TravelDirection);
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData;
sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision, null, shot.AimedMount);
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
var settings = shot.Target.ItemManager.GameplaySettings;
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
return p * PSpread(shot.Spread, sil.Span, shot.FireRange, settings.SchematicCellSize) * sil.POnHull;
}
private static ShotOutcome Commit(Zone zone, ref PendingShot shot, float now)
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
{
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// A fused round with no target rolls nothing: it bursts. Where is decided here, at the commit, from the
// poses of this tick (R4); Apply performs it.
if (shot.DeliveredAtCommit) return CommitBurst(zone, ref shot, now);
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// Cut 6b, 6.1 (Soul finding 6): a shot's dice belong to the shot, not to whatever else happened to
// draw from the engine-wide shared generator first. A pure function of (zone identity, shot id) --
// the `| 1u` guards
// the degenerate zero seed -- so two runs of the same fight from the same galaxy seed roll identically
// no matter what the UI drew from the shared stream in between. This generator is local and dies with
// the call: nothing outside Commit may seed or advance a combat draw, and Commit may not read or write
// a shared stream.
Cut 9: the die is not a die 9.1, blocking. FireControl seeded CultMath's xorshift32 per shot and drew once. NextFloat returns the top 24 bits of the FIRST round, and one round cannot carry ShotId's low-bit variation into the high bits -- so every zone's hit roll spanned about an eighth, with the threshold fixed at zone creation. A shot was a step function of p: below the band it never hit, above it always did. An unaided shooter hit 0 of 18,926 shots. That is the operator's "no hits registering," and being stable per zone is why it came and went. The seed derivation is unchanged -- still a pure function of (zone, shot id), Cut 6b's rule -- and only its diffusion into the first output changes, via an fmix32 finalizer. Measured across six zone seeds: hit fraction at p=.3 went from 0.0% in five of six zones to 29-31%, and at p=.6 to 58-61%, with the roll spanning the full range. The defect survived three cuts because nothing in the campaign measured a distribution -- every fixture uses accuracy 1 or 0, and a constant satisfies a determinism test perfectly. TheDieIsUniform now asserts the observed hit fraction is within .08 of p across several zone seeds, which is the test whose absence was the real defect. 9.2. Sigma is floored at half a cell. HullKernel approximates a continuous Gaussian with a discrete sum over occupied cells, and that sum collapses once sigma falls well under one cell against an aim point that is not exactly on a cell centre -- true of every shipped hull. Past Precision ~4, pOnHull went to zero and a better targeting system made the weapon unable to hit anything. 9.3. An active entity's Target is now always a live entity it has info on. Cut 8's _active guard could never have fixed the operator's LockWeapon crash -- that code only runs on active entities, and cut8's own harness header says so. The live route was dock, target dies, undock; Deactivate disposed the subscription that nulls a stale Target and Activate reseeded EntityInfoGathered without reconciling it. Still not a TryGetValue in LockWeapon. 229 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:33:22 +02:00
// 9.1 (docs/fire-control-cut.md): the per-shot seed is unchanged -- a pure function of
// (zone identity, shot id), Cut 6b's own rule -- but CultMath.Random draws only one xorshift
// round for NextFloat's first call, and one round does not diffuse a seed that differs from its
// neighbours only in ShotId's low bits. Unmixed, every zone's first draw was a near-constant
// function of ShotId (measured span ~0.125), making a shot a step function of p fixed at zone
// creation, not a roll (Soul C1). SimulationDice.Mix is a standard fmix32 finalizer (MurmurHash3): this is
Cut 9: the die is not a die 9.1, blocking. FireControl seeded CultMath's xorshift32 per shot and drew once. NextFloat returns the top 24 bits of the FIRST round, and one round cannot carry ShotId's low-bit variation into the high bits -- so every zone's hit roll spanned about an eighth, with the threshold fixed at zone creation. A shot was a step function of p: below the band it never hit, above it always did. An unaided shooter hit 0 of 18,926 shots. That is the operator's "no hits registering," and being stable per zone is why it came and went. The seed derivation is unchanged -- still a pure function of (zone, shot id), Cut 6b's rule -- and only its diffusion into the first output changes, via an fmix32 finalizer. Measured across six zone seeds: hit fraction at p=.3 went from 0.0% in five of six zones to 29-31%, and at p=.6 to 58-61%, with the roll spanning the full range. The defect survived three cuts because nothing in the campaign measured a distribution -- every fixture uses accuracy 1 or 0, and a constant satisfies a determinism test perfectly. TheDieIsUniform now asserts the observed hit fraction is within .08 of p across several zone seeds, which is the test whose absence was the real defect. 9.2. Sigma is floored at half a cell. HullKernel approximates a continuous Gaussian with a discrete sum over occupied cells, and that sum collapses once sigma falls well under one cell against an aim point that is not exactly on a cell centre -- true of every shipped hull. Past Precision ~4, pOnHull went to zero and a better targeting system made the weapon unable to hit anything. 9.3. An active entity's Target is now always a live entity it has info on. Cut 8's _active guard could never have fixed the operator's LockWeapon crash -- that code only runs on active entities, and cut8's own harness header says so. The live route was dock, target dies, undock; Deactivate disposed the subscription that nulls a stale Target and Activate reseeded EntityInfoGathered without reconciling it. Still not a TryGetValue in LockWeapon. 229 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:33:22 +02:00
// a caller's obligation, not a CultMath defect -- seeding xorshift with a structured value and
// drawing once is the documented way to get a correlated first output, and a follow-up to give
// CultMath.Random a mixed-seed entry point of its own is recorded for a separate CultLib cut.
var random = new Random(SimulationDice.Mix((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
var p = CommitProbability(shot, now, out var sil);
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
var hit = p > 0f && random.NextFloat() < p;
var cell = int2.zero;
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
var bearing = float2.zero;
var lateral = 0f;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
var shielded = false;
Fire control Cut 5: close the rules that landed on one path and not its twin Soul's pass over the whole campaign confirmed eleven findings. Seven were code defects of one shape -- a rule implemented on one path and left off the path beside it -- and this is all seven. 5.1 Tracking falls back to GameplaySettings.UnaidedTracking (new, 10) instead of zero, and Commit drops the zero branch that turned a Tracking-less shooter into a hard wall. Unaided fire was meant to be really, really bad; it was 334 shots and no hits against a target drifting at 5 u/s. 5.2 A shot the shield cannot absorb now breaks it. The decision is frozen into ShotOutcome.ShieldBroken at commit and performed in Apply, so a shield that recharges mid-flight cannot retroactively survive. Splash already did this; the discrete roll never did, which made the whole shield-break mechanic unreachable for every weapon in the catalog. 5.3 ConstantWeapon gates on ArcAllowsFire alongside StanceAllowsFire. A side-mounted beam was firing forward. 5.4 The point-blank bearing case moves out of Weapon.ArcAllowsFire and into InArc, which used to normalize the zero vector into NaN. The trigger said fire and the probability said impossible. 5.5 A destroyed item drops out of IsRevealed's ranking, so Precision stops concentrating hits onto a dead subsystem's footprint. 5.6 Zone subscribes Entity.Death and removes the corpse itself. That removal lived only in Unity's loot-drop subscription, so headless nothing ever left the zone, shots landed on corpses and no fight could end. 5.7 PendingShot drops PredictedIntercept and FlightTime, written and read nowhere. 208 tests pass (197 plus eleven new). Known defect, recorded rather than hidden: the committed mutation harness is unreliable. Across two runs it failed to restore FireControl.cs and Weapon.cs, leaving live mutations in the working tree -- including its own 5.1 mutation, which is how a green suite briefly reported two failures. Its verdicts are therefore evidence only where re-run from a verified-clean tree. Two anchors (Zone.cs's ObserveAdd line, Weapon.cs's InArc return) fail to match at all, and 5.3's ConstantWeaponObeysArc survives its own mutation, so that rule is written but not pinned. Cut 5b owns the repair; nothing here is trusted on the harness's word, and the suite result above was taken by hand from a hash-verified tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:56:40 +02:00
var shieldBroken = false;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
if (hit)
{
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Cut 12.2: the same silhouette pOnHull drew its mass from -- same bearing (live, at this commit
// tick), same sigma (the frozen Precision). A shot that passed the roll always lands on metal (R3:
// one roll decides damage; the off-hull share was already priced into that roll, not resolved here
// as a second stage). The lateral draw picks where along the shadow the shot lands; Lane converts
// that (bearing, lateral) pair into the actual cell -- only the first element (the impact cell,
// nearest the shooter's side) is used here; the rest of the lane is 12.3's armour march.
bearing = Bearing(shot.Target, shot.TravelDirection);
lateral = LateralDraw(sil, random.NextFloat());
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData;
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
var buffer = ArrayPool<LaneCell>.Shared.Rent(hullData.Shape.Coordinates.Length);
try
{
var count = Lane(hullData, bearing, lateral, buffer);
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
// R3: a hit that passed its roll always lands on metal. With LateralDraw's half-open clamp,
// the drawn `lateral` always falls strictly inside the interval sil.POnHull priced, so Lane
// must find at least one occupied cell -- an empty lane here is not a miss to fall back on
// silently (that would put a hit marker on (0,0), which may not even be occupied); it is proof
// the draw and the lane have drifted apart.
if (count == 0)
throw new InvalidOperationException(
$"shot {shot.ShotId}: Lane found no occupied cell at bearing {bearing} lateral {lateral} " +
"after a hit passed its roll -- LateralDraw and Lane have drifted apart (R3).");
cell = buffer[0].Cell;
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
}
finally
{
ArrayPool<LaneCell>.Shared.Return(buffer);
}
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
// Q12-9 = A (docs/fire-control-cut.md): Commit decides no shield for a shot that carries a fuse --
// Detonate decides every blast's shields live, at detonation, the host included, charged the
// entity's own covered share. A direct hit (Fuse == null) is unchanged: Commit still decides it here,
// on the whole frozen Damage.
if (shot.Fuse == null)
{
var shield = shot.Target.Shield;
var shieldActive = shield != null && shield.Item.Active.Value;
if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true;
else if (shieldActive) shieldBroken = true;
}
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
}
// A proximity round detonates at its burst point whether or not the roll hit, so a roll that missed is a
// Burst, not a Miss: the outcome types what happened, and the HUD must not call a real explosion a miss. A
// contact or delayed round detonates only on a hit, so its miss stays a Miss.
var proximity = shot.Fuse == WeaponFuse.Proximity;
var outcome = MakeOutcome(shot, hit ? ShotResult.Hit : proximity ? ShotResult.Burst : ShotResult.Miss,
shielded, shieldBroken, cell, bearing, lateral, now);
if (proximity)
{
outcome.BurstPoint = shot.BurstPosition.xz;
outcome.HasBurstPoint = true;
}
return outcome;
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
}
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
// Operator rulings 2026-09-30. A fused round with no target bursts at the first hull its aim line crosses
// before the max-range point Fire froze, whatever its fuse (contact, proximity and delayed alike: a round with
// nothing locked stops on what is in its way), and at the max-range point when the line is clear. The point is
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// read from this tick's poses and frozen into the outcome (R4, the same timing Commit reads facing at).
// Nothing is rolled or drawn, so determinism needs nothing beyond the frozen inputs.
private static ShotOutcome CommitBurst(Zone zone, ref PendingShot shot, float now)
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
{
var outcome = MakeOutcome(shot, ShotResult.Burst, false, false, int2.zero, float2.zero, 0f, now);
outcome.HasBurstPoint = true;
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz);
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
var travelled = min(reach, max(HullDistance(zone, shot, reach), shot.BlastRadius));
// The delivery point is decided here, so the falloff is read here, at the distance the round travelled to it.
Deliver(ref shot, travelled);
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
if (travelled < reach)
{
outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled;
// The round arrives once it has flown that far, which is sooner than the frozen max-range arrival.
outcome.ArrivalIn = max(0f, shot.FireTime + (shot.ArrivalTime - shot.FireTime) * travelled / reach - now);
}
else outcome.BurstPoint = shot.BurstPosition.xz;
return outcome;
}
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
// The distance along a no-lock round's aim line to the first hull it crosses, or `reach` (the
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// max-range point) when it meets nothing first. The shooter's own hull is never a candidate.
// SEAM FOR THE MINING CAMPAIGN: mining chunks are not Entities and are not on this branch. When they land,
// whatever they are contributes its own entry distance to this minimum, and nothing else in the file changes.
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
private static float HullDistance(Zone zone, PendingShot shot, float reach)
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
{
var nearest = reach;
foreach (var entity in zone.Entities)
{
if (entity == shot.Source) continue;
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
nearest = min(nearest, HullEntry(entity, shot.FireOrigin.xz, shot.TravelDirection));
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
}
return nearest;
}
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// Where the line from `origin` along the unit planar `direction` meets `entity`'s hull: the world distance from
// `origin` to the first metal cell ahead, +infinity when the line meets none, and negative when the origin is
// already inside the hull (the blast radius floors every caller's use of it). Read through Lane's own cell
// admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset is its own, and cells
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
// wholly behind the origin are skipped. Only a no-lock round asks.
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
private static float HullEntry(Entity entity, float2 origin, float2 direction)
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
{
var hullData = entity.ItemManager.GetData(entity.Hull) as HullData;
var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize;
var start = entity.ToSchematicPoint(origin);
var bearing = Bearing(entity, direction);
var lateral = dot(start, Lateral(bearing));
var along = dot(start, bearing);
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
var entry = float.PositiveInfinity;
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
var buffer = ArrayPool<LaneCell>.Shared.Rent(hullData.Shape.Coordinates.Length);
try
{
var count = CellsOnLine(hullData, bearing, lateral, buffer);
for (var i = 0; i < count; i++)
{
if (!(buffer[i].Exit > along)) continue;
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
entry = (buffer[i].Entry - along) * cellSize;
break;
}
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
return entry;
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
}
finally
{
ArrayPool<LaneCell>.Shared.Return(buffer);
}
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
}
// 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, 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.
// F2 fix batch (Soul, 2026-09-25; operator: "I would prefer if the overlapping damage cells were diffused
// sideways to thicken the line rather than doubling up"): lanes are spaced by the shadow width a cell
// casts at this bearing -- Extent's own h, doubled -- not by one cell. At axis-aligned bearings that width
// is exactly 1 (today's spacing, unchanged); at 45 degrees it is sqrt(2). Reusing Extent (the one function
// that owns h, Silhouette's shadow and Lane's own admission test) instead of re-deriving |bx|+|by| a
// second way is the same discipline Cut 12.2's own fix batch already applied to Lane -- two derivations of
// one geometric quantity is exactly how that cut's crash happened. Because each cell's admitted lateral
// interval is exactly this width wide and half-open, lanes this far apart can never both admit the same
// cell: no cell is struck twice, and the footprint widens at angles instead of doubling up.
Cut 12.3 fix batch: F1, decide lane membership once per cell Soul's second pass (CONFIRMED): even after the F2 shadow-width fix, Apply's per-lane loop still called Lane() once per lane index, each call re-deriving its own lateral s(li) = Lateral + (li - n) * laneSpacing by forward float addition, then testing that value against a cell's Extent interval independently of every other lane. Two lane lines are only exactly laneSpacing apart in real arithmetic; accumulating the offset by repeated addition can shift a side lane by an ulp or two, letting it land inside a neighbouring cell's half-open interval that its own unrounded value would never reach -- the same "two derivations of one quantity" defect Cut 12.2's own fix batch already removed from Lane itself, recurring one layer up in Apply. FireControl.Apply now calls a new Lanes() that decides each candidate cell's lane index with one computation: k = ceil((cell.Lo - centreLateral) / laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in the cell's own [Lo, Hi) -- derived once, from values no other lane's arithmetic has touched. A single Ceiling call returns exactly one integer, so a cell can be assigned to at most one lane by construction, not by float luck. When k == 0 this is the same condition Lane() tests for centreLateral itself, so the centre lane's cell set is unchanged (the 12.2 placement contract, HitsLandOnTheFacingEdge, still holds). laneSpacing is still used, unchanged, to place each admitted lane's own line for AlongBearing's slab solve -- geometry, not membership. Lane() itself is untouched (still used directly by Commit and by FireControlCut12Tests' edge-seed tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:06:38 +02:00
// F1 fix batch (Soul's second pass, 2026-09-25): the F2 spacing above is correct in exact arithmetic, but
// the loop that used to sit here still asked the wrong question in float: it called Lane() once per lane
// index, each call re-deriving that lane's own lateral s(li) = Lateral + (li - n) * laneSpacing by forward
// float addition, then testing s(li) against a cell's Extent(c, ell) interval independently of every other
// lane's test. Two lane lines are only ever exactly laneSpacing apart in real arithmetic; accumulating that
// offset by repeated addition can shift a side lane by an ulp or two, and a shifted s(li) can land inside a
// NEIGHBOURING cell's half-open interval that its own, unshifted s(li) was never meant to reach -- the same
// "two derivations of one quantity" defect Cut 12.2's own fix batch found and removed from Lane itself
Cut 12.3 fix batch: unify Lane and Lanes on one membership query Self's review found that commit 4e6495dd's fix for F1 replaced the per-lane forward-arithmetic defect with a second, still-independent membership computation: Lane's own half-open admission (Lo <= s < Hi, from a cell's own Extent) versus Lanes' ceil((cell.Lo - centreLateral) / laneSpacing) lane index. The two agree only when a cell's own (Hi - Lo) equals the externally supplied laneSpacing exactly in float -- the identical two-derivations trap Cut 12.2's own fix batch already found and removed from Lane itself, reintroduced one layer up. Confirmed reachable: on commit 4e6495dd, solid5 b=(0,1) s=0.49999997 n=1 leaves the ceil-based centre lane EMPTY while Lane(s) itself finds all 5 cells of the row -- breaking R3 ("the centre lane always meets metal", Commit's own guard) and the 12.2 placement contract. FireControl.Lanes is now built entirely out of FireControl.Lane: it calls Lane(hull, b, centreLateral + k * laneSpacing, ...) once per lane index, so Lane(s) is literally this function's n = 0 case rather than a parallel re-implementation of its rule. Commit's placement and Apply's damage split now read one function. The narrower question left -- can two individually correct Lane() calls, at two different lines, still walk the same cell? -- is resolved by ownership (the lane closest to the centre keeps a contested cell; the centre lane, k = 0, never loses one), not by a competing formula, so the centre lane's final cell set is always exactly Lane(centreLateral)'s own output. Lane() itself is unchanged (still called directly by Commit and by FireControlCut12Tests' edge-seed and interval-edge tests, all still green: HitsLandOnTheFacingEdge, LaneSeamIsHalfOpen, LaneNeverEmptiesAtAnIntervalEdge, Cut12-edge-68442709, Cut12-edge-64078070). Adds LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull: now that Lanes is a public, pure geometric query, the boundary sweep is a behavioural test of its own contract, no reflection -- over every cell's own shadow edge and float neighbours, at exact-axis, 45-degree, |bx| > |by| bearings and Soul's own two reproduction cases, spread 1-2: (a) no cell is walked by two lanes, (b) the centre lane equals a plain Lane(s) call, (c) the centre lane is never empty when the centre lateral is inside the hull's own shadow. Hand- confirmed to fail on 4e6495dd's ceil-based Lanes (property (b), the same input above) and to pass on this commit (fc123fix2-mut3.log). Adds CommittedCellIsTheFirstCellTheCentreLaneDamages: an end-to-end check through Fire -> Commit -> Apply (damageSpread 0, a single lane) that the committed outcome.Cell is the first cell Apply damages. Full suite: 286 tests, identical across 3 runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:24:55 +02:00
// (S1 above), now recurring one layer up.
// Self's review (2026-09-25): the first fix for this replaced the per-lane Lane() loop with a ceil-based
// index formula that computed lane membership from a cell's own Extent directly -- but that formula and
// Lane's own half-open test (Lo <= s < Hi) were still two separate computations, agreeing only when a
// cell's own (Hi - Lo) equalled laneSpacing exactly in float. That is the identical trap one level removed,
// not removed. `Lanes` below is the actual single owner: it calls Lane() itself once per lane index, so
// 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.
//
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of which model
// a shot uses, through the switch on the frozen shot.Fuse below. Step has no damage branch left -- every
// arrived shot, direct hit or blast, comes through here. A null fuse keeps 12.3's lane path unchanged; a
// proximity fuse detonates at arrival with no roll, whether the shot hit or missed
// (ProximityDetonatesEvenOnAGuaranteedMiss keeps today's AirburstSplashesEvenOnAGuaranteedMiss rule); a
// contact or delayed fuse detonates only on a committed hit, at a point 12.3's own Lane/Reach machinery
// locates. This is a default, not a ruling: a blast shot's DamageSpread is not read, and P sits on the
// centre lane (see the fuse-point comment on the Contact/Delayed branch below).
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
private static void Apply(Zone zone, PendingShot shot)
{
// A committed shot at a ship is an act on its side (ruling ew-hostility-from-belief-ruled): the target provokes the shooter, hit or miss.
if (shot.Target != null && shot.Source != null && shot.Target != shot.Source) shot.Target.Provoke(shot.Source);
// A Burst outcome detonates at the point Commit decided (its BurstPoint): a fused round with no target,
// which has nothing to hit or miss, and a proximity round, which detonates whether its roll hit or not.
// Detonate is still the one blast path.
if (shot.Outcome.Result == ShotResult.Burst || shot.Fuse == WeaponFuse.Proximity)
{
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
Detonate(zone, shot.Outcome.BurstPoint, shot.BlastRadius, shot.Damage, shot.DamageType);
return;
}
switch (shot.Fuse)
{
case null:
ApplyDirectHit(shot);
return;
case WeaponFuse.Contact:
case WeaponFuse.Delayed:
ApplyBlastHit(shot);
return;
}
}
// 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, 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 by Reach (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.
// Cut 12.4(b): unchanged from 12.3 except that the inline penetration clip is now Reach, the shared owner
// the delayed fuse point also reads (see "The penetrator").
private static void ApplyDirectHit(PendingShot shot)
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
{
if (!shot.Outcome.Hit) return;
Fire control Cut 5: close the rules that landed on one path and not its twin Soul's pass over the whole campaign confirmed eleven findings. Seven were code defects of one shape -- a rule implemented on one path and left off the path beside it -- and this is all seven. 5.1 Tracking falls back to GameplaySettings.UnaidedTracking (new, 10) instead of zero, and Commit drops the zero branch that turned a Tracking-less shooter into a hard wall. Unaided fire was meant to be really, really bad; it was 334 shots and no hits against a target drifting at 5 u/s. 5.2 A shot the shield cannot absorb now breaks it. The decision is frozen into ShotOutcome.ShieldBroken at commit and performed in Apply, so a shield that recharges mid-flight cannot retroactively survive. Splash already did this; the discrete roll never did, which made the whole shield-break mechanic unreachable for every weapon in the catalog. 5.3 ConstantWeapon gates on ArcAllowsFire alongside StanceAllowsFire. A side-mounted beam was firing forward. 5.4 The point-blank bearing case moves out of Weapon.ArcAllowsFire and into InArc, which used to normalize the zero vector into NaN. The trigger said fire and the probability said impossible. 5.5 A destroyed item drops out of IsRevealed's ranking, so Precision stops concentrating hits onto a dead subsystem's footprint. 5.6 Zone subscribes Entity.Death and removes the corpse itself. That removal lived only in Unity's loot-drop subscription, so headless nothing ever left the zone, shots landed on corpses and no fight could end. 5.7 PendingShot drops PredictedIntercept and FlightTime, written and read nowhere. 208 tests pass (197 plus eleven new). Known defect, recorded rather than hidden: the committed mutation harness is unreliable. Across two runs it failed to restore FireControl.cs and Weapon.cs, leaving live mutations in the working tree -- including its own 5.1 mutation, which is how a green suite briefly reported two failures. Its verdicts are therefore evidence only where re-run from a verified-clean tree. Two anchors (Zone.cs's ObserveAdd line, Weapon.cs's InArc return) fail to match at all, and 5.3's ConstantWeaponObeysArc survives its own mutation, so that rule is written but not pinned. Cut 5b owns the repair; nothing here is trusted on the harness's word, and the suite result above was taken by hand from a hash-verified tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:56:40 +02:00
// Cut 5, 5.2 (Soul finding 4): the frozen decision, not a re-check -- a shield that recharges during
// the flight does not retroactively survive a hit that broke it at commit time.
if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
if (shot.Outcome.Shielded)
{
Publish(shot.Target, AbsorbLayer.Shield, ImpactPoint(shot.Target, shot.Outcome.Cell),
float3(shot.TravelDirection.x, 0, shot.TravelDirection.y), shot.Damage, shot.DamageType);
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
shot.Target.Shield.TakeHit(shot.DamageType, shot.Damage);
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. Cut 12.4(b): fires only for a direct hit; a proximity burst
// and every bystander in any blast never fire it (AContactBlastReportsTheHit covers the blast half).
target.IncomingHit.OnNext(shot.Source);
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// 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).
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;
// F2: the shadow width at this bearing, from Extent's own h (the cell argument does not affect h).
var shadowExtent = Extent(float2.zero, Lateral(bearing));
var laneSpacing = shadowExtent.Hi - shadowExtent.Lo;
var buffers = new LaneCell[laneCount][];
for (var li = 0; li < laneCount; li++)
buffers[li] = ArrayPool<LaneCell>.Shared.Rent(hullData.Shape.Coordinates.Length);
Cut 12.3 fix batch: F1, decide lane membership once per cell Soul's second pass (CONFIRMED): even after the F2 shadow-width fix, Apply's per-lane loop still called Lane() once per lane index, each call re-deriving its own lateral s(li) = Lateral + (li - n) * laneSpacing by forward float addition, then testing that value against a cell's Extent interval independently of every other lane. Two lane lines are only exactly laneSpacing apart in real arithmetic; accumulating the offset by repeated addition can shift a side lane by an ulp or two, letting it land inside a neighbouring cell's half-open interval that its own unrounded value would never reach -- the same "two derivations of one quantity" defect Cut 12.2's own fix batch already removed from Lane itself, recurring one layer up in Apply. FireControl.Apply now calls a new Lanes() that decides each candidate cell's lane index with one computation: k = ceil((cell.Lo - centreLateral) / laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in the cell's own [Lo, Hi) -- derived once, from values no other lane's arithmetic has touched. A single Ceiling call returns exactly one integer, so a cell can be assigned to at most one lane by construction, not by float luck. When k == 0 this is the same condition Lane() tests for centreLateral itself, so the centre lane's cell set is unchanged (the 12.2 placement contract, HitsLandOnTheFacingEdge, still holds). laneSpacing is still used, unchanged, to place each admitted lane's own line for AlongBearing's slab solve -- geometry, not membership. Lane() itself is untouched (still used directly by Commit and by FireControlCut12Tests' edge-seed tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:06:38 +02:00
var walked = Lanes(hullData, bearing, shot.Outcome.Lateral, n, laneSpacing, buffers);
// Cut 12.4(b) ("The penetrator", Q12-8): Reach is now the one penetration-reach owner, shared with the
// delayed fuse point below -- no second reach derivation exists anywhere in this file.
for (var li = 0; li < laneCount; li++)
walked[li] = Reach(buffers[li], walked[li], shot.Penetration);
Cut 12.3 fix batch: F1, decide lane membership once per cell Soul's second pass (CONFIRMED): even after the F2 shadow-width fix, Apply's per-lane loop still called Lane() once per lane index, each call re-deriving its own lateral s(li) = Lateral + (li - n) * laneSpacing by forward float addition, then testing that value against a cell's Extent interval independently of every other lane. Two lane lines are only exactly laneSpacing apart in real arithmetic; accumulating the offset by repeated addition can shift a side lane by an ulp or two, letting it land inside a neighbouring cell's half-open interval that its own unrounded value would never reach -- the same "two derivations of one quantity" defect Cut 12.2's own fix batch already removed from Lane itself, recurring one layer up in Apply. FireControl.Apply now calls a new Lanes() that decides each candidate cell's lane index with one computation: k = ceil((cell.Lo - centreLateral) / laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in the cell's own [Lo, Hi) -- derived once, from values no other lane's arithmetic has touched. A single Ceiling call returns exactly one integer, so a cell can be assigned to at most one lane by construction, not by float luck. When k == 0 this is the same condition Lane() tests for centreLateral itself, so the centre lane's cell set is unchanged (the 12.2 placement contract, HitsLandOnTheFacingEdge, still holds). laneSpacing is still used, unchanged, to place each admitted lane's own line for AlongBearing's slab solve -- geometry, not membership. Lane() itself is untouched (still used directly by Commit and by FireControlCut12Tests' edge-seed tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:06:38 +02:00
var metalLanes = 0;
for (var li = 0; li < laneCount; 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 = ApplyPooled(target, buffers, walked, laneCount, damagePerLane);
for (var li = 0; li < laneCount; li++) ArrayPool<LaneCell>.Shared.Return(buffers[li]);
Publish(target, AbsorbLayer.Hull, ImpactPoint(target, shot.Outcome.Cell),
float3(shot.TravelDirection.x, 0, shot.TravelDirection.y), shot.Damage, shot.DamageType);
target.DamageHull(hullDamage);
}
// Where a landed direct hit meets the receiver: the impact cell's world point on its sim pose, at its height.
private static float3 ImpactPoint(Entity target, int2 cell)
{
var world = target.ToWorldPoint((float2) cell);
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));
// 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)
{
var away = entity.Position.xz - 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);
}
// Cut 12.4(b) (docs/fire-control-cut.md, "Where P is"): a contact or delayed blast needs a committed hit --
// on a miss nothing happens, exactly like a direct hit's own gate. IncomingHit fires once, for the host,
// before the blast is handed to Detonate (which then treats the host like any other entity in the radius:
// Detonate never reads a host parameter, and finds the host's own cells again by ToSchematicPoint).
private static void ApplyBlastHit(PendingShot shot)
{
if (!shot.Outcome.Hit) return;
var target = shot.Target;
target.IncomingHit.OnNext(shot.Source);
// "Where P is": the lane's own parametrisation gives the point directly, point(t) = t*b + s*ell, on the
// committed lane (the committed Bearing and Lateral, which Commit's own guard already proved non-empty,
// R3). Contact: t is the impact cell's own entry, lane[0].Entry. Delayed: t_fuse is where the frozen
// Penetration runs out or the metal does, whichever comes first -- Reach (the same owner the direct-hit
// walk reads) decides how deep the lane reaches, and t_fuse clamps to the last reached cell's exit so
// the disc is always centred on metal.
var hullData = target.ItemManager.GetData(target.Hull) as HullData;
var bearing = shot.Outcome.Bearing;
var buffer = ArrayPool<LaneCell>.Shared.Rent(hullData.Shape.Coordinates.Length);
float2 schematicPoint;
try
{
var walked = Lane(hullData, bearing, shot.Outcome.Lateral, buffer);
var t = buffer[0].Entry;
if (shot.Fuse == WeaponFuse.Delayed)
{
var reach = Reach(buffer, walked, shot.Penetration);
t = min(buffer[0].Entry + shot.Penetration, buffer[reach - 1].Exit);
}
schematicPoint = t * bearing + shot.Outcome.Lateral * Lateral(bearing);
}
finally
{
ArrayPool<LaneCell>.Shared.Return(buffer);
}
// "Conversion to the world": the round trip through the host's own ToWorldPoint/ToSchematicPoint
// cancels the host's pose, so the host's own damage is fixed by the commit (R4 holds for it), while
// every bystander is judged against where it actually is at arrival -- the same live rule a proximity
// burst already applies to everyone.
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
// The arming rule applies to this point as it does to every other burst (Solve's Armed): a hull met nearer
// the muzzle than the blast radius is detonated on at the radius, along the round's own flight line.
var hullPoint = target.ToWorldPoint(schematicPoint);
var worldPoint = Armed(shot.FireOrigin, shot.TravelDirection, shot.BlastRadius, float3(hullPoint.x, shot.FireOrigin.y, hullPoint.y)).xz;
Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType);
}
// Cut 12.4(b) (docs/fire-control-cut.md, "The penetrator", Q12-8): the one penetration-reach owner, moved
// verbatim from Apply's own inline clip (Cut 12.3 fix batch) so the delayed fuse point above can read the
// identical rule the direct-hit walk already used. The rule is unchanged: the impact cell is always
// reached, and a later cell is reached while entry - impact.entry < penetration. Two callers, and no third:
// a null-fuse shot spends damage along the walk (ApplyDirectHit), a contact-or-delayed shot only locates P
// (ApplyBlastHit) -- no shot ever takes both branches of Apply's switch.
private static int Reach(LaneCell[] cells, int walked, float penetration)
{
if (walked == 0) return 0;
var impactEntry = cells[0].Entry;
var reach = 1;
while (reach < walked && cells[reach].Entry - impactEntry < penetration) reach++;
return reach;
}
// 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.
//
// 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)
{
// 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;
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)
{
finished[li] = true;
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;
// No +1 needed when li2 is blocked at this same item's cell: `alreadyContributed` above already
// caught that case (a blocked lane is always a contributor to the item it is blocked on), so
// `pointer[li2]` itself is never this item's own cell by the time this line runs.
for (var i = pointer[li2]; 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 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
{
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]++;
// One writer for lane completion: AdvanceLane, not here. Unblocking and advancing the
// pointer is enough -- the next call to AdvanceLane(lane), later this pass or the next while
// iteration, is the one place that checks `pointer >= walked` and calls FinishLane.
}
}
finally
{
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])
{
if (pointer[li] >= walked[li])
{
FinishLane(li);
moved = true;
break;
}
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) Deposit(li, item);
else pointer[li]++;
}
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)
{
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);
}
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
}
Cut 12.3 fix batch: unify Lane and Lanes on one membership query Self's review found that commit 4e6495dd's fix for F1 replaced the per-lane forward-arithmetic defect with a second, still-independent membership computation: Lane's own half-open admission (Lo <= s < Hi, from a cell's own Extent) versus Lanes' ceil((cell.Lo - centreLateral) / laneSpacing) lane index. The two agree only when a cell's own (Hi - Lo) equals the externally supplied laneSpacing exactly in float -- the identical two-derivations trap Cut 12.2's own fix batch already found and removed from Lane itself, reintroduced one layer up. Confirmed reachable: on commit 4e6495dd, solid5 b=(0,1) s=0.49999997 n=1 leaves the ceil-based centre lane EMPTY while Lane(s) itself finds all 5 cells of the row -- breaking R3 ("the centre lane always meets metal", Commit's own guard) and the 12.2 placement contract. FireControl.Lanes is now built entirely out of FireControl.Lane: it calls Lane(hull, b, centreLateral + k * laneSpacing, ...) once per lane index, so Lane(s) is literally this function's n = 0 case rather than a parallel re-implementation of its rule. Commit's placement and Apply's damage split now read one function. The narrower question left -- can two individually correct Lane() calls, at two different lines, still walk the same cell? -- is resolved by ownership (the lane closest to the centre keeps a contested cell; the centre lane, k = 0, never loses one), not by a competing formula, so the centre lane's final cell set is always exactly Lane(centreLateral)'s own output. Lane() itself is unchanged (still called directly by Commit and by FireControlCut12Tests' edge-seed and interval-edge tests, all still green: HitsLandOnTheFacingEdge, LaneSeamIsHalfOpen, LaneNeverEmptiesAtAnIntervalEdge, Cut12-edge-68442709, Cut12-edge-64078070). Adds LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull: now that Lanes is a public, pure geometric query, the boundary sweep is a behavioural test of its own contract, no reflection -- over every cell's own shadow edge and float neighbours, at exact-axis, 45-degree, |bx| > |by| bearings and Soul's own two reproduction cases, spread 1-2: (a) no cell is walked by two lanes, (b) the centre lane equals a plain Lane(s) call, (c) the centre lane is never empty when the centre lateral is inside the hull's own shadow. Hand- confirmed to fail on 4e6495dd's ceil-based Lanes (property (b), the same input above) and to pass on this commit (fc123fix2-mut3.log). Adds CommittedCellIsTheFirstCellTheCentreLaneDamages: an end-to-end check through Fire -> Commit -> Apply (damageSpread 0, a single lane) that the committed outcome.Cell is the first cell Apply damages. Full suite: 286 tests, identical across 3 runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:24:55 +02:00
// Self's fix (2026-09-25): the ONE public membership query behind both Commit's placement and Apply's
// damage split. Lane(s) is exactly this function's n = 0 case -- Lanes calls Lane(hull, b, centreLateral +
// k * laneSpacing, ...) once per lane index k, so a lane's own cell set can never disagree with what
// Lane(s) alone returns for that s: there is no second formula for "does this cell belong to this lane",
// only Lane's own half-open Extent test, called at 2n+1 different lines. This is what removes the trap a
// ceil-based index formula reintroduced one level up from Cut 12.2's own crash (see Apply's comment above):
// that formula and Lane's own test were still two computations, agreeing only when a cell's own (Hi - Lo)
// equalled the externally supplied laneSpacing exactly in float.
// What is left, once every lane's own result is individually exact, is a DIFFERENT and much narrower
// question: can two separate calls to Lane(), at two different (and individually correct) lines, still walk
// the SAME cell? Yes, rarely -- laneSpacing (F2's shadow width, one value shared by every lane) can differ
// by a few ulps from one specific cell's own computed (Hi - Lo), the same float drift Soul's second pass
// found. Lanes resolves that by ownership, not by a competing membership rule: a cell walked by more than
// one lane keeps the lane closest to the centre (ties are impossible -- equal |k| means the same lane), and
// the centre lane (k = 0) never loses a cell to a side lane. Its own final cell set is therefore always
// exactly Lane(centreLateral)'s own output, unmodified -- R3 (the centre lane always meets metal, Commit's
// own guard) and the 12.2 placement contract (HitsLandOnTheFacingEdge) hold structurally, not by chance. A
// side lane that cedes a cell simply omits it from its own walk; the walk's existing contiguity rule (a
// cell more than 1e-4 past the previous SURVIVING cell's exit ends the lane) then decides on its own whether
// the lane continues past the gap that leaves -- no new rule, the same one `Lane` already applies at a
// genuine gap. laneSpacing itself is untouched: it is still the one F2 value that places every lane's own
// query line.
public static int[] Lanes(HullData hull, float2 b, float centreLateral, int n, float laneSpacing, LaneCell[][] buffers)
Cut 12.3 fix batch: F1, decide lane membership once per cell Soul's second pass (CONFIRMED): even after the F2 shadow-width fix, Apply's per-lane loop still called Lane() once per lane index, each call re-deriving its own lateral s(li) = Lateral + (li - n) * laneSpacing by forward float addition, then testing that value against a cell's Extent interval independently of every other lane. Two lane lines are only exactly laneSpacing apart in real arithmetic; accumulating the offset by repeated addition can shift a side lane by an ulp or two, letting it land inside a neighbouring cell's half-open interval that its own unrounded value would never reach -- the same "two derivations of one quantity" defect Cut 12.2's own fix batch already removed from Lane itself, recurring one layer up in Apply. FireControl.Apply now calls a new Lanes() that decides each candidate cell's lane index with one computation: k = ceil((cell.Lo - centreLateral) / laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in the cell's own [Lo, Hi) -- derived once, from values no other lane's arithmetic has touched. A single Ceiling call returns exactly one integer, so a cell can be assigned to at most one lane by construction, not by float luck. When k == 0 this is the same condition Lane() tests for centreLateral itself, so the centre lane's cell set is unchanged (the 12.2 placement contract, HitsLandOnTheFacingEdge, still holds). laneSpacing is still used, unchanged, to place each admitted lane's own line for AlongBearing's slab solve -- geometry, not membership. Lane() itself is untouched (still used directly by Commit and by FireControlCut12Tests' edge-seed tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:06:38 +02:00
{
var laneCount = 2 * n + 1;
Cut 12.3 fix batch: unify Lane and Lanes on one membership query Self's review found that commit 4e6495dd's fix for F1 replaced the per-lane forward-arithmetic defect with a second, still-independent membership computation: Lane's own half-open admission (Lo <= s < Hi, from a cell's own Extent) versus Lanes' ceil((cell.Lo - centreLateral) / laneSpacing) lane index. The two agree only when a cell's own (Hi - Lo) equals the externally supplied laneSpacing exactly in float -- the identical two-derivations trap Cut 12.2's own fix batch already found and removed from Lane itself, reintroduced one layer up. Confirmed reachable: on commit 4e6495dd, solid5 b=(0,1) s=0.49999997 n=1 leaves the ceil-based centre lane EMPTY while Lane(s) itself finds all 5 cells of the row -- breaking R3 ("the centre lane always meets metal", Commit's own guard) and the 12.2 placement contract. FireControl.Lanes is now built entirely out of FireControl.Lane: it calls Lane(hull, b, centreLateral + k * laneSpacing, ...) once per lane index, so Lane(s) is literally this function's n = 0 case rather than a parallel re-implementation of its rule. Commit's placement and Apply's damage split now read one function. The narrower question left -- can two individually correct Lane() calls, at two different lines, still walk the same cell? -- is resolved by ownership (the lane closest to the centre keeps a contested cell; the centre lane, k = 0, never loses one), not by a competing formula, so the centre lane's final cell set is always exactly Lane(centreLateral)'s own output. Lane() itself is unchanged (still called directly by Commit and by FireControlCut12Tests' edge-seed and interval-edge tests, all still green: HitsLandOnTheFacingEdge, LaneSeamIsHalfOpen, LaneNeverEmptiesAtAnIntervalEdge, Cut12-edge-68442709, Cut12-edge-64078070). Adds LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull: now that Lanes is a public, pure geometric query, the boundary sweep is a behavioural test of its own contract, no reflection -- over every cell's own shadow edge and float neighbours, at exact-axis, 45-degree, |bx| > |by| bearings and Soul's own two reproduction cases, spread 1-2: (a) no cell is walked by two lanes, (b) the centre lane equals a plain Lane(s) call, (c) the centre lane is never empty when the centre lateral is inside the hull's own shadow. Hand- confirmed to fail on 4e6495dd's ceil-based Lanes (property (b), the same input above) and to pass on this commit (fc123fix2-mut3.log). Adds CommittedCellIsTheFirstCellTheCentreLaneDamages: an end-to-end check through Fire -> Commit -> Apply (damageSpread 0, a single lane) that the committed outcome.Cell is the first cell Apply damages. Full suite: 286 tests, identical across 3 runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:24:55 +02:00
var raw = new LaneCell[laneCount][];
var rawCount = new int[laneCount];
for (var li = 0; li < laneCount; li++)
Cut 12.3 fix batch: F1, decide lane membership once per cell Soul's second pass (CONFIRMED): even after the F2 shadow-width fix, Apply's per-lane loop still called Lane() once per lane index, each call re-deriving its own lateral s(li) = Lateral + (li - n) * laneSpacing by forward float addition, then testing that value against a cell's Extent interval independently of every other lane. Two lane lines are only exactly laneSpacing apart in real arithmetic; accumulating the offset by repeated addition can shift a side lane by an ulp or two, letting it land inside a neighbouring cell's half-open interval that its own unrounded value would never reach -- the same "two derivations of one quantity" defect Cut 12.2's own fix batch already removed from Lane itself, recurring one layer up in Apply. FireControl.Apply now calls a new Lanes() that decides each candidate cell's lane index with one computation: k = ceil((cell.Lo - centreLateral) / laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in the cell's own [Lo, Hi) -- derived once, from values no other lane's arithmetic has touched. A single Ceiling call returns exactly one integer, so a cell can be assigned to at most one lane by construction, not by float luck. When k == 0 this is the same condition Lane() tests for centreLateral itself, so the centre lane's cell set is unchanged (the 12.2 placement contract, HitsLandOnTheFacingEdge, still holds). laneSpacing is still used, unchanged, to place each admitted lane's own line for AlongBearing's slab solve -- geometry, not membership. Lane() itself is untouched (still used directly by Commit and by FireControlCut12Tests' edge-seed tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:06:38 +02:00
{
Cut 12.3 fix batch: unify Lane and Lanes on one membership query Self's review found that commit 4e6495dd's fix for F1 replaced the per-lane forward-arithmetic defect with a second, still-independent membership computation: Lane's own half-open admission (Lo <= s < Hi, from a cell's own Extent) versus Lanes' ceil((cell.Lo - centreLateral) / laneSpacing) lane index. The two agree only when a cell's own (Hi - Lo) equals the externally supplied laneSpacing exactly in float -- the identical two-derivations trap Cut 12.2's own fix batch already found and removed from Lane itself, reintroduced one layer up. Confirmed reachable: on commit 4e6495dd, solid5 b=(0,1) s=0.49999997 n=1 leaves the ceil-based centre lane EMPTY while Lane(s) itself finds all 5 cells of the row -- breaking R3 ("the centre lane always meets metal", Commit's own guard) and the 12.2 placement contract. FireControl.Lanes is now built entirely out of FireControl.Lane: it calls Lane(hull, b, centreLateral + k * laneSpacing, ...) once per lane index, so Lane(s) is literally this function's n = 0 case rather than a parallel re-implementation of its rule. Commit's placement and Apply's damage split now read one function. The narrower question left -- can two individually correct Lane() calls, at two different lines, still walk the same cell? -- is resolved by ownership (the lane closest to the centre keeps a contested cell; the centre lane, k = 0, never loses one), not by a competing formula, so the centre lane's final cell set is always exactly Lane(centreLateral)'s own output. Lane() itself is unchanged (still called directly by Commit and by FireControlCut12Tests' edge-seed and interval-edge tests, all still green: HitsLandOnTheFacingEdge, LaneSeamIsHalfOpen, LaneNeverEmptiesAtAnIntervalEdge, Cut12-edge-68442709, Cut12-edge-64078070). Adds LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull: now that Lanes is a public, pure geometric query, the boundary sweep is a behavioural test of its own contract, no reflection -- over every cell's own shadow edge and float neighbours, at exact-axis, 45-degree, |bx| > |by| bearings and Soul's own two reproduction cases, spread 1-2: (a) no cell is walked by two lanes, (b) the centre lane equals a plain Lane(s) call, (c) the centre lane is never empty when the centre lateral is inside the hull's own shadow. Hand- confirmed to fail on 4e6495dd's ceil-based Lanes (property (b), the same input above) and to pass on this commit (fc123fix2-mut3.log). Adds CommittedCellIsTheFirstCellTheCentreLaneDamages: an end-to-end check through Fire -> Commit -> Apply (damageSpread 0, a single lane) that the committed outcome.Cell is the first cell Apply damages. Full suite: 286 tests, identical across 3 runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:24:55 +02:00
var k = li - n;
Cut 12.3 fix batch: F1, decide lane membership once per cell Soul's second pass (CONFIRMED): even after the F2 shadow-width fix, Apply's per-lane loop still called Lane() once per lane index, each call re-deriving its own lateral s(li) = Lateral + (li - n) * laneSpacing by forward float addition, then testing that value against a cell's Extent interval independently of every other lane. Two lane lines are only exactly laneSpacing apart in real arithmetic; accumulating the offset by repeated addition can shift a side lane by an ulp or two, letting it land inside a neighbouring cell's half-open interval that its own unrounded value would never reach -- the same "two derivations of one quantity" defect Cut 12.2's own fix batch already removed from Lane itself, recurring one layer up in Apply. FireControl.Apply now calls a new Lanes() that decides each candidate cell's lane index with one computation: k = ceil((cell.Lo - centreLateral) / laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in the cell's own [Lo, Hi) -- derived once, from values no other lane's arithmetic has touched. A single Ceiling call returns exactly one integer, so a cell can be assigned to at most one lane by construction, not by float luck. When k == 0 this is the same condition Lane() tests for centreLateral itself, so the centre lane's cell set is unchanged (the 12.2 placement contract, HitsLandOnTheFacingEdge, still holds). laneSpacing is still used, unchanged, to place each admitted lane's own line for AlongBearing's slab solve -- geometry, not membership. Lane() itself is untouched (still used directly by Commit and by FireControlCut12Tests' edge-seed tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:06:38 +02:00
var s = centreLateral + k * laneSpacing;
Cut 12.3 fix batch: unify Lane and Lanes on one membership query Self's review found that commit 4e6495dd's fix for F1 replaced the per-lane forward-arithmetic defect with a second, still-independent membership computation: Lane's own half-open admission (Lo <= s < Hi, from a cell's own Extent) versus Lanes' ceil((cell.Lo - centreLateral) / laneSpacing) lane index. The two agree only when a cell's own (Hi - Lo) equals the externally supplied laneSpacing exactly in float -- the identical two-derivations trap Cut 12.2's own fix batch already found and removed from Lane itself, reintroduced one layer up. Confirmed reachable: on commit 4e6495dd, solid5 b=(0,1) s=0.49999997 n=1 leaves the ceil-based centre lane EMPTY while Lane(s) itself finds all 5 cells of the row -- breaking R3 ("the centre lane always meets metal", Commit's own guard) and the 12.2 placement contract. FireControl.Lanes is now built entirely out of FireControl.Lane: it calls Lane(hull, b, centreLateral + k * laneSpacing, ...) once per lane index, so Lane(s) is literally this function's n = 0 case rather than a parallel re-implementation of its rule. Commit's placement and Apply's damage split now read one function. The narrower question left -- can two individually correct Lane() calls, at two different lines, still walk the same cell? -- is resolved by ownership (the lane closest to the centre keeps a contested cell; the centre lane, k = 0, never loses one), not by a competing formula, so the centre lane's final cell set is always exactly Lane(centreLateral)'s own output. Lane() itself is unchanged (still called directly by Commit and by FireControlCut12Tests' edge-seed and interval-edge tests, all still green: HitsLandOnTheFacingEdge, LaneSeamIsHalfOpen, LaneNeverEmptiesAtAnIntervalEdge, Cut12-edge-68442709, Cut12-edge-64078070). Adds LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull: now that Lanes is a public, pure geometric query, the boundary sweep is a behavioural test of its own contract, no reflection -- over every cell's own shadow edge and float neighbours, at exact-axis, 45-degree, |bx| > |by| bearings and Soul's own two reproduction cases, spread 1-2: (a) no cell is walked by two lanes, (b) the centre lane equals a plain Lane(s) call, (c) the centre lane is never empty when the centre lateral is inside the hull's own shadow. Hand- confirmed to fail on 4e6495dd's ceil-based Lanes (property (b), the same input above) and to pass on this commit (fc123fix2-mut3.log). Adds CommittedCellIsTheFirstCellTheCentreLaneDamages: an end-to-end check through Fire -> Commit -> Apply (damageSpread 0, a single lane) that the committed outcome.Cell is the first cell Apply damages. Full suite: 286 tests, identical across 3 runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:24:55 +02:00
raw[li] = ArrayPool<LaneCell>.Shared.Rent(hull.Shape.Coordinates.Length);
rawCount[li] = Lane(hull, b, s, raw[li]);
}
// Ownership: whichever lane is closest to the centre keeps a cell two lanes both walked; the centre
// lane (k = 0, |k| = 0) can never be outranked.
var ownerLane = new Dictionary<int2, int>();
for (var li = 0; li < laneCount; li++)
{
var k = Math.Abs(li - n);
for (var i = 0; i < rawCount[li]; i++)
{
var cell = raw[li][i].Cell;
if (!ownerLane.TryGetValue(cell, out var curLi) || Math.Abs(curLi - n) > k)
ownerLane[cell] = li;
}
Cut 12.3 fix batch: F1, decide lane membership once per cell Soul's second pass (CONFIRMED): even after the F2 shadow-width fix, Apply's per-lane loop still called Lane() once per lane index, each call re-deriving its own lateral s(li) = Lateral + (li - n) * laneSpacing by forward float addition, then testing that value against a cell's Extent interval independently of every other lane. Two lane lines are only exactly laneSpacing apart in real arithmetic; accumulating the offset by repeated addition can shift a side lane by an ulp or two, letting it land inside a neighbouring cell's half-open interval that its own unrounded value would never reach -- the same "two derivations of one quantity" defect Cut 12.2's own fix batch already removed from Lane itself, recurring one layer up in Apply. FireControl.Apply now calls a new Lanes() that decides each candidate cell's lane index with one computation: k = ceil((cell.Lo - centreLateral) / laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in the cell's own [Lo, Hi) -- derived once, from values no other lane's arithmetic has touched. A single Ceiling call returns exactly one integer, so a cell can be assigned to at most one lane by construction, not by float luck. When k == 0 this is the same condition Lane() tests for centreLateral itself, so the centre lane's cell set is unchanged (the 12.2 placement contract, HitsLandOnTheFacingEdge, still holds). laneSpacing is still used, unchanged, to place each admitted lane's own line for AlongBearing's slab solve -- geometry, not membership. Lane() itself is untouched (still used directly by Commit and by FireControlCut12Tests' edge-seed tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:06:38 +02:00
}
var walked = new int[laneCount];
for (var li = 0; li < laneCount; li++)
{
Cut 12.3 fix batch: unify Lane and Lanes on one membership query Self's review found that commit 4e6495dd's fix for F1 replaced the per-lane forward-arithmetic defect with a second, still-independent membership computation: Lane's own half-open admission (Lo <= s < Hi, from a cell's own Extent) versus Lanes' ceil((cell.Lo - centreLateral) / laneSpacing) lane index. The two agree only when a cell's own (Hi - Lo) equals the externally supplied laneSpacing exactly in float -- the identical two-derivations trap Cut 12.2's own fix batch already found and removed from Lane itself, reintroduced one layer up. Confirmed reachable: on commit 4e6495dd, solid5 b=(0,1) s=0.49999997 n=1 leaves the ceil-based centre lane EMPTY while Lane(s) itself finds all 5 cells of the row -- breaking R3 ("the centre lane always meets metal", Commit's own guard) and the 12.2 placement contract. FireControl.Lanes is now built entirely out of FireControl.Lane: it calls Lane(hull, b, centreLateral + k * laneSpacing, ...) once per lane index, so Lane(s) is literally this function's n = 0 case rather than a parallel re-implementation of its rule. Commit's placement and Apply's damage split now read one function. The narrower question left -- can two individually correct Lane() calls, at two different lines, still walk the same cell? -- is resolved by ownership (the lane closest to the centre keeps a contested cell; the centre lane, k = 0, never loses one), not by a competing formula, so the centre lane's final cell set is always exactly Lane(centreLateral)'s own output. Lane() itself is unchanged (still called directly by Commit and by FireControlCut12Tests' edge-seed and interval-edge tests, all still green: HitsLandOnTheFacingEdge, LaneSeamIsHalfOpen, LaneNeverEmptiesAtAnIntervalEdge, Cut12-edge-68442709, Cut12-edge-64078070). Adds LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull: now that Lanes is a public, pure geometric query, the boundary sweep is a behavioural test of its own contract, no reflection -- over every cell's own shadow edge and float neighbours, at exact-axis, 45-degree, |bx| > |by| bearings and Soul's own two reproduction cases, spread 1-2: (a) no cell is walked by two lanes, (b) the centre lane equals a plain Lane(s) call, (c) the centre lane is never empty when the centre lateral is inside the hull's own shadow. Hand- confirmed to fail on 4e6495dd's ceil-based Lanes (property (b), the same input above) and to pass on this commit (fc123fix2-mut3.log). Adds CommittedCellIsTheFirstCellTheCentreLaneDamages: an end-to-end check through Fire -> Commit -> Apply (damageSpread 0, a single lane) that the committed outcome.Cell is the first cell Apply damages. Full suite: 286 tests, identical across 3 runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:24:55 +02:00
var w = 0;
for (var i = 0; i < rawCount[li]; i++)
Cut 12.3 fix batch: F1, decide lane membership once per cell Soul's second pass (CONFIRMED): even after the F2 shadow-width fix, Apply's per-lane loop still called Lane() once per lane index, each call re-deriving its own lateral s(li) = Lateral + (li - n) * laneSpacing by forward float addition, then testing that value against a cell's Extent interval independently of every other lane. Two lane lines are only exactly laneSpacing apart in real arithmetic; accumulating the offset by repeated addition can shift a side lane by an ulp or two, letting it land inside a neighbouring cell's half-open interval that its own unrounded value would never reach -- the same "two derivations of one quantity" defect Cut 12.2's own fix batch already removed from Lane itself, recurring one layer up in Apply. FireControl.Apply now calls a new Lanes() that decides each candidate cell's lane index with one computation: k = ceil((cell.Lo - centreLateral) / laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in the cell's own [Lo, Hi) -- derived once, from values no other lane's arithmetic has touched. A single Ceiling call returns exactly one integer, so a cell can be assigned to at most one lane by construction, not by float luck. When k == 0 this is the same condition Lane() tests for centreLateral itself, so the centre lane's cell set is unchanged (the 12.2 placement contract, HitsLandOnTheFacingEdge, still holds). laneSpacing is still used, unchanged, to place each admitted lane's own line for AlongBearing's slab solve -- geometry, not membership. Lane() itself is untouched (still used directly by Commit and by FireControlCut12Tests' edge-seed tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:06:38 +02:00
{
Cut 12.3 fix batch: unify Lane and Lanes on one membership query Self's review found that commit 4e6495dd's fix for F1 replaced the per-lane forward-arithmetic defect with a second, still-independent membership computation: Lane's own half-open admission (Lo <= s < Hi, from a cell's own Extent) versus Lanes' ceil((cell.Lo - centreLateral) / laneSpacing) lane index. The two agree only when a cell's own (Hi - Lo) equals the externally supplied laneSpacing exactly in float -- the identical two-derivations trap Cut 12.2's own fix batch already found and removed from Lane itself, reintroduced one layer up. Confirmed reachable: on commit 4e6495dd, solid5 b=(0,1) s=0.49999997 n=1 leaves the ceil-based centre lane EMPTY while Lane(s) itself finds all 5 cells of the row -- breaking R3 ("the centre lane always meets metal", Commit's own guard) and the 12.2 placement contract. FireControl.Lanes is now built entirely out of FireControl.Lane: it calls Lane(hull, b, centreLateral + k * laneSpacing, ...) once per lane index, so Lane(s) is literally this function's n = 0 case rather than a parallel re-implementation of its rule. Commit's placement and Apply's damage split now read one function. The narrower question left -- can two individually correct Lane() calls, at two different lines, still walk the same cell? -- is resolved by ownership (the lane closest to the centre keeps a contested cell; the centre lane, k = 0, never loses one), not by a competing formula, so the centre lane's final cell set is always exactly Lane(centreLateral)'s own output. Lane() itself is unchanged (still called directly by Commit and by FireControlCut12Tests' edge-seed and interval-edge tests, all still green: HitsLandOnTheFacingEdge, LaneSeamIsHalfOpen, LaneNeverEmptiesAtAnIntervalEdge, Cut12-edge-68442709, Cut12-edge-64078070). Adds LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull: now that Lanes is a public, pure geometric query, the boundary sweep is a behavioural test of its own contract, no reflection -- over every cell's own shadow edge and float neighbours, at exact-axis, 45-degree, |bx| > |by| bearings and Soul's own two reproduction cases, spread 1-2: (a) no cell is walked by two lanes, (b) the centre lane equals a plain Lane(s) call, (c) the centre lane is never empty when the centre lateral is inside the hull's own shadow. Hand- confirmed to fail on 4e6495dd's ceil-based Lanes (property (b), the same input above) and to pass on this commit (fc123fix2-mut3.log). Adds CommittedCellIsTheFirstCellTheCentreLaneDamages: an end-to-end check through Fire -> Commit -> Apply (damageSpread 0, a single lane) that the committed outcome.Cell is the first cell Apply damages. Full suite: 286 tests, identical across 3 runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:24:55 +02:00
if (ownerLane[raw[li][i].Cell] != li) continue; // ceded to a closer lane -- this lane's own gap
if (w > 0 && raw[li][i].Entry > buffers[li][w - 1].Exit + 1e-4f) break;
buffers[li][w++] = raw[li][i];
Cut 12.3 fix batch: F1, decide lane membership once per cell Soul's second pass (CONFIRMED): even after the F2 shadow-width fix, Apply's per-lane loop still called Lane() once per lane index, each call re-deriving its own lateral s(li) = Lateral + (li - n) * laneSpacing by forward float addition, then testing that value against a cell's Extent interval independently of every other lane. Two lane lines are only exactly laneSpacing apart in real arithmetic; accumulating the offset by repeated addition can shift a side lane by an ulp or two, letting it land inside a neighbouring cell's half-open interval that its own unrounded value would never reach -- the same "two derivations of one quantity" defect Cut 12.2's own fix batch already removed from Lane itself, recurring one layer up in Apply. FireControl.Apply now calls a new Lanes() that decides each candidate cell's lane index with one computation: k = ceil((cell.Lo - centreLateral) / laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in the cell's own [Lo, Hi) -- derived once, from values no other lane's arithmetic has touched. A single Ceiling call returns exactly one integer, so a cell can be assigned to at most one lane by construction, not by float luck. When k == 0 this is the same condition Lane() tests for centreLateral itself, so the centre lane's cell set is unchanged (the 12.2 placement contract, HitsLandOnTheFacingEdge, still holds). laneSpacing is still used, unchanged, to place each admitted lane's own line for AlongBearing's slab solve -- geometry, not membership. Lane() itself is untouched (still used directly by Commit and by FireControlCut12Tests' edge-seed tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:06:38 +02:00
}
Cut 12.3 fix batch: unify Lane and Lanes on one membership query Self's review found that commit 4e6495dd's fix for F1 replaced the per-lane forward-arithmetic defect with a second, still-independent membership computation: Lane's own half-open admission (Lo <= s < Hi, from a cell's own Extent) versus Lanes' ceil((cell.Lo - centreLateral) / laneSpacing) lane index. The two agree only when a cell's own (Hi - Lo) equals the externally supplied laneSpacing exactly in float -- the identical two-derivations trap Cut 12.2's own fix batch already found and removed from Lane itself, reintroduced one layer up. Confirmed reachable: on commit 4e6495dd, solid5 b=(0,1) s=0.49999997 n=1 leaves the ceil-based centre lane EMPTY while Lane(s) itself finds all 5 cells of the row -- breaking R3 ("the centre lane always meets metal", Commit's own guard) and the 12.2 placement contract. FireControl.Lanes is now built entirely out of FireControl.Lane: it calls Lane(hull, b, centreLateral + k * laneSpacing, ...) once per lane index, so Lane(s) is literally this function's n = 0 case rather than a parallel re-implementation of its rule. Commit's placement and Apply's damage split now read one function. The narrower question left -- can two individually correct Lane() calls, at two different lines, still walk the same cell? -- is resolved by ownership (the lane closest to the centre keeps a contested cell; the centre lane, k = 0, never loses one), not by a competing formula, so the centre lane's final cell set is always exactly Lane(centreLateral)'s own output. Lane() itself is unchanged (still called directly by Commit and by FireControlCut12Tests' edge-seed and interval-edge tests, all still green: HitsLandOnTheFacingEdge, LaneSeamIsHalfOpen, LaneNeverEmptiesAtAnIntervalEdge, Cut12-edge-68442709, Cut12-edge-64078070). Adds LanesQueryIsDisjointMatchesLaneAtCentreAndNeverEmptyOnHull: now that Lanes is a public, pure geometric query, the boundary sweep is a behavioural test of its own contract, no reflection -- over every cell's own shadow edge and float neighbours, at exact-axis, 45-degree, |bx| > |by| bearings and Soul's own two reproduction cases, spread 1-2: (a) no cell is walked by two lanes, (b) the centre lane equals a plain Lane(s) call, (c) the centre lane is never empty when the centre lateral is inside the hull's own shadow. Hand- confirmed to fail on 4e6495dd's ceil-based Lanes (property (b), the same input above) and to pass on this commit (fc123fix2-mut3.log). Adds CommittedCellIsTheFirstCellTheCentreLaneDamages: an end-to-end check through Fire -> Commit -> Apply (damageSpread 0, a single lane) that the committed outcome.Cell is the first cell Apply damages. Full suite: 286 tests, identical across 3 runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:24:55 +02:00
walked[li] = w;
ArrayPool<LaneCell>.Shared.Return(raw[li]);
Cut 12.3 fix batch: F1, decide lane membership once per cell Soul's second pass (CONFIRMED): even after the F2 shadow-width fix, Apply's per-lane loop still called Lane() once per lane index, each call re-deriving its own lateral s(li) = Lateral + (li - n) * laneSpacing by forward float addition, then testing that value against a cell's Extent interval independently of every other lane. Two lane lines are only exactly laneSpacing apart in real arithmetic; accumulating the offset by repeated addition can shift a side lane by an ulp or two, letting it land inside a neighbouring cell's half-open interval that its own unrounded value would never reach -- the same "two derivations of one quantity" defect Cut 12.2's own fix batch already removed from Lane itself, recurring one layer up in Apply. FireControl.Apply now calls a new Lanes() that decides each candidate cell's lane index with one computation: k = ceil((cell.Lo - centreLateral) / laneSpacing) is the unique integer for which centreLateral + k * laneSpacing falls in the cell's own [Lo, Hi) -- derived once, from values no other lane's arithmetic has touched. A single Ceiling call returns exactly one integer, so a cell can be assigned to at most one lane by construction, not by float luck. When k == 0 this is the same condition Lane() tests for centreLateral itself, so the centre lane's cell set is unchanged (the 12.2 placement contract, HitsLandOnTheFacingEdge, still holds). laneSpacing is still used, unchanged, to place each admitted lane's own line for AlongBearing's slab solve -- geometry, not membership. Lane() itself is untouched (still used directly by Commit and by FireControlCut12Tests' edge-seed tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:06:38 +02:00
}
return walked;
}
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
private static ShotOutcome MakeOutcome(PendingShot shot, ShotResult result, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now)
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
{
return new ShotOutcome
{
ShotId = shot.ShotId,
Source = shot.Source,
Target = shot.Target,
Weapon = shot.Weapon,
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
Result = result,
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
Shielded = shielded,
Fire control Cut 5: close the rules that landed on one path and not its twin Soul's pass over the whole campaign confirmed eleven findings. Seven were code defects of one shape -- a rule implemented on one path and left off the path beside it -- and this is all seven. 5.1 Tracking falls back to GameplaySettings.UnaidedTracking (new, 10) instead of zero, and Commit drops the zero branch that turned a Tracking-less shooter into a hard wall. Unaided fire was meant to be really, really bad; it was 334 shots and no hits against a target drifting at 5 u/s. 5.2 A shot the shield cannot absorb now breaks it. The decision is frozen into ShotOutcome.ShieldBroken at commit and performed in Apply, so a shield that recharges mid-flight cannot retroactively survive. Splash already did this; the discrete roll never did, which made the whole shield-break mechanic unreachable for every weapon in the catalog. 5.3 ConstantWeapon gates on ArcAllowsFire alongside StanceAllowsFire. A side-mounted beam was firing forward. 5.4 The point-blank bearing case moves out of Weapon.ArcAllowsFire and into InArc, which used to normalize the zero vector into NaN. The trigger said fire and the probability said impossible. 5.5 A destroyed item drops out of IsRevealed's ranking, so Precision stops concentrating hits onto a dead subsystem's footprint. 5.6 Zone subscribes Entity.Death and removes the corpse itself. That removal lived only in Unity's loot-drop subscription, so headless nothing ever left the zone, shots landed on corpses and no fight could end. 5.7 PendingShot drops PredictedIntercept and FlightTime, written and read nowhere. 208 tests pass (197 plus eleven new). Known defect, recorded rather than hidden: the committed mutation harness is unreliable. Across two runs it failed to restore FireControl.cs and Weapon.cs, leaving live mutations in the working tree -- including its own 5.1 mutation, which is how a green suite briefly reported two failures. Its verdicts are therefore evidence only where re-run from a verified-clean tree. Two anchors (Zone.cs's ObserveAdd line, Weapon.cs's InArc return) fail to match at all, and 5.3's ConstantWeaponObeysArc survives its own mutation, so that rule is written but not pinned. Cut 5b owns the repair; nothing here is trusted on the harness's word, and the suite result above was taken by hand from a hash-verified tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:56:40 +02:00
ShieldBroken = shieldBroken,
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
Cell = cell,
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
Bearing = bearing,
Lateral = lateral,
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
ArrivalIn = max(0f, shot.ArrivalTime - now),
DamageType = shot.DamageType
};
}
// 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.
//
// 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):
// take Ps = ToSchematicPoint(P) and rCells = radius / SchematicCellSize once, then for every occupied hull
// cell whose unit square meets the disc, share_c = damage * overlap(c) / (pi * rCells^2) -- the exact area
// of the disc inside that cell's unit square, over the disc's own area. Every share that falls on an
// unoccupied cell, or outside the schematic, is lost (TheShareOffTheHullIsLost); nothing renormalises over
// the covered cells.
//
// Every shield decision for a blast is made here, live, charged the entity's summed shares under today's
// CanTakeHit/Break/TakeHit rules (Q12-9 = A: the host of a contact or delayed blast decides its shield here
// too, never at Commit). If the shield absorbs, no cell of that entity takes anything; if it cannot, it
// breaks and the cells take their shares.
//
// Armour protects only its own cell's share (ArmorProtectsOnlyItsOwnCellsShare) -- "armour absorbs first"
// runs per cell, in hull Coordinates order. Only once every covered cell has deposited does an item resolve
// its own pool, once (AMultiCellItemAbsorbsItsCoveredCellsAsOnePool): no per-cell item threshold survives
// anywhere, because a disc's schedule has no walk -- no cell's share depends on another
// cell's leftover, so every deposit is known before any item resolves and there is nothing for a
// reachability test or a cycle rule to decide (unlike ApplyPooled's lane walk, which this function does not
// call: doing so would buy no behaviour, per ProbeC in the cut doc). Leftovers -- unoccupied-cell shares,
// and whatever an item's pool does not absorb -- join one entity-wide hull total, applied through one
// DamageHull call.
public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType)
Fire control Cut 4, part 1: FireControl gains Splash and a beam roll FireControl.Fire takes an optional damageOverride so a continuous weapon can queue a shot for less than its full Damage. FireControl.Splash is the one new rule this cut adds: an unconditional area effect (mines, airburst) applying the shield-absorb-or-hull-takes-it branch per target, then DamageSchematic over the directional half of each 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^). ConstantWeapon.Execute rolls once per GameplaySettings.BeamResolveInterval (new setting) for Damage*interval, through the same FireControl.Fire/Step pair a discrete shot uses -- a beam's authored Velocity is 0, so the flight commits and resolves in the same tick it fires (R4's short-flight degradation). A beam is a sequence of rolls, not a continuous truth. Fixes a real defect found while testing the above: both InstantWeapon.Execute (Cut 3) and the new ConstantWeapon.Execute wrote `OnFire?.Invoke(FireControl. Fire(...))` inline. C#'s null-conditional short-circuits the whole expression, argument included, whenever the event has no subscriber -- true of any headless run with no Unity EntityInstance wired up (docs/headless-playground-cut.md) -- so a shot silently never fired. Both now evaluate FireControl.Fire into a local before the null-conditional invoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:16:57 +02:00
{
// No distance prefilter: RectDiskOverlap is the one decider of whether the disc touches a hull. A
// prefilter measured from Position (the centre of mass) with the bounding box's half-diagonal is not a
// bound on how far a hull's cells reach from it -- it culled real overlap on an L-shaped hull and on the
// shipped Longinus corners. A nonpositive radius needs no guard either: the overlap of a disc with no
// area is zero for every cell, so nothing is covered and nothing is delivered.
//
// The pass runs over a snapshot: a lethal blast fires an entity's Death, which removes it from
// Zone.Entities, and the live enumeration threw on the next MoveNext (the same idiom Zone.Update uses).
foreach (var entity in zone.Entities.ToArray())
Fire control Cut 4, part 1: FireControl gains Splash and a beam roll FireControl.Fire takes an optional damageOverride so a continuous weapon can queue a shot for less than its full Damage. FireControl.Splash is the one new rule this cut adds: an unconditional area effect (mines, airburst) applying the shield-absorb-or-hull-takes-it branch per target, then DamageSchematic over the directional half of each 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^). ConstantWeapon.Execute rolls once per GameplaySettings.BeamResolveInterval (new setting) for Damage*interval, through the same FireControl.Fire/Step pair a discrete shot uses -- a beam's authored Velocity is 0, so the flight commits and resolves in the same tick it fires (R4's short-flight degradation). A beam is a sequence of rolls, not a continuous truth. Fixes a real defect found while testing the above: both InstantWeapon.Execute (Cut 3) and the new ConstantWeapon.Execute wrote `OnFire?.Invoke(FireControl. Fire(...))` inline. C#'s null-conditional short-circuits the whole expression, argument included, whenever the event has no subscriber -- true of any headless run with no Unity EntityInstance wired up (docs/headless-playground-cut.md) -- so a shot silently never fired. Both now evaluate FireControl.Fire into a local before the null-conditional invoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:16:57 +02:00
{
var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize;
var hullData = entity.ItemManager.GetData(entity.Hull) as HullData;
var shape = hullData.Shape;
var centre = entity.ToSchematicPoint(worldPlanar);
var rCells = radius / cellSize;
var normaliser = PI * rCells * rCells;
// Shares, computed once, in hull Coordinates order -- the same order armour absorbs in below and
// an item's pool deposits in.
var coords = shape.Coordinates;
List<(int2 Cell, float Share)> covered = null;
var totalShare = 0f;
for (var i = 0; i < coords.Length; i++)
{
var overlap = CircleSquareOverlap(centre, rCells, coords[i]);
if (overlap <= 0f) continue;
var share = damage * overlap / normaliser;
(covered ??= new List<(int2 Cell, float Share)>()).Add((coords[i], share));
totalShare += share;
}
// An entity the disc does not touch has nothing to decide: no shield is consulted, nothing is dealt.
if (covered == null) continue;
Fire control Cut 4, part 1: FireControl gains Splash and a beam roll FireControl.Fire takes an optional damageOverride so a continuous weapon can queue a shot for less than its full Damage. FireControl.Splash is the one new rule this cut adds: an unconditional area effect (mines, airburst) applying the shield-absorb-or-hull-takes-it branch per target, then DamageSchematic over the directional half of each 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^). ConstantWeapon.Execute rolls once per GameplaySettings.BeamResolveInterval (new setting) for Damage*interval, through the same FireControl.Fire/Step pair a discrete shot uses -- a beam's authored Velocity is 0, so the flight commits and resolves in the same tick it fires (R4's short-flight degradation). A beam is a sequence of rolls, not a continuous truth. Fixes a real defect found while testing the above: both InstantWeapon.Execute (Cut 3) and the new ConstantWeapon.Execute wrote `OnFire?.Invoke(FireControl. Fire(...))` inline. C#'s null-conditional short-circuits the whole expression, argument included, whenever the event has no subscriber -- true of any headless run with no Unity EntityInstance wired up (docs/headless-playground-cut.md) -- so a shot silently never fired. Both now evaluate FireControl.Fire into a local before the null-conditional invoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:16:57 +02:00
var shield = entity.Shield;
Fire control Cut 4, part 1: FireControl gains Splash and a beam roll FireControl.Fire takes an optional damageOverride so a continuous weapon can queue a shot for less than its full Damage. FireControl.Splash is the one new rule this cut adds: an unconditional area effect (mines, airburst) applying the shield-absorb-or-hull-takes-it branch per target, then DamageSchematic over the directional half of each 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^). ConstantWeapon.Execute rolls once per GameplaySettings.BeamResolveInterval (new setting) for Damage*interval, through the same FireControl.Fire/Step pair a discrete shot uses -- a beam's authored Velocity is 0, so the flight commits and resolves in the same tick it fires (R4's short-flight degradation). A beam is a sequence of rolls, not a continuous truth. Fixes a real defect found while testing the above: both InstantWeapon.Execute (Cut 3) and the new ConstantWeapon.Execute wrote `OnFire?.Invoke(FireControl. Fire(...))` inline. C#'s null-conditional short-circuits the whole expression, argument included, whenever the event has no subscriber -- true of any headless run with no Unity EntityInstance wired up (docs/headless-playground-cut.md) -- so a shot silently never fired. Both now evaluate FireControl.Fire into a local before the null-conditional invoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:16:57 +02:00
var shieldActive = shield != null && shield.Item.Active.Value;
if (shieldActive)
Fire control Cut 4, part 1: FireControl gains Splash and a beam roll FireControl.Fire takes an optional damageOverride so a continuous weapon can queue a shot for less than its full Damage. FireControl.Splash is the one new rule this cut adds: an unconditional area effect (mines, airburst) applying the shield-absorb-or-hull-takes-it branch per target, then DamageSchematic over the directional half of each 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^). ConstantWeapon.Execute rolls once per GameplaySettings.BeamResolveInterval (new setting) for Damage*interval, through the same FireControl.Fire/Step pair a discrete shot uses -- a beam's authored Velocity is 0, so the flight commits and resolves in the same tick it fires (R4's short-flight degradation). A beam is a sequence of rolls, not a continuous truth. Fixes a real defect found while testing the above: both InstantWeapon.Execute (Cut 3) and the new ConstantWeapon.Execute wrote `OnFire?.Invoke(FireControl. Fire(...))` inline. C#'s null-conditional short-circuits the whole expression, argument included, whenever the event has no subscriber -- true of any headless run with no Unity EntityInstance wired up (docs/headless-playground-cut.md) -- so a shot silently never fired. Both now evaluate FireControl.Fire into a local before the null-conditional invoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:16:57 +02:00
{
if (shield.CanTakeHit(damageType, totalShare))
{
PublishBlast(entity, worldPlanar, AbsorbLayer.Shield, totalShare, damageType);
shield.TakeHit(damageType, totalShare);
continue;
}
shield.Break();
Fire control Cut 4, part 1: FireControl gains Splash and a beam roll FireControl.Fire takes an optional damageOverride so a continuous weapon can queue a shot for less than its full Damage. FireControl.Splash is the one new rule this cut adds: an unconditional area effect (mines, airburst) applying the shield-absorb-or-hull-takes-it branch per target, then DamageSchematic over the directional half of each 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^). ConstantWeapon.Execute rolls once per GameplaySettings.BeamResolveInterval (new setting) for Damage*interval, through the same FireControl.Fire/Step pair a discrete shot uses -- a beam's authored Velocity is 0, so the flight commits and resolves in the same tick it fires (R4's short-flight degradation). A beam is a sequence of rolls, not a continuous truth. Fixes a real defect found while testing the above: both InstantWeapon.Execute (Cut 3) and the new ConstantWeapon.Execute wrote `OnFire?.Invoke(FireControl. Fire(...))` inline. C#'s null-conditional short-circuits the whole expression, argument included, whenever the event has no subscriber -- true of any headless run with no Unity EntityInstance wired up (docs/headless-playground-cut.md) -- so a shot silently never fired. Both now evaluate FireControl.Fire into a local before the null-conditional invoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:16:57 +02:00
}
// Armour first, per cell; an item's covered cells pool their post-armour deposits and resolve once,
// from the pooled sum -- ItemAbsorb's own degenerate case for a pool of one covers a single-cell
// item, exactly as it does in ApplyPooled.
Dictionary<EquippedItem, List<float>> itemPools = null;
var hullTotal = 0f;
foreach (var (cell, share) in covered)
{
var postArmor = entity.ArmorAbsorb(cell, share);
var item = entity.GearOccupancy[cell.x, cell.y];
if (item == null) { hullTotal += postArmor; continue; }
itemPools ??= new Dictionary<EquippedItem, List<float>>();
if (!itemPools.TryGetValue(item, out var pool)) itemPools[item] = pool = new List<float>();
pool.Add(postArmor);
}
if (itemPools != null)
foreach (var (item, pool) in itemPools)
{
var amounts = pool.ToArray();
entity.ItemAbsorb(item, amounts);
for (var i = 0; i < amounts.Length; i++) hullTotal += amounts[i];
}
PublishBlast(entity, worldPlanar, AbsorbLayer.Hull, totalShare, damageType);
entity.DamageHull(hullTotal);
}
}
// 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.
public static bool Touches(Entity entity, float2 worldPlanar, float radius)
{
var rCells = radius / entity.ItemManager.GameplaySettings.SchematicCellSize;
var centre = entity.ToSchematicPoint(worldPlanar);
var coords = (entity.ItemManager.GetData(entity.Hull) as HullData).Shape.Coordinates;
for (var i = 0; i < coords.Length; i++)
if (CircleSquareOverlap(centre, rCells, coords[i]) > 0f) return true;
return false;
}
// 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
// (schematic-cell) units. Allocates nothing.
private static float CircleSquareOverlap(float2 diskCentre, float r, int2 cell)
{
var xlo = cell.x - .5f - diskCentre.x;
var xhi = cell.x + .5f - diskCentre.x;
var ylo = cell.y - .5f - diskCentre.y;
var yhi = cell.y + .5f - diskCentre.y;
return RectDiskOverlap(xlo, xhi, ylo, yhi, r);
}
// The area of intersection of a disc of radius r centred at the origin with the axis-aligned rectangle
// [xlo,xhi] x [ylo,yhi]. Integrates the disc's chord height over the rectangle's x-span: for a fixed x, the
// disc spans y in [-h,h] with h = sqrt(r^2 - x^2), and the covered length there is
// min(yhi,h) - max(ylo,-h), clamped to zero. That covered length changes formula only where h(x) crosses
// ylo or yhi (or at the rectangle's own edges), so the span is cut at those breakpoints and each piece is
// integrated in closed form with AntiderivSqrt, the antiderivative of sqrt(r^2 - x^2).
private static float RectDiskOverlap(float xlo, float xhi, float ylo, float yhi, float r)
{
if (r <= 0f) return 0f;
xlo = max(xlo, -r);
xhi = min(xhi, r);
if (xlo >= xhi) return 0f;
Span<float> bp = stackalloc float[6];
var n = 0;
bp[n++] = xlo;
bp[n++] = xhi;
// The breakpoints where h(x) = sqrt(r^2 - x^2) crosses ylo or yhi -- the only places the covered
// length's formula (below) can change. No local function: a stackalloc Span cannot be captured by one.
Span<float> ys = stackalloc float[2] { ylo, yhi };
for (var k = 0; k < 2; k++)
{
var y = ys[k];
if (abs(y) >= r) continue;
var xc = sqrt(r * r - y * y);
if (-xc > xlo && -xc < xhi) bp[n++] = -xc;
if (xc > xlo && xc < xhi) bp[n++] = xc;
Fire control Cut 4, part 1: FireControl gains Splash and a beam roll FireControl.Fire takes an optional damageOverride so a continuous weapon can queue a shot for less than its full Damage. FireControl.Splash is the one new rule this cut adds: an unconditional area effect (mines, airburst) applying the shield-absorb-or-hull-takes-it branch per target, then DamageSchematic over the directional half of each 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^). ConstantWeapon.Execute rolls once per GameplaySettings.BeamResolveInterval (new setting) for Damage*interval, through the same FireControl.Fire/Step pair a discrete shot uses -- a beam's authored Velocity is 0, so the flight commits and resolves in the same tick it fires (R4's short-flight degradation). A beam is a sequence of rolls, not a continuous truth. Fixes a real defect found while testing the above: both InstantWeapon.Execute (Cut 3) and the new ConstantWeapon.Execute wrote `OnFire?.Invoke(FireControl. Fire(...))` inline. C#'s null-conditional short-circuits the whole expression, argument included, whenever the event has no subscriber -- true of any headless run with no Unity EntityInstance wired up (docs/headless-playground-cut.md) -- so a shot silently never fired. Both now evaluate FireControl.Fire into a local before the null-conditional invoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:16:57 +02:00
}
// Insertion sort: n is at most 6.
for (var i = 1; i < n; i++)
{
var key = bp[i];
var j = i - 1;
while (j >= 0 && bp[j] > key) { bp[j + 1] = bp[j]; j--; }
bp[j + 1] = key;
}
var area = 0f;
for (var i = 0; i < n - 1; i++)
{
var a = bp[i];
var b = bp[i + 1];
if (b - a < 1e-9f) continue;
var mid = .5f * (a + b);
var h = sqrt(max(0f, r * r - mid * mid));
// Inclusive: a rectangle edge exactly tangent to the disc (h == yhi at a piece whose midpoint is 0)
// is the disc's own boundary, so the chord is the true bound there. A strict test sent that piece to
// the whole-rectangle branch and counted the square in full -- a boundary flip that creates damage.
var upperIsDisc = h <= yhi;
var lowerIsDisc = -h >= ylo;
var upper = upperIsDisc ? h : yhi;
var lower = lowerIsDisc ? -h : ylo;
if (upper <= lower) continue;
float piece;
if (upperIsDisc && lowerIsDisc)
piece = 2f * (AntiderivSqrt(b, r) - AntiderivSqrt(a, r));
else if (upperIsDisc)
piece = (AntiderivSqrt(b, r) - AntiderivSqrt(a, r)) - ylo * (b - a);
else if (lowerIsDisc)
piece = yhi * (b - a) + (AntiderivSqrt(b, r) - AntiderivSqrt(a, r));
else
piece = (yhi - ylo) * (b - a);
area += piece;
}
return max(0f, area);
}
// The antiderivative of sqrt(r^2 - x^2): x/2 * sqrt(r^2 - x^2) + r^2/2 * asin(x/r). Clamped into [-r,r]
// first -- every caller here already clips its integration bounds into that range, so this is a guard
// against float rounding at the boundary, not a second clipping rule.
private static float AntiderivSqrt(float x, float r)
{
var xc = clamp(x, -r, r);
return .5f * (xc * sqrt(max(0f, r * r - xc * xc)) + r * r * asin(xc / r));
Fire control Cut 4, part 1: FireControl gains Splash and a beam roll FireControl.Fire takes an optional damageOverride so a continuous weapon can queue a shot for less than its full Damage. FireControl.Splash is the one new rule this cut adds: an unconditional area effect (mines, airburst) applying the shield-absorb-or-hull-takes-it branch per target, then DamageSchematic over the directional half of each 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^). ConstantWeapon.Execute rolls once per GameplaySettings.BeamResolveInterval (new setting) for Damage*interval, through the same FireControl.Fire/Step pair a discrete shot uses -- a beam's authored Velocity is 0, so the flight commits and resolves in the same tick it fires (R4's short-flight degradation). A beam is a sequence of rolls, not a continuous truth. Fixes a real defect found while testing the above: both InstantWeapon.Execute (Cut 3) and the new ConstantWeapon.Execute wrote `OnFire?.Invoke(FireControl. Fire(...))` inline. C#'s null-conditional short-circuits the whole expression, argument included, whenever the event has no subscriber -- true of any headless run with no Unity EntityInstance wired up (docs/headless-playground-cut.md) -- so a shot silently never fired. Both now evaluate FireControl.Fire into a local before the null-conditional invoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:16:57 +02:00
}
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Cut 12.2 (docs/fire-control-cut.md): Sigma keeps its Cut 6d/9.2 rule unchanged -- the frozen Precision's
// reciprocal, in hull-schematic cell units, floored at half a cell. The 9.2 floor's old rationale ("the
// discrete sum undershoots the continuous integral") is retired: Silhouette below integrates the Gaussian
// exactly over the shadow's intervals, so an exact 1D integral does not undershoot. The floor survives as
// a design minimum on group tightness (F12-3, TheSigmaFloorHoldsAtHalfACell) -- a targeting system is never
// authored tighter than half a schematic cell's width of spread, regardless of what the exact integral
// would otherwise allow.
Cut 9: the die is not a die 9.1, blocking. FireControl seeded CultMath's xorshift32 per shot and drew once. NextFloat returns the top 24 bits of the FIRST round, and one round cannot carry ShotId's low-bit variation into the high bits -- so every zone's hit roll spanned about an eighth, with the threshold fixed at zone creation. A shot was a step function of p: below the band it never hit, above it always did. An unaided shooter hit 0 of 18,926 shots. That is the operator's "no hits registering," and being stable per zone is why it came and went. The seed derivation is unchanged -- still a pure function of (zone, shot id), Cut 6b's rule -- and only its diffusion into the first output changes, via an fmix32 finalizer. Measured across six zone seeds: hit fraction at p=.3 went from 0.0% in five of six zones to 29-31%, and at p=.6 to 58-61%, with the roll spanning the full range. The defect survived three cuts because nothing in the campaign measured a distribution -- every fixture uses accuracy 1 or 0, and a constant satisfies a determinism test perfectly. TheDieIsUniform now asserts the observed hit fraction is within .08 of p across several zone seeds, which is the test whose absence was the real defect. 9.2. Sigma is floored at half a cell. HullKernel approximates a continuous Gaussian with a discrete sum over occupied cells, and that sum collapses once sigma falls well under one cell against an aim point that is not exactly on a cell centre -- true of every shipped hull. Past Precision ~4, pOnHull went to zero and a better targeting system made the weapon unable to hit anything. 9.3. An active entity's Target is now always a live entity it has info on. Cut 8's _active guard could never have fixed the operator's LockWeapon crash -- that code only runs on active entities, and cut8's own harness header says so. The live route was dock, target dies, undock; Deactivate disposed the subscription that nulls a stale Target and Activate reseeded EntityInfoGathered without reconciling it. Still not a TryGetValue in LockWeapon. 229 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:33:22 +02:00
private const float SigmaFloor = 0.5f;
private static float Sigma(float precision) => max(1f / max(precision, 1e-3f), SigmaFloor);
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
// The aim point a silhouette is centred on (R10/Q6: the same frozen Aimed and AimedMount both HitProbability and Fire
// read) -- the cells of the mount aimed at when the aim was frozen at a mount (the shooter has not learned what is
// fitted there, so never the fitted item's cells), else the aimed item's own cell centroid when it currently occupies
// cells on the target, else the hull's own centre of mass. One path; the uniform-random branch Cut 6d replaced stays deleted.
private static float2 ResolveAimPoint(Entity target, HullData hullData, EquippedItem aimed, HardpointData mount)
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
{
var aimedCells = mount != null ? MountCells(mount) : aimed == null ? null : CellsOf(target, aimed);
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
if (aimedCells != null && aimedCells.Length > 0)
return aimedCells.Aggregate(float2.zero, (total, c) => total + (float2) c) / aimedCells.Length;
return hullData.Shape.CenterOfMass;
}
// Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): the merged lateral shadow the
// hull casts across the bearing, and the exact 1D Gaussian mass it carries -- the one function HitProbability's
// forecast, Inspect's HUD factors and Commit's roll and placement all read. Nowhere else may compute a
// sigma, a projection or a bearing. Allocates one interval array of length N (occupied-cell count, <=128 on
// shipped hulls) plus a sort -- only on this, the gated-in, path (Cut 10's own gated-out guard is
// unaffected: HitProbability and CommitProbability both bail on a zero shooter/deviation factor before
// this is ever called).
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
// Cost fix batch (Self, 2026-09-24): `buffer`, when supplied, replaces the `new Interval[coords.Length]`
// allocation -- the caller owns renting and returning it (ArrayPool<Interval>.Shared, sized to at least
// coords.Length). Only HitProbability's and Inspect's forecast reads POnHull/Span and is done with the
// array before returning, so only they pool it; Commit keeps its own array alive across the LateralDraw
// call that follows, so it still passes null and gets an owned allocation. A caller-supplied buffer may be
// larger than coords.Length (ArrayPool rents "at least"), so every loop below is bounded by coords.Length,
// never buffer.Length.
public static Silhouette Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision, Interval[] buffer = null, HardpointData mount = null)
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
{
var ell = Lateral(bearing);
var coords = hull.Shape.Coordinates;
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
var intervals = buffer ?? new Interval[coords.Length];
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
for (var i = 0; i < coords.Length; i++)
intervals[i] = Extent((float2) coords[i], ell);
Array.Sort(intervals, 0, coords.Length);
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Merge in place: sorted ascending by Lo, so an interval overlaps (or exactly abuts) the last kept
// interval whenever its own Lo does not exceed that interval's Hi.
var count = 0;
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
for (var i = 0; i < coords.Length; i++)
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
{
if (count > 0 && intervals[i].Lo <= intervals[count - 1].Hi)
intervals[count - 1].Hi = max(intervals[count - 1].Hi, intervals[i].Hi);
else
intervals[count++] = intervals[i];
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
}
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
var sigma = Sigma(precision);
var aimPoint = ResolveAimPoint(target, hull, aimed, mount);
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
var a = dot(aimPoint, ell);
var pOnHull = 0f;
for (var i = 0; i < count; i++)
pOnHull += Phi((intervals[i].Hi - a) / sigma) - Phi((intervals[i].Lo - a) / sigma);
var span = count > 0 ? intervals[count - 1].Hi - intervals[0].Lo : 0f;
return new Silhouette
{
Intervals = intervals,
Count = count,
AimPoint = aimPoint,
A = a,
Sigma = sigma,
Span = span,
POnHull = saturate(pOnHull)
};
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
}
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Phi(z) = 1/2 (1 + erf(z / sqrt(2))): the standard normal CDF, CultMath's erf the one function underneath
// every Gaussian-mass sum this cut computes.
private static float Phi(float z) => .5f * (1f + erf(z * (1f / SQRT2)));
private const float SQRT2 = 1.4142135f;
// Cut 12.2: the lateral draw. One NextFloat u, taken after the hit roll -- walks the cumulative interval
// mass to u * POnHull to select interval k, then inverts the Gaussian CDF inside it. `p` here is the
// absolute CDF value at the drawn point (Phi(lo_k) plus the leftover target mass within interval k), not
// a value renormalised to [0,1] -- inverting that gives s directly in the same units A and Sigma are in.
// Clamped into [lo_k, hi_k) at the end to absorb rounding (a guard, not an invariant); erfinv's own
// handling of the domain edges (+-infinity at p = 0 or 1) means the clamp is what actually keeps s finite
// for an interval with negligible mass.
private static float LateralDraw(Silhouette sil, float u)
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
{
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
var target = u * sil.POnHull;
var cumulative = 0f;
for (var k = 0; k < sil.Count; k++)
{
var interval = sil.Intervals[k];
var loValue = Phi((interval.Lo - sil.A) / sil.Sigma);
var hiValue = Phi((interval.Hi - sil.A) / sil.Sigma);
var mass = hiValue - loValue;
if (target <= cumulative + mass || k == sil.Count - 1)
{
var p = clamp(loValue + (target - cumulative), loValue, hiValue);
var s = sil.A + sil.Sigma * SQRT2 * erfinv(2f * p - 1f);
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
// Half-open [lo_k, hi_k), matching the shadow interval itself ("Each occupied cell c projects
// to [dot(c,ell)-h, dot(c,ell)+h)"). A draw that rounds to exactly Hi is a real, reachable
// float value (Soul found 3 of them enumerating all 2^24 NextFloat outputs on the
// TurningArmour geometry) -- clamping to the closed [Lo, Hi] let s == Hi through, which Lane's
// own half-open filter (`s >= centre + h` continues) then rejects for every cell, returning an
// empty lane for a shot that already passed its roll (R3 violation). The epsilon matches
// Lane's own contiguity tolerance; every merged interval is at least one cell's shadow width
// wide (>= 2h), far above it.
var hiExclusive = max(interval.Lo, interval.Hi - 1e-4f);
return clamp(s, interval.Lo, hiExclusive);
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
}
cumulative += mass;
}
return sil.A; // sil.Count == 0: no metal in the shadow at all -- unreachable while POnHull > 0 gated Commit's roll
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
}
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// Every occupied cell the line meets, sorted by entry, in the pooled buffer; returns how many. Lane below
// walks the contiguous run from the first; a caller that needs the whole line (a contact fuse looking for
// the first cell ahead of its origin) reads all of them. One admission test either way.
private static int CellsOnLine(HullData hull, float2 b, float s, LaneCell[] buffer)
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
{
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
var ell = Lateral(b);
var coords = hull.Shape.Coordinates;
var n = 0;
for (var i = 0; i < coords.Length; i++)
{
var c = (float2) coords[i];
var lateral = Extent(c, ell);
if (s < lateral.Lo || s >= lateral.Hi) continue; // half-open, the same admission Silhouette sums over
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
// F9 (Soul's fix batch, 2026-09-24): Projection (dot(cell, b)) is precomputed here, into the
// struct, so the sort below needs no closure over `b`. S3 fix batch: the sort itself now uses
// LaneCell's own IComparable<LaneCell>, not a per-call Comparer<T>.Create allocation the "never
// allocates" comment used to contradict. Projection is also the "along-bearing projection" the cut
// doc orders entry by.
AlongBearing(c, b, ell, s, out var entry, out var exit);
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
buffer[n++] = new LaneCell { Cell = coords[i], Entry = entry, Exit = exit, Projection = dot(c, b) };
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
}
Array.Sort(buffer, 0, n);
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
return n;
}
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): exact slab traversal along the
// bearing at a fixed lateral offset s, replacing the old 0.5-step sampling march. Collects the occupied
// cells whose shadow interval contains s, each with its own entry/exit parameter along b, ordered by
// entry (ties broken by dot(cell, b), then cell index for full determinism), then walks forward while
// consecutive cells are contiguous (`next.entry <= current.exit + 1e-4`) -- the first gap ends the walk,
// the same rule Entity.cs's old march kept. A direct hit starts outside the hull (t -> -infinity), so the
// walk's first element is the impact cell. Only that first element is read in 12.2; the rest is 12.3's
// armour-first absorption march. Writes into the caller-supplied pooled buffer and returns the walked
// count; never allocates on its own.
// Cut 12.2 fix batch (S1, Soul's second pass): admission is now decided ONCE, by the exact same shadow
// interval Silhouette sums over (Extent(c, ell), the shared function above) -- not by a re-derivation and
// not by a second, independent geometric test. The old code ran two tests: this shadow prefilter (a
// "re-derivation" of Silhouette's own centre/h, inline) AND SlabAlongB, an independent 2-axis ray-box
// intersection that could reject a cell the shadow test had just admitted, purely on float rounding at an
// interval edge -- an empty lane for a shot that had already passed its roll (R3 violation, the crash Soul
// reproduced through Zone.Update). Deleting the second gate removes the split authority: a cell the shadow
// sum counted metal for can no longer be un-counted here.
public static int Lane(HullData hull, float2 b, float s, LaneCell[] buffer)
{
var n = CellsOnLine(hull, b, s, buffer);
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
var walked = n > 0 ? 1 : 0;
for (var i = 1; i < n; i++)
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
{
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
if (buffer[i].Entry > buffer[i - 1].Exit + 1e-4f) break;
walked++;
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
}
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
return walked;
}
// Cut 12.2 fix batch (S1): the along-bearing entry/exit parameters for a cell the shadow test above has
// ALREADY admitted -- unconditional, never a gate. The doc's own proof ("s lies in the shadow is exactly
// the same event as this lane has metal") makes the intersection this computes a certainty, not a
// hypothesis to re-test: for a convex shape, a value inside its projection onto ell is achieved by a
// nonempty segment of the line ell=s, so the box-slab solve below cannot come back empty in exact
// arithmetic. It only ever disagreed with the shadow test by float rounding at the interval's own edge,
// which is exactly where SlabAlongB used to throw the metal away; here that same rounding is absorbed by
// clamping entry <= exit instead of rejecting.
private static void AlongBearing(float2 c, float2 b, float2 ell, float s, out float entry, out float exit)
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
{
entry = float.NegativeInfinity;
exit = float.PositiveInfinity;
SlabAxis(b.x, c.x - .5f, c.x + .5f, s * ell.x, ref entry, ref exit);
SlabAxis(b.y, c.y - .5f, c.y + .5f, s * ell.y, ref entry, ref exit);
if (entry > exit) exit = entry; // rounding only -- the shadow test already proved a real intersection
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
}
// The slab (ray-box) bound of point(t) = t*b + s*ell against the unit square centred on cell c, one axis at
// a time: solving bAxis*t + val in [lo, hi], then folding the result into the running (entry, exit) bound.
// A near-zero bAxis component means the ray does not move along that axis at all, so it adds no constraint
// on t (the shadow test above already established this axis's bound holds).
private static void SlabAxis(float bAxis, float lo, float hi, float val, ref float entry, ref float exit)
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
{
if (abs(bAxis) < 1e-9f) return; // no motion along this axis: already covered by the shadow admission test
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
var t1 = (lo - val) / bAxis;
var t2 = (hi - val) / bAxis;
entry = max(entry, min(t1, t2));
exit = min(exit, max(t1, t2));
Cut 6d: the dart-throw kernel replaces the coin-flip landing rule FireControl.Commit used to place a hit with a coin flip between exactly the aimed item and a uniform-random hull cell -- no spatial term, so a near-miss on a stern item was as likely to land in the bow as the stern. One Gaussian kernel, in hull-schematic cell units, now decides both halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over every occupied cell, sigma = 1/Precision. HitProbability folds the kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6 unaffected); Commit draws the landing cell from the same weights. A shot that passes the roll always lands on metal -- the off-hull chance is priced into that one roll, never resolved as a second miss stage. The uniform-random branch is deleted outright, not demoted. Precision now feeds every shot's pOnHull, aimed or not, so its unaided fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/ UnaidedTracking floors. pOnHull is saturated: the discrete sum over- estimates the continuous kernel's integral once sigma drops below about one cell, and a mass share cannot exceed the whole kernel. Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and TargetingSystemTests authored Precision as 0 (or relied on the old 0 fallback) purely as a don't-care value under the coin-flip rule, where it only gated the aimed-item branch. Under the kernel it drives every shot's sigma, so those fixtures are re-authored to keep pOnHull near its ceiling and stay pinned on what they actually test. mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's return line and the deleted coin-flip branch are updated to the equivalent lines/mutations on the new code.
2026-09-19 21:37:39 +02:00
}
// The hull-schematic cells a hardpoint covers, laid as Entity builds its Hardpoints[,] grid.
private static int2[] MountCells(HardpointData hardpoint) =>
hardpoint.Shape.Coordinates.Select(c => hardpoint.Position + c).ToArray();
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
// 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 Detonate and ApplyPooled read.
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
private static int2[] CellsOf(Entity target, EquippedItem item)
{
var hullData = target.ItemManager.GetData(target.Hull) as HullData;
var cells = new List<int2>();
foreach (var v in hullData.Shape.Coordinates)
if (target.GearOccupancy[v.x, v.y] == item)
cells.Add(v);
return cells.ToArray();
}
}
// Cut 3 (docs/fire-control-cut.md, 0b table): a shot in flight. Born in FireControl.Fire, aged by
// FireControl.Step (called from Zone.Update after the entity loop), never serialised -- a zone saved mid-
// flight loses its shots, which is correct, a save is a scene boundary. Zone owns the collection and the
// ShotId; FireControl owns every transition. Nothing else may add, remove or mutate one.
public struct PendingShot
{
public int ShotId;
public Entity Source;
public Entity Target;
public EquippedItem Weapon;
public EquippedItem Aimed;
// The mount the aim was at, when it was at a mount rather than an item (Entity.ResolvedAim at Fire), frozen with Aimed:
// a reveal between Fire and Commit does not move where this shot was aimed. Null for an item aim or none.
public HardpointData AimedMount;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
// The frozen payload (R10, Q6): the gun's own stats at the instant the trigger was pulled. Nothing
// downstream re-evaluates any of these.
public float Damage;
// The weapon's falloff stats at Fire (with MaxRange below), frozen like the rest: what the range factor reads, at Fire or
// at commit.
public float FireMinRange;
public float FireEffectiveRange;
public float FireDamageAtMaxRange;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
public float Penetration;
public float DamageSpread;
public DamageType DamageType;
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Cut 12.2 (docs/fire-control-cut.md): renamed from PBase -- its meaning changes from "the whole shot's
// frozen roll price" to "the shooter's own share of it," behind the same gates (Accuracy x PSensor). The
// spread and hull factors are no longer frozen here; they are priced live at Commit, against the target's
// facing then (Bearing timing).
public float PFire;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
public float Tracking;
public float Precision;
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Cut 12.2: frozen at Fire alongside the rest of the shooter's own decision (R10) -- the shot's
// world-planar travel direction (FireControl.TravelDirection), and the weapon stats PSpread reads at
// Commit against the target's live silhouette.
public float2 TravelDirection;
public float Spread;
public float FireRange;
// Cut 12.4(a): the blast payload, frozen at Fire alongside everything else above. BlastRadius is zero for
// a weapon whose data does not carry one -- Step reads that zero as "resolve with Apply," not a separate
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// bool. Fuse is frozen only when BlastRadius > 0 (Solve decides "detonates" once); it is unread until
// Apply's fuse switch (12.4(b)).
public float3 BurstPosition;
public WeaponFuse? Fuse;
public float BlastRadius;
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// Frozen with the rest of the shooter's decision: where the round left from. A round outlives its shooter, so
// nothing at resolution reads the shooter's position.
public float3 FireOrigin;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
public float3 FireTargetPosition;
public float3 FireTargetVelocity;
public float FireTime;
// The weapon's Velocity at Fire, frozen with the rest so a presenter draws the same line whatever happens to the stat later.
public float Speed;
// The weapon's Range at Fire, likewise frozen: how far a presenter may draw a round that misses. FireRange above
// is the distance to the engaged target (0 for a round with none), not how far a round that misses flies on.
public float MaxRange;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
public float CommitTime;
public float ArrivalTime;
public bool Committed;
public ShotOutcome Outcome;
// A fused round with no target: where it bursts is decided at its commit (the first hull its aim line meets), so its
// damage reads the falloff there rather than at Fire.
public bool DeliveredAtCommit => Fuse != null && Target == null;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
}
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// What a round does when fired (FireControl.Solve): Direct resolves against its target, Burst detonates at a
// point, Refused cannot be fired -- its blast radius exceeds the weapon's Range.
public enum FireOutcome
{
Direct,
Burst,
Refused
}
// A gun's solution against its subject (FireControl.Solution): whether the gun bears on the subject, whether the
// aim is free (inside the arc), and the direction a round from it travels.
public struct GunSolution
{
public Entity Subject;
public bool Bears;
public bool Free;
public float2 Direction;
}
// Solve's answer. Outcome and BurstReach (planar distance from the shooter to the burst point; the HUD reads it for
// Burst, Fire's range factor for both outcomes) are what the HUD shows; the rest is what Fire freezes into the PendingShot.
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
public struct FireSolution
{
public FireOutcome Outcome;
public float BurstReach;
// How far the round flies: to its burst point, or for a direct round to its target.
public float FlightDistance;
public float PFire;
public float FireRange;
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
// Whether the shooter holds valid targeting data on the target (Designated); the HUD and the agents read it here.
public bool Designated;
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
public Entity Engaged;
public float2 TravelDirection;
public float3 BurstPosition;
public WeaponFuse? Fuse;
public float BlastRadius;
}
2026-09-20 21:58:44 +02:00
public struct FireControlDiagnostic
{
public bool HasTarget;
Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:31:59 +02:00
public bool Designated;
2026-09-20 21:58:44 +02:00
public bool InArc;
public float Range;
public float MinRange;
public float MaxRange;
public float Info;
public float InfoDemandCeiling;
public float Accuracy;
public float Resolution;
public float Precision;
public float Tracking;
public float PSensor;
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Cut 12.2 (optional, presentation): the shooter-side factor alone, Accuracy x PSensor -- the same value
// Fire freezes into PendingShot.PFire.
public float PFire;
2026-09-20 21:58:44 +02:00
public float PSpread;
public float POnHull;
// The target's evasion along the shot (metres) and the share of the shot that survives it at this shooter's Tracking.
public float Evasion;
public float PEvasion;
// The mount: how fast the target sweeps the shooter's sky (degrees per second), the rate this gun on this ship
// follows (degrees per second) and the share of the shot that survives it.
public float AngularVelocity;
public float TrackingRate;
public float PMount;
2026-09-20 21:58:44 +02:00
public float PBase;
Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:36:59 +02:00
// What a round from this weapon does (Solve): the forecast for a fused weapon, whose percentages above do
// not describe it. BurstReach is read only when Outcome is Burst.
public FireOutcome Outcome;
public float BurstReach;
2026-09-20 21:58:44 +02:00
}
// What a ship can do at this instant, in its own frame: acceleration along each body axis (m/s^2) and the kinematic
// turn rate each way (rad/s; turning toward the ship's right is Clockwise). Ship derives it each update as the box
// extremes of its thrusters' columns (Ship.RecalculateEnvelope); nothing stores it. Zero is the envelope of anything
// that is not a Ship.
public readonly struct ManoeuvreEnvelope
{
public readonly float Forward, Reverse, Left, Right, Clockwise, CounterClockwise;
public ManoeuvreEnvelope(float forward, float reverse, float left, float right, float clockwise, float counterClockwise)
{
Forward = forward;
Reverse = reverse;
Left = left;
Right = right;
Clockwise = clockwise;
CounterClockwise = counterClockwise;
}
}
// How unpredictably an entity is changing its motion, observed from its measured acceleration. The trend is an
// exponential average of the acceleration, the innovation is what departs from it (low-passed at a quarter of the
// window), and the moments are the exponential average of that innovation's outer product. A steady burn and
// coasting have no innovation; a weave at the window's scale does; dithering much faster than the window averages out.
// A change of vector is an acceleration that differs from the last observation's by more than ChangeThreshold. When
// none has happened for a whole window the unpredictability is over: the track forgets it outright (the moments
// return to exactly zero and the trend settles on the steady acceleration), so a ship that stops jinking stops
// evading within the window and the zero-spread early return is reachable again.
public struct ManoeuvreTrack
{
public const float ChangeThreshold = 0.01f;
public float2 Trend;
public float2 Innovation;
public float3 Moments;
public float2 Last;
public float Quiet;
public void Observe(float2 acceleration, float dt, float window)
{
if (dt <= 0f || window <= 0f) return;
var changed = abs(acceleration.x - Last.x) > ChangeThreshold || abs(acceleration.y - Last.y) > ChangeThreshold;
Quiet = changed ? 0f : Quiet + dt;
Last = acceleration;
if (Quiet >= window)
{
Trend = acceleration;
Innovation = float2(0, 0);
Moments = float3(0, 0, 0);
return;
}
var k = 1f - exp(-dt / window);
var k4 = 1f - exp(-4f * dt / window);
Trend += k * (acceleration - Trend);
Innovation += k4 * ((acceleration - Trend) - Innovation);
Moments += k * (float3(Innovation.x * Innovation.x, Innovation.x * Innovation.y, Innovation.y * Innovation.y) - Moments);
}
// The root-mean-square innovation across the axis n (a unit vector).
public float LateralRms(float2 n) => sqrt(max(0f, n.x * n.x * Moments.x + 2f * n.x * n.y * Moments.y + n.y * n.y * Moments.z));
}
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Cut 12.2 (docs/fire-control-cut.md): a merged interval of a hull's lateral shadow, in the schematic frame's
// lateral (ell) coordinate.
// S3 fix batch (Hands, 2026-09-25): IComparable<Interval> (sorted by Lo) instead of a separate IComparer<T> --
// Array.Sort(T[], int, int, IComparer<T>) boxes/wraps the comparer into a fresh delegate on CoreCLR every call
// even when the comparer instance itself is a cached singleton; Array.Sort(T[], int, int) resolves T's own
// IComparable<T> at JIT time and allocates nothing per call. Same order as the deleted IntervalByLoComparer.
public struct Interval : IComparable<Interval>
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
{
public float Lo;
public float Hi;
public int CompareTo(Interval other) => Lo.CompareTo(other.Lo);
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
}
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Cut 12.2: the hull's lateral shadow at one bearing, and the exact Gaussian mass it carries. Intervals holds
// the merged, sorted shadow (only the first Count entries are valid -- the array is sized to the hull's own
// occupied-cell count, not trimmed, to avoid a second allocation). A, Sigma and POnHull are read together by
// the lateral draw; Span is read by PSpread so the two factors can never disagree about the target's size.
public struct Silhouette
{
public Interval[] Intervals;
public int Count;
public float2 AimPoint;
public float A;
public float Sigma;
public float Span;
public float POnHull;
}
// Cut 12.2: one occupied cell FireControl.Lane crossed at a fixed lateral offset, with its own slab entry/exit
// parameter along the bearing. Entry ascending is nearest-to-farthest along the shot's own travel direction.
// S3 fix batch (Hands, 2026-09-25): IComparable<LaneCell> replaces the separate LaneCellComparer, for the same
// reason Interval's own comparer was deleted -- the (T[], int, int, IComparer<T>) sort overload allocates a
// wrapper delegate per call even for a cached singleton comparer; (T[], int, int) does not. Same order as the
// deleted comparer: entry ascending, ties by projection along b, then cell index for full determinism.
public struct LaneCell : IComparable<LaneCell>
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
{
public int2 Cell;
public float Entry;
public float Exit;
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
// F9 (Soul's fix batch, 2026-09-24): dot(Cell, b), precomputed once per candidate so Lane's sort tie-break
// needs no closure over the bearing.
public float Projection;
public int CompareTo(LaneCell other)
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
{
var byEntry = Entry.CompareTo(other.Entry);
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
if (byEntry != 0) return byEntry;
var byProjection = Projection.CompareTo(other.Projection);
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
if (byProjection != 0) return byProjection;
var byX = Cell.x.CompareTo(other.Cell.x);
return byX != 0 ? byX : Cell.y.CompareTo(other.Cell.y);
Cut 12.2 Soul fix batch: half-open clamp, split assembly, real rolls F1 (correctness): LateralDraw clamped the drawn lateral to the closed [Lo, Hi], not the spec's half-open [Lo, Hi). Soul enumerated all 2^24 NextFloat outputs against TurningArmourIntoTheShotTakesItOnTheArmour's own geometry and found 3 that draw exactly Hi, which Lane's own half-open filter then admits no cell for -- a hit that passed its roll landing nowhere (R3). Fixed the clamp; Lane's silent int2.zero fallback on an empty lane is now a thrown InvalidOperationException, since an empty lane is provably impossible after the fix. Committed Soul's edge probe as LateralDrawIsHalfOpenAtTheShadowsTopEdge. F6/F8: HitProbability, Inspect and Fire each assembled bearing -> Silhouette -> PSpread (or recomputed range) in their own copy; a shooter-frame bearing bug in HitProbability's copy survived while the identical bug in Inspect's copy was caught. Collapsed into one Forecast function and one PFire(out range) overload; both callers, and Fire, read the one computation. F2: FireControlCut12Tests' zone names carried a Guid, breaking every other cut's fixed-seed convention (FireControlCut6Tests.cs:62-63) and reseeding the die every run -- the actual source of the flake in TurningArmourIntoTheShotTakesItOnTheArmour. Fixed to a fixed zone name. F3/F4/F5/F7: added a two-prong hull fixture (LateralDraw's walk past the first interval, and a wrong-sigma placement, were unreachable when every fixture projects to one interval), a real-roll empirical test for CommitProbability (the old one called the pure function twice and compared it to itself), enough Penetration in TurningAfterCommitChangesNothing for ApplyHit to actually read the bearing, and a 2x2 item fixture for ResolveAimPoint's average (every "Marker" elsewhere is 1x1, so sum/length and sum*length agree). F9: Lane's own comment claimed no allocation while its sort used Comparer<T>.Create over a closure; LaneCell now carries its own precomputed projection and Lane sorts with a stateless singleton comparer. Corrected an overclaiming comment in FireAuthorityTests (SigmaFloor makes the aimed-lane draw ~68% per shot, not deterministic; the fixture's fixed zone name is what actually makes it reproduce). Cost: Silhouette takes an optional caller-rented Interval[] buffer; the forecast path (HitProbability/Inspect, via Forecast) rents from ArrayPool<Interval>.Shared and returns it before returning, since neither reads sil.Intervals after the call. Commit/CommitProbability still get an owned array, since LateralDraw needs it alive past the call. Measured on LonginusX (66 cells, Release): unaimed 672 B -> 184 B/call (1512-1637ns before, 1482-1560ns after); aimed 1160 B -> 672 B/call (2123-2140ns before, 2094-2162ns after) -- the remaining ~488B/call gap between aimed and unaimed is ResolveAimPoint's own CellsOf allocation (a List<int2>+ToArray per call), not in this batch's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:08:19 +02:00
}
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
}
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
// Cut 3 (docs/fire-control-cut.md, 0b table): a commit. Created once, at ArrivalTime - CommitHorizon (or at
// fire time when the flight is shorter than the horizon), immutable from then on, published once on
// Zone.ShotCommitted and again (unchanged) on Zone.ShotResolved at arrival. Presentations are the only
// readers, and nothing outside presentation may read one to change state.
public sealed class ShotOutcome
{
public int ShotId;
public Entity Source;
public Entity Target;
public EquippedItem Weapon;
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// What the commit decided, typed: a shot rolled and missed, rolled and hit, or was a fused round with no
// target and so burst without rolling, or a proximity round whose roll missed but which detonates anyway
// (Burst, at BurstPoint). Hit is derived from it, not stored beside it.
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
public ShotResult Result;
public bool Hit => Result == ShotResult.Hit;
// World planar point a proximity or no-target round detonates at, frozen by the commit; HasBurstPoint says
// whether this round has one (a contact or delayed round detonates where it hits, not at a frozen point).
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
public float2 BurstPoint;
public bool HasBurstPoint;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
public bool Shielded;
Fire control Cut 5: close the rules that landed on one path and not its twin Soul's pass over the whole campaign confirmed eleven findings. Seven were code defects of one shape -- a rule implemented on one path and left off the path beside it -- and this is all seven. 5.1 Tracking falls back to GameplaySettings.UnaidedTracking (new, 10) instead of zero, and Commit drops the zero branch that turned a Tracking-less shooter into a hard wall. Unaided fire was meant to be really, really bad; it was 334 shots and no hits against a target drifting at 5 u/s. 5.2 A shot the shield cannot absorb now breaks it. The decision is frozen into ShotOutcome.ShieldBroken at commit and performed in Apply, so a shield that recharges mid-flight cannot retroactively survive. Splash already did this; the discrete roll never did, which made the whole shield-break mechanic unreachable for every weapon in the catalog. 5.3 ConstantWeapon gates on ArcAllowsFire alongside StanceAllowsFire. A side-mounted beam was firing forward. 5.4 The point-blank bearing case moves out of Weapon.ArcAllowsFire and into InArc, which used to normalize the zero vector into NaN. The trigger said fire and the probability said impossible. 5.5 A destroyed item drops out of IsRevealed's ranking, so Precision stops concentrating hits onto a dead subsystem's footprint. 5.6 Zone subscribes Entity.Death and removes the corpse itself. That removal lived only in Unity's loot-drop subscription, so headless nothing ever left the zone, shots landed on corpses and no fight could end. 5.7 PendingShot drops PredictedIntercept and FlightTime, written and read nowhere. 208 tests pass (197 plus eleven new). Known defect, recorded rather than hidden: the committed mutation harness is unreliable. Across two runs it failed to restore FireControl.cs and Weapon.cs, leaving live mutations in the working tree -- including its own 5.1 mutation, which is how a green suite briefly reported two failures. Its verdicts are therefore evidence only where re-run from a verified-clean tree. Two anchors (Zone.cs's ObserveAdd line, Weapon.cs's InArc return) fail to match at all, and 5.3's ConstantWeaponObeysArc survives its own mutation, so that rule is written but not pinned. Cut 5b owns the repair; nothing here is trusted on the harness's word, and the suite result above was taken by hand from a hash-verified tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:56:40 +02:00
// Cut 5, 5.2 (Soul finding 4): frozen alongside the rest of the outcome (R4) -- a shield present, active,
// and unable to CanTakeHit this shot is decided broken right here, so Apply performs Break() rather than
// deciding it, and a shield that recharges mid-flight cannot retroactively dodge it.
public bool ShieldBroken;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
public int2 Cell;
Cut 12.2: where a hit lands is priced and drawn from one silhouette FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:50 +02:00
// Cut 12.2 (R4): the committed geometry, frozen alongside the rest of the outcome -- the schematic-frame
// bearing and the lateral offset the roll's placement drew. Apply reads these, not a live position or
// facing (TurningAfterCommitChangesNothing).
public float2 Bearing;
public float Lateral;
Fire control Cut 3: hit authority moves to FireControl Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:07:07 +02:00
public float ArrivalIn;
public DamageType DamageType;
}
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
// What a committed shot came to. A Burst is a detonation that is not a hit on a target: a fused round that had no
// valid target and rolled nothing, or a proximity round whose roll missed but which bursts anyway. It is neither
// a hit nor a miss, and the presentation must not call it one.
No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:43 +02:00
public enum ShotResult
{
Miss,
Hit,
Burst
}