Commit graph Aetheria/Assets/Scripts/ServerShared
Author SHA1 Message Date
Meta Vens
5ea59ac6b5 Entity: an equipped item keeps the hardpoint it occupies
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>
2026-10-03 14:57:27 +02:00
Meta Vens
2d39c83504 Merge master into eureka/aetheria-release-scenarios: adopt the scenarios lane onto master's base
The one conflict is AetheriaStores.CatalogTypes: keep ShipAuthoring from the
ships lane and Scenario from the scenarios lane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:53:29 +02:00
Meta Vens
ff2be8e26f One placement rule for hardpoint gear
Soul's S3 and the S2 ruling ("B is the design intent, saving slots in a
hardpoint can be a valid tradeoff for crowded ships if you really need to fit
an extra tool"). Entity.HardpointAt is the one placement rule: at an origin,
the item lies within a hardpoint of its type at that hardpoint's rotation
(HardpointData.TakesAt), its own cells are free, and that hardpoint holds no
other hardpoint item. The origin no longer has to be a hardpoint cell, and
TryFindSpace tries every offset inside a hardpoint, the offsets the fit rule
tries, so an L-shaped hardpoint takes its L design instead of generation
throwing. Leftover hardpoint cells may hold general gear, and a hardpoint still
holds one hardpoint item.

Tests: an L hardpoint takes its L design by hand and in generation (Soul's
P11); a turret's gun goes back beside the tool gear in its leftover cells
(P21); a second gun in a roomy hardpoint is refused; a saved entity reloads to
the same loadout with its items in reverse order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:13:37 +02:00
Meta Vens
d50fd1b973 A faction reaches its own gear; allegiance names only other factions
Operator ruling, 2026-09-30: "removing the 11 self-entries was my intention".
IsAvailable, the one reach rule, now says a faction always reaches its own
manufacturer's gear, and a transient `AetherDb allegiance-self [apply]` drops
the self-entries from the eleven factions that had one (Rossum & Douglas never
did, which is why its stations could not carry its own gear).

Tests: in a main-sector galaxy every faction is offered all it makes and no
allegiance map names its own faction; and, through a fixture faction outside
the galaxy with no self-entry, its own product is on offer to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:13:34 +02:00
Meta Vens
f879527158 Zone: every writer of what the belt tasks read settles them first
Soul's F5 and F6. The asteroid-belt tasks read the clock, the orbits and each
belt's damage and respawn state. Update advanced the clock before waiting on
last tick's tasks (Soul measured 242k transforms placed for the next tick in
576 of 3000 ticks), MineAsteroid wrote damage and respawn state under a
running task, CreateOrbit and AddOrbit wrote the orbits, and a task that threw
stayed in the list, so Wait rethrew it on every tick after.

SettleBelts waits for the tasks and clears the list even when one failed; Update
settles before it advances the clock, and MineAsteroid, CreateOrbit and
AddOrbit settle before they write.

Tests: over 3000 ticks every asteroid of a belt is placed for the time of the
tick that started its task; mining every asteroid of a belt, chipping and
breaking each four times a tick for 1000 ticks, never races the task; a task
that fails surfaces once and later ticks run clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:31 +02:00
Meta Vens
74d2cf8fc4 Power bus: a consumer its thermostat has closed is not billed
The bus billed every active IPowerConsumer, but a behaviour group stops at its
Thermotoggle: a heater above its target never runs its EnergyDraw, yet its
full draw (1000-2500/s) was charged every tick. On an idle station that kept
the reactor in permanent deficit, overloading at overload efficiency. The bus
now skips a consumer whose group has a closed thermostat ahead of it, the same
rule it already applies to inactive items; Thermotoggle.Open is the one
statement of when a thermostat is open, and Execute returns it.

Tests (Soul's F2; S17 survived before): from a hull at one uniform temperature,
one tick below the heater's target raises its cells and one above does not, so
the heater's own contribution is measured with no reactor heat in it; and the
bus bills the heater's draw below its target and not above.

This does not keep an idle station warm (Soul's F1, reported separately): the
reactor's throttled idle heat alone still drives it past its plateau.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:30 +02:00
Meta Vens
072a56f0b4 One loose fit rule for hardpoint gear
Operator ruling, 2026-09-30: "Hardpoint fit is loose: nobody's gonna stop you
from putting a small reactor in a large reactor's hardpoint." There is one rule,
HardpointData.Takes: gear of the hardpoint's type fits when its shape, at the
hardpoint's rotation, lies within the hardpoint's cells. TakesAt is the same
rule at one offset, and Entity.ItemFits (equipping, save load, presets) now
asks it instead of repeating the containment test. Generation's hardpoint
picks, HasHome (so station stock and loadouts), product reuse and `AetherDb
hardpoint-fit` all read Takes.

Generation keeps its own placement preference, named IsFilledBy and not a fit
rule: it offers gear that fills the hardpoint when any is on offer and falls
back to anything that fits only when none is, so loadouts change only where a
hardpoint used to stay empty. RestoredHullsTests pick fillers through
IsFilledBy.

Consequence: the Autocannon, Vulcan, scorched void policy, Notorious, pretty
pretty bang bang, Manhattan, plight, DeathCluster, Small Drive and pswarm now
have a home and are offered. Still homeless: CShot RainbowLite Lazer,
ChargeBlast SG, ChargeBlast+-, OK Disperser.

Tests: generation offers exactly the sold hardpoint gear some hull fits; a 7x7
design no hull fits is offered only once a hull that takes it exists; a 5x5
Reactor hardpoint nothing fills still gets a reactor; equipping and the rule
agree for every hull, hardpoint and design; the homeless list is stated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:46:28 +02:00
Meta Vens
d91ab8daf9 Generation never offers gear no hull can mount
Operator ruling, 2026-09-30, on the Autocannon: "Leave the autocannon unsold,
we'll need to author a bunch more hulls before all the gear variety in the game
has a home." The fit rule gets one owner, HardpointData.Takes (the gear's type,
laid in at the hardpoint's rotation, filling every cell); LoadoutGenerator's
hardpoint picks, its reuse of a previous product and `AetherDb hardpoint-fit`
all read it. RandomProducts, which both station stock and NPC loadouts draw
through, offers only gear with a home: Tool gear, hulls, or hardpoint gear some
hull in the catalog takes. The Autocannon keeps its product and price.

Test: the Autocannon is never drawn in 2000 draws of its hardpoint type; after
a scratch hull whose hardpoint takes it is authored, it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:35 +02:00
Meta Vens
5c3cd599ac Zone: wait for last tick's belt tasks before clearing the orbit cache
Update cleared `_updatedOrbits` and only then waited on the previous tick's
asteroid-belt tasks, which read the cache through GetOrbitPosition. A task
still running could see the set mid-clear ("concurrent update ... corrupted its
state") or recompute an orbit into last tick's position. The wait now comes
first.

Test (Soul's orbit counter, committed): 3000 back-to-back 0.02 s ticks of a
quiet arena, and no orbit stands still against its parent. Clearing first gave
500+ stalls or the corruption exception. Measured against the parent because a
moon can genuinely pause in zone space: one such reading (tick 1321, a moon
whose motion cancels its planet's) is real motion, not the race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:01:34 +02:00
Meta Vens
89ef80d298 Stations: every generated station carries a heater
Operator ruling, 2026-09-30: an idle station has no heat source and cools
toward freezing and invisibility, so station generation (LoadoutGenerator, the
one owner of station loadouts) equips one heater after the docking bay, from
the products whose design is Tool gear with a low-pass Thermotoggle and Heat,
the cockpit's thermostat composition. It is required, like the docking bay.

Not yet warm: generated stations have no reactor (no reactor product fits
Zenith's 16-cell Reactor hardpoint), so the heater's EnergyDraw gets no supply
and the station still cools (280 K to ~247 K over ten idle minutes). The test
that an idle station stays above freezing waits on that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:45:32 +02:00
Meta Vens
27598a464a BaseState: an includeChildren walk keeps its state in locals
AddTransition's graph walk kept its visited, leaf and ignore sets in static
lists, so two Minions built at once on different threads (zones are built in
parallel) walked and cleared each other's state. Soul hit it as "collection
was modified" during parallel test runs. The sets are now the call's own.

BaseStateTests starts sixteen walks together behind a barrier, fifty rounds,
over prebuilt 200-state chains; with the statics restored every run failed
(several hundred of 800 walks wrong or throwing); with locals, clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:12:41 +02:00
Meta Vens
ff75b8ea34 Scenarios Cut 1: a stance survives docking; it goes only with an entity gone for good
Every entity's zone-removal subscription dropped its IFF override for whatever
left the zone, and TryDock removes the docking ship, so a scenario stance was
lost one way (the other entity forgot; the docked ship, deactivated, kept its
own) as soon as the player docked. The removal event already tells the two
apart: TryDock parents the ship to its dock before removing it, and every
leaving-for-good path (death, warp, unload) leaves with no parent. The override
is now dropped only for an entity that leaves unparented.

Tests: a hostile stance holds both ways, including the live hostility each side
tracks, across dock and undock; a destroyed entity takes the player's stance
toward it with it. UndockingAdmitsWithNoAgent commits Soul's probe: undock
admits unpiloted, active, with no agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:09:12 +02:00
Meta Vens
72c58d365f Scenarios Cut 1: a bare hull updates
Entity.Update walks _orderedEquipment, which only TryEquip and TryUnequip
set, so an entity nothing was ever equipped on threw a
NullReferenceException on its first tick. Scenario presets stage bare hulls
(a turret, an unpiloted target), and RunStartTests found it: MapEntity now
sets the ordered equipment to the hull alone when it adds the hull.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:52:44 +02:00
Meta Vens
85ebe036e1 Scenarios Cut 1: RunStart stages a new run; the Scenario catalog type
RunStart (ServerShared, no Unity type) owns what a new run starts with
(docs/scenarios-cut.md, 2.2):
- GenerateArena: the galaxy's entrance pack, generated under the scenario's
  Ambient rule. ZoneGenerator.GenerateZone gains `ambient`; false keeps
  planets, orbits and stations and generates no ship, turret or wanderer.
- Check: every failure staging a scenario would hit, found with no zone,
  touching no store.
- Stage: all-or-nothing. With no scenario, the generated starting-hull ship
  at the origin, as StartGame built it. With one, the player preset and the
  entities at their authored place and heading with cargo, stance set both
  ways through SetIff, a Minion for each piloted one; all through Zone.Admit.

Scenario (aetheria.scenario v1) joins AetheriaStores.CatalogTypes.
RunStartTests stage into a real prelude arena from the shipped catalog and
authored settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:35:18 +02:00
Meta Vens
5706b657e3 Scenarios Cut 1: Materialize builds product-less designs unbranded; the hull picks the entity
Two rules on Loadouts.Materialize (docs/scenarios-cut.md, 2.2, Q3):
- A design no product makes is outside the economy and is built unbranded,
  with no maker, at Loadouts.UnbrandedQuality (.95, the console give's).
  A design whose products are all unavailable still fails, as before.
- The hull's type picks the entity: a ship hull builds a Ship, a turret hull
  an OrbitalEntity with no orbit; a station hull is refused. Materialize now
  returns Entity.

Loadouts.Resolve is the one rule for how a design is built; RunStart uses it
for scenario cargo. LoadoutTests.MissingDesignReports no longer uses the
product-less "Orphan" as a failure (it now builds); two tests pin the Q3 rule
and the unavailable-product contract it does not loosen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:00 +02:00
Meta Vens
fbda4c74fb Scenarios Cut 1: Zone.Admit is the one admission into a zone
Zone.Admit(entity, piloted) adds the entity to Entities, activates it and,
when piloted, gives it the zone's Minion. It is the only writer of
Entities.Add and the only creator of agents (docs/scenarios-cut.md, 2.2).
The constructor's pack loop admits every packed ship but the player's as
piloted and keeps its own origin-scatter rule, which is generation
placement, not admission. Entity.TryUndock admits the undocked ship
unpiloted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:32:59 +02:00
Meta Vens
34319b0df1 Ship S2 fixes: RequireDesigns refuses nulls explicitly, and a failing Compose falls back to the shipped catalog
- RequireDesigns names the null (entity, item, zone record, lot, lot ledger) instead of catching NullReferenceException.
- ResolveCatalog reports a Compose-level failure as an exclusion covering all mods and boots the shipped catalog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:06:16 +02:00
Meta Vens
54917bb39d Ship S2 fixes: structural anchors are not mounts, mod presets refused, one bad mod never bricks the boot
- Validate: a hardpoint's anchor role is fixed by its type (thruster-emitter, radiator-mesh, else articulation); ShipModPlan's
  duplicate role check is deleted.
- Loadouts.Commit refuses a preset naming a mod design (mod-hull:/mod-ship:), so the shipped catalog never references a mod.
- Compose quarantines each package: a bad package is excluded and named, a collision excludes every package involved; the
  game boots on the rest (or the shipped catalog) and the main menu lists the exclusions.
- RequireDesigns refuses a save with a null item or lot instead of throwing NullReferenceException.
- Tests: per structural role, muzzle id ties, every slot/zone/lot in RequireDesigns, listing order, exclusion cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 08:30:52 +02:00
Meta Vens
7eebd21d29 Ship S2: commit the .meta files for ShipModShips and ShipModPlan
Unity generated them on import; without them the two scripts' GUIDs are
unpinned and differ per checkout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 08:20:33 +02:00
Meta Vens
460bb85ed0 Ship S2: the game composes and loads mod ships
Boot opens the derived catalog when GameData/Mods holds packages
(ShipModCatalog.ResolveCatalog), preloads each hull's GLB into an assembled
inactive prototype (ShipModShips), and LoadEntity clones it when the hull names
a Visual. ShipModPlan decides which anchor fills which ShipInstance slot; a
ShipModTemplate prefab carries the shared effects. Continue refuses a run that
names a design the catalog no longer holds (RunSave.RequireDesigns).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:43:52 +02:00
Meta Vens
f470c1b775 Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.

A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.

The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.

Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.

Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
Meta Vens
1702e0549f Ship S1: one hull record, and a hull names its visual
A mod ship file now holds a HullData at mod-hull:<id> and the ShipAuthoring
visual package at mod-ship:<id>. HullData.Visual (key 32) is the typed ref
between them; ShipAuthoring.Hull (key 1) is retired.

- One joint validator over (hull, visual) is the only semantic check; Load
  owns the file structure (one hull, one visual, deterministic keys).
- Compose copies the mod store's records verbatim, so the derived catalog
  holds one hull per mod ship.
- ship_cc.py edits the hull record's slots directly; the body[HULL_SLOT]
  indirection is gone.
- The package fixture is generated from ShipAuthoringTests.Fixture() (glTF
  scene included) and exported when AETHERIA_SHIP_FIXTURE_DIR is set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 03:06:43 +02:00
Meta Vens
18e12d81ce Merge master (fire control 12) into codex/moddable-ships 2026-09-30 02:21:09 +02:00
Meta Vens
fe38f28865 Close the gaps a mutation pass found in the new ship tests (S-Soul F1-F3)
A pass of 137 hand-written mutations over ShipAuthoring.cs, ShipModCatalog.cs
and ship_cc.py, run against the tests from the last three commits, left six
surviving or unrunnable. Each is resolved:

- Validator: the hull Width/Height < 1 guards and the opacity IsInfinity
  guard could not change any outcome (an empty grid has no occupied cell;
  +Infinity exceeds 1 and -Infinity is below 0). Deleted.
- Two accepted shapes had no test: a hardpoint footprint with empty cells
  beside occupied ones, and an empty ParentId meaning no parent. Added.
- The GLB header length was pinned only from above. Added the shorter case.
- Compose's working-directory cleanup was invisible because the assertion
  listed files, not directory entries. It now lists both.
- ReadPackage runs the catalog's item validation on the hull (the derived
  catalog must reopen); that call now has a test.
- Python: a single non-ship record, a non-array payload long enough to fill
  every slot, and a hardpoint-only change to the layout revision each had a
  surviving mutation. Tests added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 02:14:10 +02:00
Meta Vens
1987009ba1 Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6)
Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now
covers the derived catalog, the atomic replace of an existing one, every
failure path leaving the previous derived file byte-identical, the unsafe
source/output refusals, package binding (directory name, GLB existence and
extension, anchor node presence), and the GLB reader (each header guard, the
JSON chunk boundary, node index accounting, duplicate ids).

Writing them exposed three defects in Compose, fixed here:

- F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped
  catalog, and CultCache indexes names per concrete type with last writer
  winning while GetByName over a base type throws on a cross-subtype match.
  Compose checked keys only, so a mod hull named like a shipped item, or like
  another mod's hull, silently shadowed it or broke lookups. It now refuses
  a hull name matching any catalog ItemData and a ship ID matching an
  existing ShipAuthoring, naming the ship.
- The backing store writes a .lock file beside every file it opens, so each
  Compose left <output>.<guid>.tmp.lock behind, success or failure, once per
  boot. The working copy now lives in its own directory that is deleted whole.
- Deleted: the duplicate-ID check (ReadPackage already pins ID to directory
  name, so it could not fire) and ReadPackage's containment StartsWith
  (Validate already refuses rooted and `..` model paths on the same Read).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:47:13 +02:00
Meta Vens
742cb23f5b Gate a charged weapon's Activate on CanFire, and start a burst's cooldown with the tick it flew in
A released charge has a burst pending and no cooldown until its first round, so a second Activate in that
window started a second charge; Activate now asks CanFire. The round that starts a burst's cooldown does so
after the tick's decrement, which made every cooldown one tick long; it now charges that tick to the cooldown.
Pins both, and that a burst triggered after a fired one is not ready before its first round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:15:51 +02:00
Meta Vens
136fddf15a Start the cooldown at a burst's first unrefused round; refused charged weapons never charge
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>
2026-09-29 21:16:52 +02:00
Meta Vens
57c2560fc0 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
Meta Vens
a193853d34 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
Meta Vens
24b4071b69 Arming distance: no round bursts nearer than its blast radius past the shooter's hull
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>
2026-09-29 17:05:14 +02:00
Meta Vens
9c0029931a Presentation follows the committed shot, not the selection (F7)
EntityInstance handed effect managers the shooter's selected target whatever
FireControl decided. A fused round whose target was invalid or out of arc
flies at none, and GuidedProjectileManager dropped a launcher shot with no
target entirely, so a real burst had no missile.

- Zone.TryGetShot: a presentation reads the shot the simulation committed
  (its target, its burst point) by the ShotId OnFire hands it.
- EntityInstance passes the shot's own target; a launcher round with none
  flies to the shot's burst point.

Unity-only; compile-checked by reading.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 16:27:54 +02:00
Meta Vens
640fa96787 Fused rounds fire out of arc; safe fuse; proximity misses type as Burst
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>
2026-09-29 16:27:46 +02:00
Meta Vens
f5c6729e7a 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
Meta Vens
e3c9cbb282 Refuse a fused weapon whose Range does not exceed its BlastRadius
Soul: a no-lock round bursts at Range, so Range <= BlastRadius (or a zero
Range) lands the blast on the shooter. Made structurally impossible at the
catalog rather than policed at runtime: StatValidation.ValidateFuse joins
the checks CultRecordRefs.Validate runs on every Upsert, at the weakest value
the Range stat can take. Only a weapon that will detonate (a Fuse and a
BlastRadius above zero) is held to it.

AetheriaStores.Open now calls that same Validate for every equippable and
consumable document instead of carrying its own copy of the three checks, so
Upsert and Open can no longer disagree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:27:42 +02:00
Meta Vens
8c00263740 A fused shot with no target bursts at max range along the aim line
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>
2026-09-29 12:28:10 +02:00
Meta Vens
4629bc6799 Fire control 12.4 closing batch: Death fires once; pin the blast loop's multi-kill and untouched-entity cases
Entity.Death, the owner of the stream, now ends after its first cause. A blast that destroyed a
cockpit fired Death, which removed and deactivated the entity, and the blast's own DamageHull then
raised a second Death to every subscriber but Zone's idempotent handler. Every damage path gets the
guard because none of them raise Death themselves.

New pins: one blast killing three ships (a break after the first kill survives the old suite), a
shieldless ship outside the disc receiving nothing, a cockpit-kill blast and a direct hull kill each
raising Death exactly once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 07:36:04 +02:00
Meta Vens
8b4bf01eac Fire control 12.4 fix batch: tangent disc, candidate cull, lethal blast
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>
2026-09-29 03:38:14 +02:00
Meta Vens
24c2964046 Preview typed mod ship visuals and captured LineArt in Unity 2026-09-26 19:10:44 +02:00
Meta Vens
4ba4bbaf2e Compose validated mod ships into a derived catalog 2026-09-26 18:30:02 +02:00
Meta Vens
42eb5214ce Add parallel typed ship authoring and Blender LineArt capture 2026-09-26 18:14:36 +02:00
Meta Vens
e160bbe5e8 Merge restored hull and thruster content 2026-09-26 13:19:42 +02:00
Meta Vens
072e50d275 Cut 1 fix batch 3 (F3, F4): fix the entrance-throw condition, delete the dead FixedPosition copy
F3: the throw fired on selectedStationOrbits.Length == 0, which is also true
whenever stationCount rolls 0 -- an unrelated variable. It now checks
lagrangeCandidates.Length == 0 directly, the actual "no candidate orbit"
case, and the message says so.

F4: CreateLagrangeOrbit's FixedPosition copy and its comment described a
fallback orbit that no longer exists (widening always returns a real,
parented planet orbit). Deleted; nothing reads FixedPosition off a
lagrange/turret orbit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 01:23:43 +02:00
Meta Vens
7c99909d2b Cut 12.4(b): Detonate replaces Splash and Entity.Absorb
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>
2026-09-26 01:17:36 +02:00
Meta Vens
0fe7156d8b Cut 1 fix batch 2 (F2): collapse the entrance station to one orbit selection
Two writers decided the tutorial entrance's station orbit: the floor
(stationCount < 1 -> 1) and a synthesized fallback orbit, each masking
bugs in the other (removing either alone still passed the entrance
test). Collapse to one rule: the floor still guarantees stationCount
>= 1, but its orbit always comes from the same Lagrange selection. When
that selection has no candidate (a zone that is entirely one rosette),
widen it to every parented planet orbit ordinary placement draws from
before the rosette filter, instead of inventing an unparented,
zero-distance orbit -- which gave PlaceTurret's phase a division by
zero and left an orphan orbit record. If even the widened set is empty,
throw loudly instead of silently omitting the entrance's station.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:15:00 +02:00
Meta Vens
2625768f86 Cut 12.4(a): rename AirburstRange to BlastRadius, add WeaponFuse
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>
2026-09-25 19:15:26 +02:00
Meta Vens
c07b4fee65 Cut 12.3 fix batch (F4, F6): delete unreachable guards, fix stale ArmorAbsorb comment
Entity.ItemAbsorb's `item == null` guard is unreachable: both callers (Absorb,
and FireControl.ApplyPooled's Resolve) already guarantee a non-null item
before calling in. Its `total > 0f ? ... : 0f` divide guard is unreachable
too -- the function already returned above whenever total <= 0.1f, so the
divide never sees zero. Both deleted; the surrounding comment now says why
instead of describing a guard that no longer exists.

ArmorAbsorb's own comment was stale from before the "one code path" ruling:
it described the item phase as deferred only for a cell shared with another
lane, when every occupied cell now defers to ItemAbsorb's pool, a lane that
turns out to be the sole contributor included (a pool of one, not a separate
solo path).

Soul's findings (docs/fire-control-cut.md, Cut 12.3): F4, F6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:33:23 +02:00
Meta Vens
64d17a290d Cut 12.3 fix batch (F4): one writer for lane completion, delete dead reachability offset
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>
2026-09-25 18:33:09 +02:00
Meta Vens
3a0b5892c9 Tutorial station: ZoneGenerator.GenerateZone always seats the entrance zone (operator ruling)
Operator, 2026-09-25 (docs/locomotion-cut.md): the tutorial's entrance zone always gets a
generated faction station with a docking bay, deterministically. Structurally, three defects made
that false on every seed: Galaxy.cs halves influence and picks the lowest-influence unowned zone
for Entrance, so stationCount rolled 0; a Lagrange orbit is only available when a planet has an
orbiting sibling, which a boring or empty system never has; and a null station loadout silently
skipped the slot.

GenerateZone now identifies the entrance zone by reference against the galaxy parameter it already
takes (isTutorial && galaxyZone == galaxy.Entrance), never by name:
- stationCount is floored to 1 for the entrance zone, after the roll that produced it.
- When no Lagrange orbit exists, a fallback orbit fixed at half the zone radius from centre is used
  instead of leaving selectedStationOrbits empty. CreateLagrangeOrbit now carries FixedPosition
  through so a Parent-less base orbit (the fallback) still positions correctly.
- A null station loadout throws for the entrance zone instead of continuing past it.
- The enemy count is based on the pre-override station count, so forcing the entrance's station
  doesn't also hand it a bonus enemy.

The story-station path (Locations/LocationStory) is not used: it isn't saved, and
EntitySerializer.cs:77 would break Continue, per the map's own ruling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:40:31 +02:00
Meta Vens
e9339a5673 Cut 12.3 fix batch: one apply path for every item (operator ruling)
Replace the survey-then-branch (ApplySequential vs. ApplyWithSharedItems)
with a single pooled walk. Lanes are clipped to penetration once, right
after Lanes returns; every item -- single-cell or multi-cell -- pools
whatever lanes reach it and resolves the moment no other unfinished lane
can still reach it down its own remaining clipped walk, with no separate
reachability survey. A cyclic dependency between two non-convex items
resolves every currently open pool at once, order-independent by
construction. Entity.ItemAbsorb becomes the one absorption function,
gated on the pooled total; Absorb (Splash's only remaining caller) now
funnels through it with a one-contribution pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:01:05 +02:00
Meta Vens
567579136a Cut 12.3 fix batch: proportional absorption for multi-cell items shared between lanes
Operator ruling (2026-09-25): "proportional absorption; go". A multi-cell item
struck by several lanes of one shot now absorbs their combined incoming damage
(up to its own remaining durability) and each lane's leftover is in proportion
to what it brought, replacing the old behaviour where whichever lane Apply
happened to visit first drained the item and the next lane inherited whatever
was left.

Entity.Absorb is split into ArmorAbsorb (per-cell, never shared) and ItemAbsorb
(the single-lane path), so FireControl.Apply can call armour immediately and
defer item resolution only for a cell whose item is reachable by more than one
lane this shot. FireControl.Apply gains a geometric survey (which items are
reachable by >1 lane, decided before any damage is applied) and takes the
untouched sequential walk (ApplySequential) whenever nothing is shared, or a
new coordinated walk (ApplyWithSharedItems) that deposits each lane's
post-armour remainder into a shared item's pool and resolves it once every
contributing lane has arrived, splitting the leftover proportionally
(sum-then-split, order-independent by construction). A deterministic tie-break
(smallest first-contact Entry) breaks the rare "opposite order" deadlock two
non-convex shared items can create between two lanes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:16:53 +02:00