MineLayerData (union 40) lays through InstantWeapon -> FireControl.Fire -> Lay;
Zone.Mines steps each mine as a KinematicBody after the entity loop, arms it,
triggers it on a hostile hull its blast disc touches (FireControl.Touches, the
coverage Detonate uses) and detonates it through FireControl.Detonate at the
fuse delay or lifetime. Hostile trigger, faction-blind blast, no owner leash.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Soul gate closed at verdict cut-floating-bodies.s3, head 6299627b, under
operator ruling aetheria-release:ruling:completed-cuts-merge.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Operator ruling mines-back-in-sim-asap: delete now, return as a simulation body.
Mesh, materials and BigExplosion stay for the sim-mines presenter. GridObject
stays for the pickups until loot-1 r2. Comments in FireControl.Detonate and
HullCollider drop the deleted callers. Builds not yet verified at this commit.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Ship.Update reads Turn and feeds the rotation thrusters and aether drives; it no longer reads the
look direction or snaps Direction toward it. Steering.Toward is the one law from heading to demand
(a heading astern now turns at full demand). HelmInput owns the face-the-aim state. Accelerate,
Combat and MoveTo write Turn through Steering.Toward. Entity.LookDirection is renamed Aim in the
simulation, the Unity readers and the tests. Unity player input follows in the next commit.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Mining owns belts, chunks and target picking; scenarios owns admission and run
staging; master owns ships and fire control (rule scenarios-cut.md S4).
- AetheriaStores.CatalogTypes: nine-type union, master's list plus FieldKindData, no Scenario.
- tools/AetherDb/Program.cs: both cases (ship-authoring, field-kinds), one usage string.
- Zone.cs: master's MineAsteroid dropped (mining deleted its inputs); scenarios' SettleBelts and its three calls deleted.
- RunStartTests: the two belt-task tests deleted (they read belt.Transforms and MineAsteroid).
- FireControlCut124Tests: master's strict Exact assertions; mining's CompatibleDrift allowance and its comment dropped.
- GameData/Aetheria.cc: master's pointer 70a7b0a9. This commit lacks the Asteroid field kind in the catalog, so
TheShippedCatalogHasAPickableFieldKind fails here by design; the next commit rebuilds it.
Builds: this commit builds (verified at the end with commit 2); the catalog pin test fails at this commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The placement rule (HardpointAt) already finds the one hardpoint an item lies
in at equip; TryEquip threw that answer away and eighteen readers recomputed it
from the item's origin cell, which need not lie in the hardpoint (Soul's
scenarios batch-4 P30). EquippedItem.Hardpoint now carries the answer, null for
Tool gear and the hull, and every origin-cell lookup reads it instead:
FireControl.ArcFor, the eleven weapon managers, EntityInstance, ShipInstance
and ActionGameManager's articulation grouping. Entity.Hardpoints[,] stays the
per-cell map for armour and display.
TryFindSpace's hardpoint search tries every origin the fit rule can accept,
including ones up and left of the hardpoint, so a design whose shape lacks its
origin cell is found wherever ItemFits would place it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
A burst whose every round is refused fires nothing and leaves no cooldown: the cooldown moves from
Trigger to the first round Execute does not refuse, and CanFire holds a triggered burst that has not
yet fired. ChargedWeapon.Activate asks FireControl before it starts charging, so a refused weapon pays
no charge heat or audio. InReach is deleted: Designated carries the one range test and the lock gate,
and Combat reads it. GuidedProjectile answers Resolve while alive and keeps its binding through the
shot's flight time, so a faded round still shows the simulation's detonation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
Operator ruling 2026-09-30. A fused round's burst is at least BlastRadius
beyond the far edge of the shooter's own hull, measured along the flight line
against the hull as it stood at fire (HullAhead's exit). A close burst -- a
target inside that distance, in or out of arc -- is pushed out along the aim
and carries no target; a contact round is floored to it too.
- The F3 trigger gate is deleted. Fire refuses a round (returns 0) whose
arming distance exceeds Range, per round, where Range is read after Execute
refreshed it; InstantWeapon and ConstantWeapon skip a refused round.
- PFire's range, Inspect's range and the range clamp are planar (R7).
- HullEntryDistance becomes HullAhead (entry and exit); the max(...,0) clamp
on entry is gone, the arming floor covers it.
- ShotOutcome.HasBurstPoint names the rounds that have a frozen burst point.
- SafeAssert: the tests no longer hand xunit an Entity, PendingShot or
ShotOutcome to format; a failing assertion had been overflowing the test
host's stack.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Operator ruling 2026-09-30: a fused weapon with a selected but out-of-arc
target fires. Its round flies along the arc-clamped aim and bursts at the
target's range. Non-fused weapons still refuse.
- FireControl.ArcPermitsFire is the one trigger arc gate (fused weapons are
exempt); Fire reads the same InArc through PFire's new designated/inArc
outs, which replace `bears`. No valid targeting data still bursts at Range.
- F3: a fused weapon whose effective Range does not exceed its BlastRadius
stays safed at the trigger (FuseCanArm, read from the runtime Range). The
catalog check on Range.Min is deleted; it read the authored floor, which
Evaluate can scale below.
- F4: a targeted proximity burst is pulled back along its line to Range.
- F8: a proximity round that detonates commits as Burst (or Hit); Apply's
Proximity case collapses into the Burst path via Outcome.BurstPoint.
- Tests: arc-only and LockWeapon gates, out-of-arc burst, trigger gate,
runtime-Range refusal, Range clamp, Burst typing, commit horizon, and
Soul's adopted probes (aim clamp edges, contact point, target leaves,
shooter destroyed).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Operator ruling 2026-09-30: fused weapons without a lock explode at max
range. Fire froze BurstPosition at the shooter's own position with flight
time 0, so a proximity shot detonated on its own shooter and contact and
delayed shots did nothing.
With no target and a fuse, Fire now freezes the burst point as the mount
direction times the weapon's Range stat from the source, and flight time as
Range over Velocity. Apply sends every such shot through Detonate at that
point whatever its fuse. Targeted shots and unfused shots are untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
F1: RectDiskOverlap counted a whole square when a cell edge sat exactly r from
the disc centre (`h < yhi`, `-h > ylo`). The comparisons are inclusive now, so
the chord bounds the piece. A proximity burst on an integer centre of mass
delivered 127.32 of 100 at BlastRadius 1 and 100.80 at 3.
F2: delete Detonate's distance prefilter and the radius <= 0 guard. The
prefilter measured from the centre of mass with the bounding box's
half-diagonal, which is not a bound on how far a hull's cells reach; it culled
real overlap on an L-shaped hull and on the shipped Longinus corners.
RectDiskOverlap is the single decider. An untouched entity is still skipped
(its covered list is never allocated).
F3: Detonate iterates a snapshot of zone.Entities, as Zone.Update does. A
lethal blast removed the entity from the live collection and the next MoveNext
threw, leaving the shot pending so it detonated again the next tick.
Tests: tangent discs through Fire and Detonate, an L-shaped hull, grazing
blasts at the farthest corner of Longinus and LonginusX (expected values from
a numeric overlap), and a lethal blast that must resolve once.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
FireControl.Apply now switches on the frozen shot.Fuse: a null fuse keeps
12.3's lane path (ApplyDirectHit), a proximity fuse detonates at arrival with
no roll, and a contact or delayed fuse detonates only on a committed hit
(ApplyBlastHit), locating P through Lane and the new Reach owner (moved
verbatim from Apply's own inline penetration clip; the delayed fuse point
reads the identical rule). Step has no damage branch left.
Detonate (FireControl.cs) is the one owner of blast damage: candidates by
centre-plus-half-diagonal, exact circle/cell overlap shares computed once,
every blast's shield decided live per Q12-9 = A (including the host of a
contact/delayed blast -- Commit now gates its own shield decision on
shot.Fuse == null), armour absorbed per cell, and each item's covered cells
pooled into one ItemAbsorb call. Entity.Absorb and FireControl.Splash are
deleted; Entity gains ToSchematicPoint/ToWorldPoint for the point conversion
Detonate and the delayed fuse point need. Mine.Explode now calls Detonate.
Existing Splash/Absorb-era tests across Cut4/5/6/11/123 are rewritten or
retargeted to the new public owners; behavioural coverage for the blast
model itself lands separately.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WeaponItemData.AirburstRange (slot 29) becomes BlastRadius, same slot and
type so CultCache opens the catalog as compatible drift. A new nullable
slot 32 (30 and 31 belong to EquippableItemData) carries WeaponFuse?
Fuse. FireControl.Fire now decides "detonates" once, from BlastRadius
and Fuse directly, and freezes Fuse only when BlastRadius > 0 -- the
WeaponModifiers.Airburst label no longer decides anything (Q12-6).
PendingShot.BurstRadius is renamed BlastRadius and gains a Fuse field
that nothing reads yet; Step's Splash/Apply dispatch is unchanged.
No shipped weapon carries a blast radius or the Airburst label (Cut 12's
Probe4, reconfirmed against GameData/Aetheria.cc), so this is
behaviour-neutral on shipped content. Apply's fuse switch and deleting
Splash are Cut 12.4(b).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ApplyPooled.Resolve no longer calls FinishLane directly -- AdvanceLane is now
the single place that checks pointer >= walked and finishes a lane, so lane
completion has one writer instead of two. StillReachableByAnotherLane's own
`blocked ? pointer+1 : pointer` collapses to plain `pointer[li2]`: the
alreadyContributed check just above it already excludes a lane that is
blocked on the very item being asked about, so `pointer[li2]` is never that
item's own cell by the time the +1 would have mattered.
Soul's findings (docs/fire-control-cut.md, Cut 12.3): F4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the survey-then-branch (ApplySequential vs. ApplyWithSharedItems)
with a single pooled walk. Lanes are clipped to penetration once, right
after Lanes returns; every item -- single-cell or multi-cell -- pools
whatever lanes reach it and resolves the moment no other unfinished lane
can still reach it down its own remaining clipped walk, with no separate
reachability survey. A cyclic dependency between two non-convex items
resolves every currently open pool at once, order-independent by
construction. Entity.ItemAbsorb becomes the one absorption function,
gated on the pooled total; Absorb (Splash's only remaining caller) now
funnels through it with a one-contribution pool.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Operator ruling (2026-09-25): "proportional absorption; go". A multi-cell item
struck by several lanes of one shot now absorbs their combined incoming damage
(up to its own remaining durability) and each lane's leftover is in proportion
to what it brought, replacing the old behaviour where whichever lane Apply
happened to visit first drained the item and the next lane inherited whatever
was left.
Entity.Absorb is split into ArmorAbsorb (per-cell, never shared) and ItemAbsorb
(the single-lane path), so FireControl.Apply can call armour immediately and
defer item resolution only for a cell whose item is reachable by more than one
lane this shot. FireControl.Apply gains a geometric survey (which items are
reachable by >1 lane, decided before any damage is applied) and takes the
untouched sequential walk (ApplySequential) whenever nothing is shared, or a
new coordinated walk (ApplyWithSharedItems) that deposits each lane's
post-armour remainder into a shared item's pool and resolves it once every
contributing lane has arrived, splitting the leftover proportionally
(sum-then-split, order-independent by construction). A deterministic tie-break
(smallest first-contact Entry) breaks the rare "opposite order" deadlock two
non-convex shared items can create between two lanes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
Operator ruling (2026-09-25): overlapping spread lanes should diffuse sideways
rather than double up. FireControl.Apply now spaces lanes by the shadow width
a cell casts at the committed bearing (Extent's own h, doubled) instead of by
one cell -- 1 at axis-aligned bearings (unchanged), up to sqrt(2) at 45
degrees. Reuses Extent, the one function that already owns h for Silhouette
and Lane, rather than re-deriving it a second way.
F3: PenetrationExcludesACellExactlyAtItsOwnBoundary's 1e6-durability marker
absorbed the whole shot before the walk could reach the boundary cell, so the
test could not fail under the `>=`-to-`>` mutant. The marker now carries 10
durability, leaving a remainder that would visibly damage the boundary cell if
the walk incorrectly included it. Adds
PenetrationExcludesCellsBeyondANonIntegerDepth, pinning the ordinary
"< penetration" comparison at a depth (1.5) that isn't an exact-integer
boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deletes Entity.DamageSchematic (the even split over the whole hit shape)
and Entity.ApplyHit (the 0.5-step penetration march), replacing both with
Entity.Absorb (armour then item, per cell) and Entity.DamageHull, moved
verbatim from DamageSchematic's own body. FireControl.Apply now builds
2n+1 parallel lanes from the committed Bearing/Lateral (Q12-2 = A),
shares damage evenly over the lanes that meet metal, walks each lane in
order via the already-verified FireControl.Lane, absorbing cell by cell
up to the penetration depth, and sends every lane's own remainder to the
hull where it ends (Q12-3 = A). Splash is rewritten onto Absorb/DamageHull
(arithmetically identical to before) since DamageSchematic is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured (Release, 7x9 solid hull -- the LonginusX shipped-hull fixture the
brief named is not available to this test file):
- Lane: 0 B / 1000 calls (was: 64 B/call from Array.Sort's comparer wrapper;
now confirmed 0 after the earlier IComparable<T> change in this batch).
- Silhouette (caller-supplied buffer): 0 B / 1000 calls.
- HitProbability, gated in (a real, non-gated-out call): ~120 B/call. This is
NOT from Lane, Silhouette or the comparer -- it comes from Accuracy/
Resolution's own GetBehavior<T>, which walks Entity.Equipment and each
item's Behaviors (both ReactiveCollection<T>); that type's enumerator is
heap-allocated per foreach. This predates Cut 12.2 and is out of this cut's
scope to fix.
- Inspect, gated in: ~280 B/call (same cause, more GetBehavior<T> calls).
The Forecast comment claiming "the forecast path (HitProbability, Inspect) no
longer allocates on a gated-in call" was not true and is corrected to say what
was actually measured: Silhouette itself is allocation-free given a pooled
buffer, and the residual cost belongs to Entity's own collection choice.
LaneAndSilhouetteAllocateNothingGivenAPooledBuffer asserts the true zero-alloc
claims exactly and records (with a loose regression bound, not a false zero)
the unrelated Entity-level cost on the full HitProbability path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
S1 (HIGH, crash in play): Lane's own shadow prefilter (a re-derivation of
Silhouette's centre/h) and the independent SlabAlongB 2-axis intersection test
could disagree at a float edge, rejecting a cell the shadow sum had already
counted metal for -- an empty lane after a hit passed its roll (R3 violation),
reproducible through Zone.Update. Both are replaced by one shared admission
test (Extent(c, axis), the exact support of a unit square along an axis) that
Silhouette and Lane now both call; SlabAlongB is deleted, and its geometry is
kept only as a non-rejecting AlongBearing() that computes entry/exit for
already-admitted cells (the doc's own equivalence proof -- "s in the shadow" is
exactly "the line intersects" -- makes rejection there unreachable by
construction, not merely unlikely).
S3 (allocation): Interval and LaneCell now implement IComparable<T> (same
order as the deleted IntervalByLoComparer/LaneCellComparer) so Silhouette's and
Lane's sorts use Array.Sort(T[], int, int), which resolves T's own comparison
at JIT time, instead of the (T[], int, int, IComparer<T>) overload, which
wraps even a cached singleton comparer in a fresh delegate per call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The first Stryker pass (375 mutants, 69 survivors on this cut's own changed
lines) found one real split-authority bug and several untested edges:
- HitProbability recomputed target.Position - source.Position a second time
for PSpread's range, a second place the same subtraction could flip sign.
PFire now returns its own gate-computed range by out parameter; HitProbability
reads that one value instead of a second copy.
- TravelDirection's point-blank fallback and Fire's null-target guard around it
were only ever exercised at shooter/target pairs where the real direction
happened to equal the (0,1) fallback by coincidence.
- diagnostic.PFire (presentation) was only ever checked at PSensor == 1, where
Accuracy * PSensor and Accuracy / PSensor agree.
- CommitProbability's four-factor product was only ever checked with
pDeviation == 1 (no fixture moved a target between fire and commit), where
* and / by 1 agree.
- ResolveAimPoint's zero-cells fallback (an aimed item that no longer occupies
any cell) was never reached; every fixture's marker survives to be hit.
New tests: TravelDirectionMatchesThePredictedInterceptAndFallsBackAtPointBlank,
DiagnosticPFireIsAccuracyTimesPSensor, CommitProbabilityIsTheExactProductOfItsFourFactors,
AimPointFallsBackWhenTheAimedItemNoLongerOccupiesAnyCell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Entity.ToSchematic(float2) is the one schematic-frame transform;
Entity.ApplyHit's penetration march and FireControl.Splash's
directional half both call it instead of each carrying an inline
forward/right copy. ShotOutcome.Aimed and its plumbing (Q12-4 = A) are
deleted: the aimed local/flag in Commit, MakeOutcome's aimed
parameter, and the AimedCells half of ResolveAimPoint's tuple, which
now returns float2 directly.
No behaviour change. All 244 tests pass unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inspect.PBase is HitProbability's own answer instead of a second product of
the factors. New behavioural tests pin a beam shot carrying its override
damage, a fitted targeting system forgiving a jink, a shot orphaned before
commit committing as a plain miss, and the HUD's arrival countdown. The
tautological damage assertion in BeamRollsPerInterval is deleted.
Stryker on FireControl.cs: 51 survivors to 43, all triaged in the map.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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.
Cut 3's harness reported OutOfArcConsumesNoDraw as "not yet reached" -- no
mutation could turn it red. Chasing that rather than accepting it found the
reason, and it is not a fixture defect.
The test asserted that a shot gated to zero draws nothing from ItemManager.
Random, so the shared stream advanced exactly as if the shot had never queued.
Cut 6b moved every roll onto a per-shot local generator seeded from (zone, shot
id), so Commit stopped touching that stream on EVERY path. The test then passed
for a reason unrelated to arcs -- it passed identically with the target dead
ahead -- and the rule it named stopped existing along with the stream it was
about. The short-circuit it guarded is now a cheap guard, not an invariant, and
Commit's comment was still asserting otherwise.
Rewritten as CombatNeverDrawsFromTheSharedStream, a Theory over both an
out-of-arc and an in-arc target, pinning the rule that IS live after 6b: no
combat path draws from the shared stream at all. Verified by hand -- restoring
the shared-stream read in Commit turns the in-arc case red while the out-of-arc
case stays green, which is exactly why the old test could not have caught it.
218 tests pass. All six mutation harnesses run clean with tree-clean PASS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cut 6b added WeaponItemData.AirburstRange as a non-nullable float at Key(29).
Every WeaponItemData record in GameData/Aetheria.cc was serialized before that
key existed, so the reader is handed nil for the absent slot and a float cannot
take nil -- AetheriaStores.Open threw MessagePackSerializationException and the
game could not read its own catalog. AirburstRange becomes float?, null meaning
no authored burst radius, which is also the more honest reading of a value no
product sets yet.
Neither cut could have caught this alone. 6b verified with unit tests over
synthetic fixtures and never opened the shipped catalog; 6c opened the catalog
constantly but on a branch without the new field. It appears only in the merge,
and only when something actually reads GameData.
The general rule, which will recur: a new non-nullable value-type field on a
persisted CultCache document cannot read records written before it existed.
Nullable, or a migration that rewrites every affected record -- and a migration
cannot run if the read already throws, so tolerant deserialization comes first.
Verified on the merged tree: catalog reads (53 designs, 47 products), 0 dangling
refs, 215 tests pass, and loadout with all three targeting sellers excluded
generates every hull with 1 armed hull falling back to unaided fire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cut 6a authored the premium targeting system strictly worse than the cheap one,
because Resolution is consumed as a cost while its name promises a benefit.
Operator ruling: fix the formula, not the handle. HitProbability now derives its
ceiling reciprocally, so higher Resolution is a sharper instrument and the
unaided fallback of 1 becomes the floor by construction rather than by accident.
No rename.
Both designs also shipped from a single seller, and galaxy generation draws a
random subset of factions -- so ship generation still threw in any galaxy that
missed that draw, and Cut 6a's ten green seeds only sampled the die. Targeting
now comes from the roster's own sensor factions, Finch and Lucent, alongside
NiteLife; and FillInterior degrades to unaided fire instead of throwing when no
seller is present, which is only safe because Cut 5.1 gave unaided fire a floor.
Also records that the existing loadout command runs with a null galaxy, where
IsAvailable passes everything -- so availability had never been exercised by any
verification in this campaign until the exclude-sellers mode this cut adds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests/mutation_tests_fire_control_cut6.sh, modelled on Cut 5's harness: a no-op
control, four mutations (6.1's shared-stream generator and dropped-ShotId seed;
6.2's double-application and unconditional-splash), byte-exact I/O with
sha256-verified restore after every mutation, and the two negative greps the
Cut 6 spec names. All five mutations behave as declared and the tree-clean
verdict is PASS.
Fixes two of Commit's and Projectile's own explanatory comments that quoted the
literal strings the negative greps check for (ItemManager.Random, Airburst),
which the first harness run caught as false failures -- reworded, no behavior
change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6.1 (Soul finding 6): Commit builds a local CultMath.Random from (Zone.CombatSeed,
shot.ShotId) instead of reading and writing the shared ItemManager.Random stream.
Zone exposes CombatSeed (the same expression that already seeds its own _random),
so a shot's outcome is a pure function of zone identity and shot id -- immune to
whatever else drew from the shared stream in between.
6.2 (Soul finding 5): airburst is wired into the simulation instead of back into
Projectile.cs. PendingShot gains BurstPosition/BurstRadius, frozen at Fire time
from PredictedIntercept and the weapon's authored WeaponItemData.AirburstRange
when WeaponModifiers carries Airburst. FireControl.Step resolves such a shot via
Splash instead of Apply, never both. Projectile.cs/ProjectileManager.cs drop the
dead AirburstDistance/AirburstRange fields and write-back.
Tests: FireControlCut6Tests.cs pins both rules (208 baseline + 5 new, all green).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6c.1 (operator ruling 2026-09-19): Resolution stays a benefit -- higher is
better -- so HitProbability derives its sensor ceiling as the reciprocal of
Resolution instead of reading Resolution as the ceiling directly. The
authored 1-cell/2-cell targeting systems' Resolution values move to the new
scale in the next (data) commit.
6c.2: LoadoutGenerator.FillInterior no longer throws InvalidLoadoutException
when no targeting-system product is available to a galaxy -- it degrades to
no targeting system and logs the gap, safe only because Cut 5.1 already gave
unaided fire a floor (GameplaySettings.UnaidedTracking).
Adds FireControlCut6cTests.cs pinning both: HigherResolutionYieldsHigherOrEqualHitProbability
(dies if Resolution is read as the ceiling directly) and
NoSellerDegradesToUnaidedInsteadOfThrowing (dies if the throw is restored).
dotnet test tests/Aetheria.Shared.Tests: 210 passed, 0 failed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
TargetingSystem is required interior Tool gear: Accuracy/Resolution/
Precision/Tracking are PerformanceStats reached through the usual
Evaluate path, so lot quality, wear, heat and PowerSupply all apply
(a starved system rolls worse, never switches off). FireControl.Accuracy/
Resolution/Precision fall back to GameplaySettings.UnaidedAccuracy (new,
authored low per Q4) whenever an entity has no targeting system or its
own is destroyed/disabled.
FireControl.IsRevealed derives R5's reveal rule from EntityInfoGathered:
non-hull equipment ranks hardpoint-mounted first, then by size, and
reveals between TargetArmorInfoThreshold and TargetGearInfoThreshold --
the first code to read either setting. Entity.TargetItem/
TrySelectTargetItem/ResolvedTargetItem give both the player and AI one
shared, revoke-on-decay aim-point slot with a single writer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entity.HardpointTransforms was written only by Unity (EntityInstance.cs),
so the simulation's notion of where a gun points was a readback from a
renderer that never runs headless -- Combat.cs threw KeyNotFoundException
on the first combat tick outside Unity. Deletes the field, its writer, and
the readback branch in Behavior.Direction, leaving the item-rotation path
(the same convention reaction thrusters use, R6) as the only source of
mount direction.
Adds FireControl (ServerShared), a static class owning whether a weapon
bears on a point: MountDirection (planar, from ItemRotation), ArcFor (the
per-hardpoint HardpointData.FiringArc override, falling back to the new
GameplaySettings.FiringArc default of 120), and InArc (the planar dot-cone
test, R7 -- target height never enters it, and an arc of 360+ passes
unconditionally so a turret hardpoint needs no new type, just a number).
Combat.cs and TurretController.cs now gate AI fire through InArc instead
of the old hardcoded 0.99 dot-product test against a live Unity transform.
Settings.cs drops AgentFiringMinDot, which was declared and read by
nothing.
docs/fire-control-cut.md, Cut 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>