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:
commit
fc90e93618
57 changed files with 12116 additions and 822 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue