Merge master (fire control) into codex/mining

Conflict in Zone.cs: kept master's TryGetShot, kept Cut 1's deletion of the BeltUpdates field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 02:12:50 +02:00
commit fc90e93618
57 changed files with 12116 additions and 822 deletions

View file

@ -29,22 +29,10 @@ public static class AetheriaStores
cache.AllStoredDocuments.All(stored => stored.Descriptor != descriptor));
if (missing != null)
throw new InvalidOperationException($"Catalog {catalogPath} has no {missing.SchemaName} record; catalog globals are authored, never invented.");
// R-heat (docs/stats-and-power-cut.md): every equippable design's heat response must describe a
// coherent range before anything reads it. Fails loudly, naming the item.
foreach (var data in cache.GetAll<EquippableItemData>())
{
StatValidation.ValidateHeatResponse(data);
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
// Cut 7 (docs/stats-and-power-cut.md): a stat naming a role its design lacks is refused.
StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors);
}
// Cut 2 (docs/stats-and-power-cut.md): a consumable's own StatModifierData needs the same check;
// consumables carry no heat response, so this is not folded into the loop above.
foreach (var data in cache.GetAll<ConsumableItemData>())
{
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors);
}
// Every catalog document is held to the one check Upsert applies on write: R-heat (docs/stats-and-power-
// cut.md), stat modifiers, role usage (Cut 7). Fails loudly, naming the item.
foreach (var data in cache.GetAll<EquippableItemData>()) CultRecordRefs.Validate(data);
foreach (var data in cache.GetAll<ConsumableItemData>()) CultRecordRefs.Validate(data);
return cache;
}
catch

View file

@ -57,10 +57,12 @@ public class CombatState : BaseState
var group = _agent.Ship.WeaponGroups[i];
var dps = 0f;
foreach (var weapon in group.weapons)
if (weapon.Item.Online.Value &&
weapon.MinRange < targetDistance &&
targetDistance < weapon.Range &&
(weapon is ConstantWeapon ||
// The range test is FireControl's (Designated), and a weapon Solve refuses adds nothing to a group's
// damage.
if (weapon.Item.Online.Value &&
FireControl.Designated(weapon, _agent.Ship, target, out _) &&
!FireControl.Refuses(weapon, _agent.Ship) &&
(weapon is ConstantWeapon ||
weapon is InstantWeapon instantWeapon && instantWeapon.CanFire))
{
if(weapon is LockWeapon lockWeapon)
@ -111,13 +113,12 @@ public class CombatState : BaseState
toTarget = normalize(predictedPosition - _agent.Ship.Position);
}
// Cut 3: replaces Cut 1's bare InArc, which HitProbability already subsumes (it returns zero out
// of arc) -- this is the AI's own "worth it" heuristic layered on top of the shared gate, not a
// second gate.
var shouldFire = FireControl.HitProbability(testWeapon, _agent.Ship, target) >= _agent.Settings.AgentMinHitProbability;
// Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a
// verdict. FireControl.AgentFires is the AI's "worth it" heuristic for a weapon that hits what it
// fires at, and "the target is designated" for a fused one.
foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons)
{
if (shouldFire)
if (FireControl.AgentFires(weapon, _agent.Ship, target))
weapon.Activate();
else if (weapon.Firing)
weapon.Deactivate();

View file

@ -160,7 +160,10 @@ public class ChargedWeapon : InstantWeapon
public override void Activate()
{
if(!_charging && !_coolingDown)
// CanFire also holds a weapon whose released charge has a burst pending: its cooldown starts at its first round,
// so a charge started before then would overlap it.
// A refused weapon never starts charging: it would pay charge heat and audio for a shot Trigger drops.
if(!_charging && CanFire && !FireControl.Refuses(this, Entity))
{
OnStartCharging?.Invoke();
Item.FireAudioEvent(ChargedWeaponAudioEvent.Start);

View file

@ -91,7 +91,13 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
// Cut 5 (docs/fire-control-cut.md, 5.3, Soul finding 9): StanceAllowsFire alone let a side-mounted beam
// fire forward -- ArcAllowsFire joins it here, matching InstantWeapon.cs's player arc gate (Q2: manual and
// programmatic are one truth, and a continuous weapon is no exception).
public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f;
// Operator ruling 2026-09-30: a refused round is free, and a beam is a stream of rounds, so a fused beam whose
// arming distance exceeds its Range fires nothing and draws nothing -- no power, ammo, wear, heat or
// visibility. FireControl.Refuses is the one arming test; it reads the Range Execute last refreshed, the same
// one-tick lag every PowerRequest reads its stats with.
private bool MayFire => StanceAllowsFire && ArcAllowsFire && !FireControl.Refuses(this, Entity);
public float PowerRequest(float dt) => _firing && MayFire ? EvaluateNominalPower(_data.Energy) * dt : 0f;
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon.
public int DefaultPowerTier => PowerTiers.Low;
@ -99,10 +105,11 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
public override bool Execute(float dt)
{
base.Execute(dt);
if (_firing && !(StanceAllowsFire && ArcAllowsFire))
if (_firing && !MayFire)
{
// Safed: shooter has a target and hasn't declared hostility toward it, or the target no longer
// bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does).
// bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does), or a fused round is
// refused.
_firing = false;
OnStopFiring?.Invoke();
return false;
@ -187,7 +194,9 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
public override void Activate()
{
if(!_firing && !_reloading)
// A refused beam never starts (operator ruling 2026-09-30): OnStartFiring drives the beam's visuals and
// audio, so starting it only for Execute to stop it a tick later would flash a beam that fires nothing.
if(!_firing && !_reloading && !FireControl.Refuses(this, Entity))
{
_firing = true;
OnStartFiring?.Invoke();

View file

@ -41,6 +41,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
protected readonly InputCapacitor _capacitor = new InputCapacitor();
protected int _burstRemaining;
private bool _burstPaid;
private bool _burstStarted;
private float _burstTimer;
private float _burstInterval;
protected float _cooldown; // Normalized
@ -52,7 +54,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
public float Cooldown { get; protected set; }
public virtual bool CanFire
{
get => !_coolingDown;
// A triggered burst that has not yet fired a round is not ready either: its cooldown has not started.
get => !_coolingDown && (_burstStarted || _burstRemaining == 0);
}
public override float DamagePerSecond => Damage / Cooldown;
@ -103,16 +106,20 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
// through -- a player's action-bar Activate() reaches this exactly the same way Combat.cs's and
// TurretController.cs's Activate() calls do.
if (!ArcAllowsFire) return;
// A refused round is free (operator ruling 2026-09-30): no ammo, energy or cooldown is spent on a burst
// whose first round FireControl would refuse.
if (FireControl.Refuses(this, Entity)) return;
// If 1 ammo is consumed per burst, perform ammo and energy consumption here
// UseAmmo returns false when triggering reload; cancel firing if that is the case
if(_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) return;
// A burst that pays once (SingleAmmoBurst) pays at its first round that is not refused, in Execute, judged
// with the Range that round flies with: paying here would judge with the Range of the previous tick, and a
// Range that fell in between would spend the ammo on rounds Fire then refuses.
_burstPaid = false;
_burstRemaining = (int) BurstCount;
_burstInterval = BurstTime / _burstRemaining;
_burstTimer = 0;
_cooldown = 1;
_coolingDown = true;
// The cooldown starts with the burst's first round that is not refused (Execute), not here: a burst whose
// every round is refused fires nothing and leaves no cooldown behind.
_burstStarted = false;
}
protected override void UpdateStats()
@ -227,16 +234,32 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
_burstTimer += dt;
while (_burstRemaining > 0 && _burstTimer > 0)
{
// If multiple ammo is consumed per burst, perform ammo and energy consumption here
// UseAmmo returns false when triggering reload; cancel firing if that is the case
if (!_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo()))
// A fused round whose arming distance Range cannot reach is refused, per round, before anything is
// spent (operator ruling 2026-09-30): it costs no ammo, energy, sound, heat, wear or visibility.
// The decision is FireControl's; Fire makes the same one.
var refused = FireControl.Refuses(this, Entity);
// Ammo and energy are consumed here, per round, or once per burst at its first unrefused round
// (SingleAmmoBurst). UseAmmo returns false when triggering reload; cancel firing if that is the case
if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo()))
{
_burstRemaining = 0;
return false;
}
if (!refused)
{
_burstPaid = _data.SingleAmmoBurst;
if (!_burstStarted)
{
_burstStarted = true;
_cooldown = 1 - dt / Cooldown;
// The round starts the cooldown after this tick's decrement above: charge the tick to it, so the cadence stays Cooldown
_coolingDown = true;
}
}
_burstRemaining--;
_burstTimer -= _burstInterval;
if (refused) continue;
// Cut 3: this is fire authority's one entry point. FireControl.Fire freezes the payload snapshot
// (Q6 -- the gun's own stats at this exact instant, base.Execute(dt) above already refreshed them
// this tick) and queues a PendingShot; nothing downstream re-evaluates a stat.

View file

@ -73,16 +73,12 @@ public class TurretController : Behavior, IInitializableBehavior
}
else
Entity.LookDirection = normalize(diff);
var dist = length(diff);
foreach (var x in _weapons)
{
var data = x.Data as WeaponData;
// Cut 3 (docs/fire-control-cut.md): the same gate and threshold Combat.cs uses -- HitProbability
// already subsumes the bare arc test (zero out of arc), so this is the "worth it" heuristic, not
// a parallel arc check.
var fire = FireControl.HitProbability(x, Entity, Entity.Target.Value) >= Entity.ItemManager.GameplaySettings.AgentMinHitProbability;
if (x.Evaluate(data.Range) > dist && fire)
// The same per-weapon decision Combat.cs makes; the range test is part of it (a shot out of range
// is not designated and prices at zero).
if (FireControl.AgentFires(x, Entity, Entity.Target.Value))
{
x.Activate();
}

View file

@ -107,16 +107,8 @@ public abstract class Weapon : Behavior, IActivatedBehavior
// gate, not the gate itself. With no target set, behaviour is unchanged (nothing to bear on).
// Cut 5, 5.4 (Soul finding 11): the point-blank special case this used to carry moved into InArc itself --
// one bearing test, one owner, so every caller (not just this one) gets a point-blank shot that bears.
public bool ArcAllowsFire
{
get
{
var target = Entity.Target.Value;
if (target == null) return true;
var toTarget = target.Position - Entity.Position;
return FireControl.InArc(Item, toTarget);
}
}
// FireControl owns the gate, including its one exemption: a fused weapon fires out of arc.
public bool ArcAllowsFire => FireControl.ArcPermitsFire(this, Entity);
public Weapon(WeaponData data, EquippedItem item) : base(data, item)
{

View file

@ -329,54 +329,78 @@ public abstract class Entity
? TargetItem.Value
: null;
// Cut 3 (docs/fire-control-cut.md): the damage rule moved verbatim from EntityInstance.cs's Unity
// HullCollider subscription (DamageSchematic) -- same arithmetic, same order, same thresholds. The only
// thing that changed is the owner: this entity's own schematic, armor and hull, not a Unity component
// reacting to a physics collision.
public void DamageSchematic(float damage, Shape hitShape)
// Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of
// what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so
// Cut 12.3's fix batch (proportional multi-lane item absorption, below) can call the armour phase once per
// cell -- always local to one lane, since armour never spans cells -- while deferring the item phase to
// ItemAbsorb's own pool for EVERY occupied cell, not only ones shared with another lane: a lane that turns
// out to be an item's only contributor still pools through a list of one (ItemAbsorb's own degenerate
// case), never a separate solo path. ArmorDamage fires only for incoming > 0.
public float ArmorAbsorb(int2 cell, float damage)
{
var hullData = ItemManager.GetData(Hull) as HullData;
foreach (var v in hitShape.Coordinates)
hitShape[v] = hitShape[v] && hullData.Shape[v];
var d = damage;
float hullDamage = 0;
var damagePerCell = damage / hitShape.Coordinates.Length;
foreach (var v in hitShape.Coordinates)
if (d > 0f)
{
var d = damagePerCell;
// Subtract surface damage from armor, passing on the remainder to the item and then to the hull
var prev = Armor[v.x, v.y];
Armor[v.x, v.y] = max(prev - d, 0);
ArmorDamage.OnNext((v, d));
d = max(d - prev, 0);
if (d > 0.1f)
{
var item = GearOccupancy[v.x, v.y];
if (item != null)
{
prev = item.EquippableItem.Durability;
item.EquippableItem.Durability = max(prev - d, 0);
ItemDamage.OnNext((item, d));
d = max(d - prev, 0);
}
}
hullDamage += d;
var prevArmor = Armor[cell.x, cell.y];
Armor[cell.x, cell.y] = max(prevArmor - d, 0);
ArmorDamage.OnNext((cell, d));
d = max(d - prevArmor, 0);
}
if (hullDamage > .1f)
return d;
}
// Cut 12.3 fix batch (one apply path, operator ruling 2026-09-25 "do not expect items taking up multiple
// cells to be an exception, this should be one code path"): the ONE function that decides an item's own
// absorption, whatever the item's shape or however many lanes of one shot reach it. `incoming` holds every
// contributing lane's own post-armour remainder for this resolve, one lane's worth included -- a
// single-lane item is this rule's own degenerate case, not a separate function or a separate threshold.
// The existing .1f threshold is decided once, on the POOLED total, never per contribution (a pooled path
// that gated each contribution separately would let two shares under .1f each slip past an item that a
// single .08f solo hit would already have stopped at). Absorbed durability is split back across `incoming`
// in place, pro-rata to what each lane brought -- the divide below never sees total <= 0.1f, so it never
// sees zero (the guard above already returned). ItemDamage fires once per contributing lane, reporting its
// own INCOMING share -- not the post-clamp amount, the same convention ArmorAbsorb already uses -- and only
// when that lane's own share is itself > 0, so a zero-deposit contributor (a lane whose armour ate its
// whole share before reaching this item) never fires a phantom event. `item` is never null here: both
// callers (`FireControl.ApplyPooled`'s `Resolve`, one lane's worth of contributions included, and
// `FireControl.Detonate`'s own per-entity item-pool resolution, one covered cell's worth included) already
// guarantee it -- a pool is only ever opened under a non-null `GearOccupancy` cell.
public void ItemAbsorb(EquippedItem item, Span<float> incoming)
{
var total = 0f;
for (var i = 0; i < incoming.Length; i++) total += incoming[i];
if (total <= 0.1f) return;
var before = item.EquippableItem.Durability;
var absorbed = min(total, before);
item.EquippableItem.Durability = max(before - absorbed, 0f);
var fraction = absorbed / total;
for (var i = 0; i < incoming.Length; i++)
{
Hull.Durability -= hullDamage;
HullDamage.OnNext(hullDamage);
if (incoming[i] > 0f) ItemDamage.OnNext((item, incoming[i]));
incoming[i] -= incoming[i] * fraction;
}
}
// Cut 12.3: moved verbatim from DamageSchematic's own tail (Cut 3) -- the >.1f threshold and the one
// HullDamage event are unchanged. FireControl.Apply calls this once per resolved hit, with the summed
// remainder every lane's own walk left over (Q12-3 = A: a lane's remainder goes to the hull wherever the
// lane ends -- penetration exhausted, a gap, or the far side).
public void DamageHull(float damage)
{
if (damage > .1f)
{
Hull.Durability -= damage;
HullDamage.OnNext(damage);
}
}
// Cut 12.1 (docs/fire-control-cut.md): the one schematic-frame owner. Maps a world-planar vector into this
// entity's own schematic frame -- x = starboard, y = bow -- so ApplyHit's penetration march and
// FireControl.Splash's directional half both read the same transform instead of each carrying their own
// copy of forward/right.
// entity's own schematic frame -- x = starboard, y = bow -- so FireControl.Apply's lane walk and
// FireControl.Detonate's per-entity point conversion both read the same transform instead of each carrying
// their own copy of forward/right.
public float2 ToSchematic(float2 worldPlanar)
{
var forward = normalize(Direction);
@ -384,38 +408,29 @@ public abstract class Entity
return float2(dot(worldPlanar, right), dot(worldPlanar, forward));
}
// Cut 3: the shape construction moved from EntityInstance.cs's Unity HullCollider subscription -- the hit
// cell is the rolled Cell FireControl already chose, not a UV texture coordinate.
// Cut 12.2 (docs/fire-control-cut.md): `bearing` is already in this entity's own schematic frame -- the
// committed ShotOutcome.Bearing FireControl.Commit computed once, at the commit tick -- so the ToSchematic
// call this used to make internally (12.1) is gone; ApplyHit no longer touches a world direction or this
// entity's own Direction at all.
public void ApplyHit(Entity source, int2 cell, float spread, float penetration, float damage, float2 bearing)
// Cut 12.4(b) (docs/fire-control-cut.md, "Area, per entity"): the world<->schematic POINT owners, built on
// ToSchematic's frame -- the entity's own position is the schematic's centre of mass. FireControl.Detonate
// uses ToSchematicPoint to find where a blast sits in each candidate's own schematic (the host of a contact
// or delayed blast included: it is treated like any other entity in the radius, its cells found again
// here, not carried over from the commit). ToWorldPoint is the inverse, used once, to convert a contact or
// delayed fuse point (found on the committed lane, in the host's schematic frame at commit) into the one
// world point Detonate's single input shape takes -- the round trip through the host's own pose at arrival
// is what fixes the host's damage to the commit (R4) while every bystander is judged live.
public float2 ToSchematicPoint(float2 worldPlanar)
{
IncomingHit.OnNext(source);
var hullData = ItemManager.GetData(Hull) as HullData;
var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height);
hitShape[cell] = true;
var cellSize = ItemManager.GameplaySettings.SchematicCellSize;
return ToSchematic(worldPlanar - Position.xz) / cellSize + hullData.Shape.CenterOfMass;
}
for (var i = 0; i < (int) floor(spread + .5f); i++)
hitShape = hitShape.Expand();
if (penetration > .5f)
{
var penetrationVector = normalize(bearing);
var penetrationPoint = (float2) cell + float2(.5f);
var penetrationDistance = 0f;
while (penetrationDistance < penetration && hullData.Shape[int2(penetrationPoint)])
{
penetrationDistance += .5f;
hitShape[int2(penetrationPoint)] = true;
penetrationPoint += penetrationVector * .5f;
}
}
DamageSchematic(damage, hitShape);
public float2 ToWorldPoint(float2 schematicPoint)
{
var hullData = ItemManager.GetData(Hull) as HullData;
var cellSize = ItemManager.GameplaySettings.SchematicCellSize;
var local = (schematicPoint - hullData.Shape.CenterOfMass) * cellSize;
var forward = normalize(Direction);
var right = float2(forward.y, -forward.x);
return Position.xz + local.x * right + local.y * forward;
}
// Another entity's stance toward THIS one, as far as this entity can perceive it: unknown (null)
@ -501,7 +516,10 @@ public abstract class Entity
Death = HullDamage.Where(_ => Hull.Durability < .01f).Select(_ => CauseOfDeath.HullDestroyed)
.Merge(HeatstrokeDeath.Select(_ => CauseOfDeath.Heatstroke))
.Merge(HypothermiaDeath.Select(_ => CauseOfDeath.Hypothermia))
.Merge(ItemDestroyed.Where(i=>i.GetBehavior<Cockpit>()!=null).Select(_ => CauseOfDeath.CockpitDestroyed));
.Merge(ItemDestroyed.Where(i=>i.GetBehavior<Cockpit>()!=null).Select(_ => CauseOfDeath.CockpitDestroyed))
// Death is terminal: the first cause ends the stream, so no damage path (a blast whose cockpit kill is
// followed by its own hull damage, a second item, a heat tick) can raise it twice to any subscriber.
.Take(1);
//CurrentSecurityLevel.Value = SecurityLevel.Open;
}

View file

@ -107,6 +107,15 @@ public enum WeaponModifiers
Cluster = 1 << 6
}
// Cut 12.4(a) (docs/fire-control-cut.md): what a weapon's blast radius (WeaponItemData.BlastRadius) detonates
// as. Null means no blast at all -- WeaponItemData.Fuse is frozen null unless BlastRadius > 0 (FireControl.Fire).
public enum WeaponFuse
{
Contact,
Proximity,
Delayed
}
public enum DamageType
{
Kinetic,

File diff suppressed because it is too large Load diff

View file

@ -490,12 +490,20 @@ public class WeaponItemData : GearData
[Inspectable, JsonProperty("modifiers"), Key(28)]
public WeaponModifiers WeaponModifiers;
// Cut 6b (docs/fire-control-cut.md, 6.2): the authored blast radius an Airburst-flagged weapon's shot
// splashes with at arrival, frozen into PendingShot.BurstRadius at fire. Zero (and unread) for a weapon
// without the Airburst flag. No authored value exists yet -- the flag is wired and unexercised until Cut
// 6a or a later authoring pass sets it.
[Inspectable, JsonProperty("airburstRange"), Key(29)]
public float? AirburstRange;
// Cut 12.4(a) (docs/fire-control-cut.md): the authored blast radius a weapon's shot detonates with, in
// world units. Renamed from slot 29's old airburst-range field -- the slot and type are unchanged, so
// CultCache opens the catalog as compatible drift. Null (every shipped record) means no blast; WeaponModifiers
// no longer decides this (Q12-6, "WeaponModifiers are labels, not behaviour").
[Inspectable, JsonProperty("blastRadius"), Key(29)]
public float? BlastRadius;
// Cut 12.4(a): what kind of blast BlastRadius describes. Nullable under the 7.4 rule -- null means no
// blast, and every shipped record reads null. FireControl.Fire freezes this from the weapon only when
// BlastRadius > 0; a fuse without a radius, or a radius without a fuse, is inert and resolves as a direct
// hit (nothing polices that, per Q12-6). Key 30 and 31 belong to EquippableItemData's OptimalTemperature
// and PlateauWidth, inherited here -- the next free key across WeaponItemData's own ancestry is 32.
[Inspectable, JsonProperty("fuse"), Key(32)]
public WeaponFuse? Fuse;
}
[CultDocument("aetheria.hulldata", "1"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]

View file

@ -43,6 +43,21 @@ public class Zone
private int _nextShotId;
public int NextShotId() => ++_nextShotId;
// The committed shot a presentation was handed the id of by OnFire. It carries what the simulation decided
// -- the target it flies at (null for a round that carries none) and where it bursts -- so a presentation
// reads that rather than the shooter's current selection.
public bool TryGetShot(int shotId, out PendingShot shot)
{
foreach (var pending in PendingShots)
if (pending.ShotId == shotId)
{
shot = pending;
return true;
}
shot = default;
return false;
}
public float Time
{
get => (float) _time;

View file

@ -207,17 +207,39 @@ public static class ZoneGenerator
var storyStations = galaxyZone.Locations.Where(story => story.Type == LocationType.Station).ToArray();
var stationCount = (int)(random.NextFloat() * (factionPresence + 1)) + storyStations.Length;
// Enemy count below is based on this pre-override value, so the tutorial entrance doesn't gain an enemy
// just because its station got forced.
var baseStationCount = stationCount;
// Operator ruling, 2026-09-25 (docs/locomotion-cut.md, "Tutorial station"): the entrance zone always
// gets a generated faction station with a docking bay, deterministically. Galaxy.cs halves influence and
// picks the lowest-influence unowned zone for Entrance, which otherwise rolls stationCount 0 on every
// seed. galaxy.Entrance is reference-identity, not a name comparison.
var isTutorialEntrance = isTutorial && galaxyZone == galaxy.Entrance;
if (isTutorialEntrance && stationCount < 1) stationCount = 1;
var potentialLagrangePoints = planets
.Where(p => p.Parent != null && p.Parent.Children
.TrueForAll(c => !(c != p && abs(c.Distance - p.Distance) < .1f))) // Filter Rosettes
.OrderBy(p => p.Distance)
.ToArray();
// The entrance's own station count is the floor above; its orbit always comes from this same selection.
// Ordinarily that selection is potentialLagrangePoints (non-rosette planet orbits). If the entrance zone
// has none of those (a boring single-body system, or a system that is entirely one rosette), widen to
// every planet orbit ordinary placement's own candidate pool is drawn from before the rosette filter --
// still a real, parented planet orbit with a positive Distance -- rather than inventing an unparented,
// zero-distance orbit (that gave PlaceTurret's phase = 20*multiplier/orbit.Distance a division by zero,
// and left an orbit record with no planet behind it).
var lagrangeCandidates = potentialLagrangePoints;
if (isTutorialEntrance && lagrangeCandidates.Length == 0)
lagrangeCandidates = planets.Where(p => p.Parent != null).OrderBy(p => p.Distance).ToArray();
// Pick a selection from the middle of the distribution
var selectedStationOrbits = potentialLagrangePoints
.Skip(potentialLagrangePoints.Length / 2)
var selectedStationOrbits = lagrangeCandidates
.Skip(lagrangeCandidates.Length / 2)
.Take(stationCount)
.Select(p=>orbitMap[p])
.ToArray();
if (isTutorialEntrance && lagrangeCandidates.Length == 0)
throw new InvalidOperationException(
"The tutorial entrance zone must always get a station, but it has no candidate orbit at all -- every planet in the zone is a parentless root.");
var loadoutGenerators = new Dictionary<Faction, LoadoutGenerator>();
@ -234,7 +256,7 @@ public static class ZoneGenerator
{
Parent = baseOrbit.Parent,
Distance = baseOrbit.Distance,
Phase = baseOrbit.Phase + PI / 3 * sign(random.NextFloat() - .5f)
Phase = baseOrbit.Phase + PI / 3 * sign(random.NextFloat() - .5f),
};
pack.Orbits.Add(cache.Upsert(lagrangeOrbit));
return lagrangeOrbit;
@ -294,7 +316,13 @@ public static class ZoneGenerator
var lagrangeOrbit = CreateLagrangeOrbit(orbit);
var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout();
if (station == null) continue;
if (station == null)
{
if (isTutorialEntrance)
throw new InvalidOperationException(
$"The tutorial entrance zone must always get a station, but {nearestFaction?.Name} has no station loadout to generate.");
continue;
}
station.Orbit = cache.RefOf(lagrangeOrbit);
station.SecurityLevel = security;
station.SecurityRadius = pack.Radius;
@ -303,7 +331,7 @@ public static class ZoneGenerator
PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2);
}
var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + stationCount;
var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount;
for (int i = 0; i < enemyCount; i++)
{
var ship = GetLoadoutGenerator(nearestFaction).GenerateShipLoadout();