Merge fire control Cut 3: dice decide hits, not physics

The raycast hit path is gone from Projectile, GuidedProjectile, Laser and
Lightning; FireControl owns whether a shot lands. A shot freezes its gun's
stats at the trigger, commits its outcome a fixed time before impact, and
resolves on arrival against how far the target deviated. Rolls are seeded,
so a seeded engagement reproduces. Five weapon kinds still on the old path
belong to Cut 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-19 14:08:52 +02:00
commit a7c09e06fc
25 changed files with 1092 additions and 432 deletions

View file

@ -200,8 +200,11 @@ public class EntityInstance : MonoBehaviour
else Debug.LogError($"No InstantWeaponEffectManager prefab found for GUID {data.EffectPrefab}");
}
instantWeapon.OnFire += () =>
_instantWeaponManagers[data].Fire(instantWeapon, item, this, entity.Target.Value != null && ZoneRenderer.EntityInstances.ContainsKey(entity.Target.Value) ? ZoneRenderer.EntityInstances[entity.Target.Value] : null);
// Cut 3 (docs/fire-control-cut.md): carries the ShotId FireControl.Fire assigned so the
// effect manager (and whatever it spawns) can bind its presentation to Zone.ShotCommitted /
// ShotResolved instead of deciding or applying anything itself.
instantWeapon.OnFire += shotId =>
_instantWeaponManagers[data].Fire(instantWeapon, item, this, entity.Target.Value != null && ZoneRenderer.EntityInstances.ContainsKey(entity.Target.Value) ? ZoneRenderer.EntityInstances[entity.Target.Value] : null, shotId);
if (behavior is ChargedWeapon chargedWeapon)
{
@ -274,126 +277,10 @@ public class EntityInstance : MonoBehaviour
}
}
void DamageSchematic(float damage, Shape hitShape)
{
foreach (var v in hitShape.Coordinates)
hitShape[v] = hitShape[v] && hullData.Shape[v];
float hullDamage = 0;
var damagePerCell = damage / hitShape.Coordinates.Length;
foreach (var v in hitShape.Coordinates)
{
var d = damagePerCell;
// Subtract surface damage from armor, passing on the remainder to the item and then to the hull
var prev = entity.Armor[v.x, v.y];
entity.Armor[v.x, v.y] = max(prev - d, 0);
entity.ArmorDamage.OnNext((v, d));
d = max(d - prev, 0);
if (d > 0.1f)
{
var item = entity.GearOccupancy[v.x, v.y];
if (item != null)
{
prev = item.EquippableItem.Durability;
item.EquippableItem.Durability = max(prev - d, 0);
entity.ItemDamage.OnNext((item, d));
d = max(d - prev, 0);
}
}
hullDamage += d;
}
if(hullDamage > .1f)
{
entity.Hull.Durability -= hullDamage;
entity.HullDamage.OnNext(hullDamage);
}
}
foreach (var collider in HullColliders)
{
collider.Splash.Subscribe(splash =>
{
var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height);
foreach (var v in hullData.Shape.Coordinates)
{
var localHitDirection = transform.InverseTransformDirection(splash.Direction.ToUnity());
var direction = normalize(float2(localHitDirection.x, localHitDirection.z));
var cellDot = dot(normalize(v - hullData.Shape.CenterOfMass), direction);
if (cellDot < 0) hitShape[v] = true;
}
DamageSchematic(splash.Damage, hitShape);
});
collider.Hit.Subscribe(hit =>
{
Entity.IncomingHit.OnNext(hit.Source);
var hardpointIndex = (int) hit.TexCoord.x - 1;
var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height);
// U coordinate between 0-1 indicates a hit that didn't land directly on a hardpoint
// Find the 2D position of the hit scaled to the schematic
float2 hitPos = float2.zero;
if (hardpointIndex < 0 || hardpointIndex >= hullData.Hardpoints.Count)
{
hitPos = float2(hit.TexCoord.x * hullData.Shape.Width, hit.TexCoord.y * hullData.Shape.Height);
// Search all schematic border cells for the cell which is closest to the hit position
var hitCell = int2(-1);
var distance = float.MaxValue;
foreach (var v in hullData.Shape.Coordinates)
{
var cellDist = lengthsq(hitPos - v);
if (cellDist < distance)
{
distance = cellDist;
hitCell = v;
hitPos = v + float2(.5f);
}
}
hitShape[hitCell] = true;
}
else
{
// Collider UV coordinates starting with 1 correspond to hardpoint index
var hardpoint = hullData.Hardpoints[hardpointIndex];
// Obtain the hull coordinates of all cells occupied by the hardpoint
var hardpointCells = hullData.Shape.Inset(hardpoint.Shape, hardpoint.Position);
hitPos = hardpointCells.CenterOfMass;
foreach (var v in hardpointCells.Coordinates)
hitShape[v] = true;
}
for (int i = 0; i < Mathf.RoundToInt(hit.Spread); i++)
{
hitShape = hitShape.Expand();
}
if (hit.Penetration > .5f)
{
// Find the local 2D vector corresponding to the direction of the incoming hit
var localHitDirection = transform.InverseTransformDirection(hit.Direction.ToUnity());
var penetrationVector = normalize(float2(localHitDirection.x, localHitDirection.z));
// TODO: Bresenham's line algorithm
// March a ray through the ship from the hit position
var penetrationPoint = hitPos;
var penetrationDistance = 0f;
while (penetrationDistance < hit.Penetration && hullData.Shape[int2(penetrationPoint)])
{
penetrationDistance += .5f;
hitShape[int2(penetrationPoint)] = true;
penetrationPoint += penetrationVector * .5f;
}
}
DamageSchematic(hit.Damage, hitShape);
});
}
// Cut 3 (docs/fire-control-cut.md): DamageSchematic and both HullCollider subscriptions (Splash, Hit)
// are deleted -- FireControl decides hits now and Entity.ApplyHit/DamageSchematic (ServerShared) apply
// them, the single owner in place of what used to exist once per Unity effect. HullColliders no longer
// publish Hit/Splash at all (R8).
LookAtPoint = new GameObject($"{entity.Name} Look Point").transform;

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections;
using System.Collections.Generic;
using UniRx;
@ -8,13 +8,26 @@ using static CultMath.math;
using UnityEngine;
using float2 = CultMath.float2;
// Cut 3 (docs/fire-control-cut.md) FORK, reported rather than worked around silently: the map's Cut 3 deletion
// list calls for Hit, Splash, SendHit and SendSplash to go away here outright. They cannot, yet, without
// breaking the build -- ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs
// and Mine.cs (Cut 4's "unused kinds," R9) still call hull.SendHit/SendSplash from their own live Physics
// queries, and Cut 4 is the cut that migrates them. Deleting these methods now is a compile break in
// out-of-scope files, not a behaviour change in this cut's own scope.
//
// What Cut 3 actually owns and has delivered: nothing in Assets/Scripts/Gameplay/Weapons/{Projectile,
// GuidedProjectile,Laser,Lightning}.cs calls SendHit/SendSplash any more (see those files), and
// EntityInstance.cs no longer subscribes to Hit/Splash at all -- so for the four weapon kinds this cut owns,
// these events have no subscriber and decide nothing. They fire into the void until Cut 4 deletes them
// together with their last remaining callers. OnCollisionEnter stays regardless: ship-on-ship collision
// impulse is a deferred Unity-physics surface, not part of fire control.
public class HullCollider : MonoBehaviour
{
public Subject<HullHitEventArgs> Hit = new Subject<HullHitEventArgs>();
public Subject<HullSplashEventArgs> Splash = new Subject<HullSplashEventArgs>();
public Entity Entity { get; set; }
public void SendHit(float damage, float penetration, float spread, DamageType damageType, Entity source, Vector2 texCoord, Vector3 direction)
{
Hit.OnNext(new HullHitEventArgs
@ -28,7 +41,7 @@ public class HullCollider : MonoBehaviour
Direction = direction.ToCultMath()
});
}
public void SendSplash(float damage, DamageType damageType, Entity source, Vector3 direction)
{
Splash.OnNext(new HullSplashEventArgs
@ -55,7 +68,7 @@ public class HullCollider : MonoBehaviour
public float2 TexCoord;
public float3 Direction;
}
public class HullSplashEventArgs
{
public float Damage;
@ -63,4 +76,4 @@ public class HullCollider : MonoBehaviour
public Entity Source;
public float3 Direction;
}
}
}

View file

@ -1,14 +1,16 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using CultMath;
using CultMath.UnityBridge;
using static CultMath.math;
using Random = UnityEngine.Random;
using static Noise1D;
using float3 = CultMath.float3;
// Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already
// decided this shot's fate before this object was ever spawned (R8). The homing/guidance flight (the whole
// point of this presentation) is untouched; children spawned on split carry no damage of their own to apply
// either, since they never did anything but inherit their parent's now-deleted fields.
public class GuidedProjectile : MonoBehaviour
{
public Prototype HitEffect;
@ -27,22 +29,20 @@ public class GuidedProjectile : MonoBehaviour
public float TopSpeed;
public Transform Source;
public Func<Vector3> TargetPosition;
private float _phase;
private float _prevDist;
private bool _active;
private bool _alive;
private Vector3 _targetVelocity;
private Vector3 _previousTargetPosition;
// Cut 3: FireControl.Fire's ShotId -- this missile's handle onto Zone.ShotCommitted/ShotResolved.
public int ShotId { get; set; }
public Transform Target { get; set; }
public float3 StartPosition { get; set; }
public float Range { get; set; }
public Vector3 Velocity { get; set; }
public float Damage { get; set; }
public float Penetration { get; set; }
public float Spread { get; set; }
public DamageType DamageType { get; set; }
public Entity SourceEntity { get; set; }
public event Action OnKill;
@ -112,10 +112,6 @@ public class GuidedProjectile : MonoBehaviour
var perpendicularRandom = randomDirection.x * right + randomDirection.y * up;
child.Velocity = (normalize(lerp(perpendicularRandom, dir.ToCultMath(), SplitSeparationForwardness)) * length(Velocity.ToCultMath()) * SplitSeparationVelocity).ToUnity();
child.Range = Range;
child.Damage = Damage / Children;
child.Penetration = Penetration;
child.Spread = Spread;
child.DamageType = DamageType;
child.Source = Source;
child.Target = Target;
child.SourceEntity = SourceEntity;
@ -149,53 +145,6 @@ public class GuidedProjectile : MonoBehaviour
if(_alive)
{
var ray = new Ray(t.position, Velocity);
foreach (var hit in Physics.RaycastAll(ray, Velocity.magnitude * Time.deltaTime, 1 | (1 << 17)))
{
var shield = hit.collider.GetComponent<ShieldManager>();
if (shield)
{
var shieldBehavior = shield.Entity.Shield;
var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value;
var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage);
// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now --
// read once into shieldAbsorbs, and break the shield exactly once, right here, at the point
// this hit is actually decided to route past it (to the hull collider RaycastAll returns
// further down this same loop) instead of being absorbed. The hull branch below re-queries
// CanTakeHit safely -- it is side-effect-free -- and will see Broken already true.
if (shieldActive && !shieldAbsorbs) shieldBehavior.Break();
if (!shieldAbsorbs) continue;
if (shield.Entity != SourceEntity)
{
shieldBehavior.TakeHit(DamageType, Damage);
shield.ShowHit(hit.point, sqrt(Damage));
}
}
var hull = hit.collider.GetComponent<HullCollider>();
if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage)))
{
if (hull.Entity != SourceEntity)
{
hull.SendHit(Damage, Penetration, Spread, DamageType, SourceEntity, hit.textureCoord, transform.forward);
transform.position = hit.point;
StartCoroutine(Kill());
}
}
else// if (hit.transform.gameObject.layer == 1)
{
StartCoroutine(Kill());
return;
}
if (HitEffect != null)
{
var ht = HitEffect.Instantiate<Transform>();
ht.SetParent(hit.collider.transform);
ht.position = hit.point;
return;
}
}
t.position += Velocity * Time.deltaTime;
}
}

View file

@ -13,12 +13,13 @@ public class GuidedProjectileManager : InstantWeaponEffectManager
public Subject<(Entity source, Transform target, GuidedProjectile missile)> OnFireGuided = new Subject<(Entity source, Transform target, GuidedProjectile missile)>();
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target)
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
{
if(weapon.Data is LauncherData launcher)
{
if (target == null) return;
var p = ProjectilePrototype.Instantiate<GuidedProjectile>();
p.ShotId = shotId;
p.Source = source.transform;
p.SourceEntity = source.Entity;
p.Target = target.transform;
@ -26,11 +27,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var barrel = source.GetBarrel(hp);
p.StartPosition = (p.transform.position = barrel.position).ToCultMath();
p.Damage = weapon.Damage;
p.Range = weapon.Range;
p.Penetration = weapon.Penetration;
p.Spread = weapon.DamageSpread;
p.DamageType = weapon.WeaponData.DamageType;
p.GuidanceCurve = launcher.GuidanceCurve.ToCurve();
p.LiftCurve = launcher.LiftCurve.ToCurve();
p.ThrustCurve = launcher.ThrustCurve.ToCurve();
@ -42,17 +39,14 @@ public class GuidedProjectileManager : InstantWeaponEffectManager
else if(weapon.Data is GuidedWeaponData guidance)
{
var p = ProjectilePrototype.Instantiate<GuidedProjectile>();
p.ShotId = shotId;
p.Source = source.transform;
p.SourceEntity = source.Entity;
p.Frequency = guidance.DodgeFrequency;
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var barrel = source.GetBarrel(hp);
p.StartPosition = (p.transform.position = barrel.position).ToCultMath();
p.Damage = weapon.Damage;
p.Range = weapon.Range;
p.Penetration = weapon.Penetration;
p.Spread = weapon.DamageSpread;
p.DamageType = weapon.WeaponData.DamageType;
p.GuidanceCurve = guidance.GuidanceCurve.ToCurve();
p.LiftCurve = guidance.LiftCurve.ToCurve();
p.ThrustCurve = guidance.ThrustCurve.ToCurve();

View file

@ -3,7 +3,7 @@ using UnityEngine;
public class HitscanManager : InstantWeaponEffectManager
{
public Prototype Prototype;
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target)
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
{
var p = Prototype.Instantiate<HitscanEffect>();
p.SourceEntity = source.Entity;

View file

@ -4,5 +4,8 @@ using UnityEngine;
public abstract class InstantWeaponEffectManager : MonoBehaviour
{
public abstract void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target);
// Cut 3 (docs/fire-control-cut.md): shotId is FireControl.Fire's ShotId (docs/fire-control-cut.md, 0b
// table) -- the presentation's handle onto Zone.ShotCommitted / ShotResolved for this specific shot.
// Implementations apply nothing; they perform whatever the shot's outcome says once it resolves.
public abstract void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId);
}

View file

@ -1,22 +1,24 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static CultMath.math;
// Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already
// rolled and, for a velocity-0 weapon like this one, already resolved this shot before the beam was ever
// drawn (a short flight commits and resolves in the same Zone.Update tick as Fire). The beam endpoint is
// presentation, rebuilt from the target's current position rather than a physics hit -- it no longer decides
// anything (R8).
public class Laser : MonoBehaviour
{
public AnimationCurve IntensityCurve;
public float Duration;
public LineRenderer LineRenderer;
public float Damage { get; set; }
public float Penetration { get; set; }
public float Spread { get; set; }
public DamageType DamageType { get; set; }
public Entity SourceEntity { get; set; }
public float Range { get; set; }
// Cut 3: FireControl.Fire's ShotId. Unused by this simple endpoint-only presentation today, kept so a
// future pass can draw the beam to the exact rolled cell instead of the target's overall position.
public int ShotId { get; set; }
public Transform TargetTransform { get; set; }
private float _startTime;
private readonly Vector3[] _zeros = {Vector3.zero, Vector3.zero};
@ -34,47 +36,12 @@ public class Laser : MonoBehaviour
GetComponent<Prototype>().ReturnToPool();
return;
}
LineRenderer.SetPosition(0, transform.position);
bool hitFound = false;
foreach (var hit in Physics.RaycastAll(new Ray(transform.position, transform.forward), Range, 1 | (1 << 17)))
{
var shield = hit.collider.GetComponent<ShieldManager>();
if (shield)
{
var shieldBehavior = shield.Entity.Shield;
var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value;
var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage);
// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- read
// once into shieldAbsorbs, and break the shield exactly once, right here, at the point this hit
// is actually decided to route past it (to the hull collider RaycastAll returns further down
// this same loop) instead of being absorbed. The hull branch below re-queries CanTakeHit safely
// -- it is side-effect-free -- and will see Broken already true.
if (shieldActive && !shieldAbsorbs) shieldBehavior.Break();
if (!shieldAbsorbs) continue;
if (shield.Entity != SourceEntity)
{
shieldBehavior.TakeHit(DamageType, Damage);
shield.ShowHit(hit.point, sqrt(Damage));
LineRenderer.SetPosition(1, hit.point);
hitFound = true;
break;
}
}
var hull = hit.collider.GetComponent<HullCollider>();
if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage)))
{
if (hull.Entity != SourceEntity)
{
hull.SendHit(Damage * (Time.deltaTime / Duration), Penetration, Spread, DamageType, SourceEntity, hit.textureCoord, transform.forward);
LineRenderer.SetPosition(1, hit.point);
hitFound = true;
break;
}
}
}
if(!hitFound)
LineRenderer.SetPosition(1, transform.position + transform.forward * Range);
var endpoint = TargetTransform != null
? TargetTransform.position
: transform.position + transform.forward * Range;
LineRenderer.SetPosition(1, endpoint);
LineRenderer.widthMultiplier = IntensityCurve.Evaluate(lerp);
}

View file

@ -6,7 +6,7 @@ public class LaserManager : InstantWeaponEffectManager
{
public Prototype ProjectilePrototype;
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target)
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
{
var p = ProjectilePrototype.Instantiate<Laser>();
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
@ -15,11 +15,9 @@ public class LaserManager : InstantWeaponEffectManager
t.SetParent(barrel);
t.forward = barrel.forward;
t.position = barrel.position;
p.ShotId = shotId;
p.SourceEntity = source.Entity;
p.Damage = weapon.Damage;
p.Range = weapon.Range;
p.Penetration = weapon.Penetration;
p.Spread = weapon.DamageSpread;
p.DamageType = weapon.WeaponData.DamageType;
p.TargetTransform = target != null ? target.transform : null;
}
}

View file

@ -1,76 +1,30 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using CultMath;
using static CultMath.math;
using Random = UnityEngine.Random;
using static CultMath.math;
// Cut 3 (docs/fire-control-cut.md): the SphereCast, shield branch and SendHit are deleted -- FireControl
// already rolled and resolved this shot before Fire() below ever ran (a velocity-0 weapon like this one
// commits and resolves in the same tick it fires). The endpoint capture stays as a visual: it now just tracks
// the target's transform instead of a physics hit (R8).
public class Lightning : MonoBehaviour
{
public LightningCompute LightningCompute;
public float HitRadius;
public float Damage { get; set; }
public float Penetration { get; set; }
public float Spread { get; set; }
public DamageType DamageType { get; set; }
public EntityInstance Source { get; set; }
public float Range { get; set; }
public EntityInstance Target { get; set; }
public Transform Barrel { get; set; }
private bool _colliderHit;
private Vector3 _colliderLocalPosition;
private Transform _colliderTransform;
// Cut 3: FireControl.Fire's ShotId. Unused by this simple endpoint-only presentation today, kept so a
// future pass can draw to the exact rolled cell instead of the target's overall position.
public int ShotId { get; set; }
private Vector3 _endpoint;
public void Fire()
{
_colliderHit = false;
LightningCompute.OnLeaderComplete = null;
LightningCompute.FixedEndpoint = false;
var hits = Physics.SphereCastAll(Barrel.position, HitRadius, Barrel.forward, Range, 1 | (1 << 17));
foreach (var hit in hits)
{
var shield = hit.collider.GetComponent<ShieldManager>();
if (shield)
{
var shieldBehavior = shield.Entity.Shield;
var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value;
var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage);
// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- read
// once into shieldAbsorbs, and break the shield exactly once, right here, at the point this hit
// is actually decided to route past it (to the hull collider SphereCastAll returns as a
// separate entry further down this same loop) instead of being absorbed. The hull branch below
// re-queries CanTakeHit safely -- it is side-effect-free -- and will see Broken already true.
if (shieldActive && !shieldAbsorbs) shieldBehavior.Break();
if (shieldAbsorbs)
{
if (shield.Entity == Source.Entity) continue;
LightningCompute.OnLeaderComplete = () =>
{
shieldBehavior.TakeHit(DamageType, Damage);
shield.ShowHit(hit.point, sqrt(Damage));
};
}
}
var hull = hit.collider.GetComponent<HullCollider>();
if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage)))
{
if (hull.Entity == Source.Entity) continue;
LightningCompute.OnLeaderComplete = () =>
hull.SendHit(Damage, Penetration, Spread, DamageType, Source.Entity, hit.textureCoord, Barrel.forward);
}
_colliderHit = true;
_colliderLocalPosition = hit.collider.transform.InverseTransformPoint(hit.point);
_colliderTransform = hit.collider.transform;
LightningCompute.FixedEndpoint = true;
break;
}
LightningCompute.OnPulseComplete = () =>
LightningCompute.FixedEndpoint = Target != null;
LightningCompute.OnPulseComplete = () =>
GetComponent<Prototype>().ReturnToPool();
LightningCompute.StartAnimation();
_endpoint = Barrel.position + Barrel.forward * Range;
@ -80,10 +34,7 @@ public class Lightning : MonoBehaviour
{
if (Barrel == null) return;
if (_colliderHit)
{
if (_colliderTransform) _endpoint = _colliderTransform.TransformPoint(_colliderLocalPosition);
}
if (Target != null) _endpoint = Target.transform.position;
LightningCompute.EndPosition = _endpoint;
LightningCompute.StartPosition = Barrel.position;

View file

@ -6,18 +6,15 @@ public class LightningGunManager : InstantWeaponEffectManager
{
public Prototype Prototype;
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target)
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
{
var p = Prototype.Instantiate<Lightning>();
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var barrel = source.GetBarrel(hp);
p.ShotId = shotId;
p.Barrel = barrel;
p.Source = source;
p.Damage = weapon.Damage;
p.Range = weapon.Range;
p.Penetration = weapon.Penetration;
p.Spread = weapon.DamageSpread;
p.DamageType = weapon.WeaponData.DamageType;
p.Target = target;
p.Fire();
}

View file

@ -10,7 +10,7 @@ public class MineManager : InstantWeaponEffectManager
public Prototype ProjectilePrototype;
public bool InheritVelocity;
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target)
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
{
var p = ProjectilePrototype.Instantiate<Mine>();
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];

View file

@ -1,41 +1,32 @@
using System;
using System.Collections;
using System.Collections.Generic;
using CultMath;
using static CultMath.math;
using System.Collections;
using UnityEngine;
using Random = UnityEngine.Random;
using static CultMath.math;
// Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already
// decided this shot's fate before this object was ever spawned (R8). What is left is pure flight-path
// presentation: fly, and disappear at Range or ShotId's resolution, whichever comes first. Airburst
// (AirburstDistance/AirburstRange) is Cut 4's -- untouched here.
public class Projectile : MonoBehaviour
{
public TrailRenderer Trail;
public float Gravity;
public float Drag = .1f;
public Prototype HitEffect;
public float AirburstDistance;
public float AirburstRange;
public float DirectHitDamageMultiplier = 1;
private bool _alive;
// Cut 3: FireControl.Fire's ShotId -- this projectile's handle onto Zone.ShotCommitted/ShotResolved.
public int ShotId { get; set; }
public Zone Zone { get; set; }
public Vector3 StartPosition { get; set; }
public Vector3 Velocity { get; set; }
public float Damage { get; set; }
public float Penetration { get; set; }
public float Spread { get; set; }
public DamageType DamageType { get; set; }
public Entity SourceEntity { get; set; }
public float Range { get; set; }
// Start is called before the first frame update
void Start()
{
}
private void OnEnable()
{
_alive = true;
@ -45,81 +36,18 @@ public class Projectile : MonoBehaviour
void Update()
{
if (SourceEntity == null) return;
if(_alive)
{
var t = transform;
var position = t.position;
Velocity -= Vector3.up * (Gravity * Time.deltaTime);
Velocity *= max(0, 1 - Drag * Time.deltaTime);
var forward = Velocity.normalized;
t.forward = forward;
var ray = new Ray(position, Velocity);
foreach (var hit in Physics.RaycastAll(ray, Velocity.magnitude * Time.deltaTime, 1 | (1 << 17)))
{
var shield = hit.collider.GetComponent<ShieldManager>();
var hull = hit.collider.GetComponent<HullCollider>();
if (shield)
{
var shieldBehavior = shield.Entity.Shield;
var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value;
var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage);
// F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now --
// read once into shieldAbsorbs, and break the shield exactly once, right here, at the point
// this hit is actually decided to route past it (to the hull collider RaycastAll returns
// as a separate entry further down this same loop) instead of being absorbed. The hull
// branch below re-queries CanTakeHit safely -- it is side-effect-free -- and will see Broken
// already true.
if (shieldActive && !shieldAbsorbs) shieldBehavior.Break();
if (!shieldAbsorbs) continue;
if (shield.Entity != SourceEntity)
{
shieldBehavior.TakeHit(DamageType, Damage*DirectHitDamageMultiplier);
shield.ShowHit(hit.point, sqrt(Damage * DirectHitDamageMultiplier));
}
}
else if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage)))
{
if (hull.Entity != SourceEntity)
{
hull.SendHit(Damage*DirectHitDamageMultiplier, Penetration, Spread, DamageType, SourceEntity, hit.textureCoord, forward);
transform.position = hit.point;
StartCoroutine(Kill());
}
}
else
{
StartCoroutine(Kill());
return;
}
if (HitEffect != null)
{
var ht = HitEffect.Instantiate<Transform>();
ht.SetParent(hit.collider.transform);
ht.position = hit.point;
return;
}
}
t.forward = Velocity.normalized;
transform.position += Velocity * Time.deltaTime;
var distanceTraveled = (transform.position - StartPosition).magnitude;
if(distanceTraveled > Range)
StartCoroutine(Kill());
if (AirburstRange > 1 && distanceTraveled > AirburstDistance)
{
StartCoroutine(Kill());
var ht = HitEffect.Instantiate<Transform>();
ht.position = t.position;
foreach (var collider in Physics.OverlapSphere(t.position, AirburstRange, 1))
{
var hull = collider.GetComponent<HullCollider>();
if (hull)
{
hull.SendSplash(Damage, DamageType, SourceEntity, (collider.transform.position - t.position).normalized);
}
}
}
}
}

View file

@ -10,27 +10,24 @@ public class ProjectileManager : InstantWeaponEffectManager
public Prototype ProjectilePrototype;
public bool InheritVelocity;
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target)
public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId)
{
var p = ProjectilePrototype.Instantiate<Projectile>();
var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y];
var barrel = source.GetBarrel(hp);
var angle = weapon.Spread / 2;
p.ShotId = shotId;
p.SourceEntity = source.Entity;
p.Velocity = Quaternion.Euler(
Random.Range(-angle, angle),
Random.Range(-angle, angle),
Random.Range(-angle, angle)) *
barrel.forward *
Random.Range(-angle, angle),
Random.Range(-angle, angle),
Random.Range(-angle, angle)) *
barrel.forward *
weapon.Velocity;
p.StartPosition = p.transform.position = barrel.position + p.Velocity * (Random.value * Time.deltaTime);
if(InheritVelocity)
p.Velocity += new Vector3(source.Entity.Velocity.x, 0, source.Entity.Velocity.y);
p.Damage = weapon.Damage;
p.Range = weapon.Range;
p.Penetration = weapon.Penetration;
p.Spread = weapon.DamageSpread;
p.DamageType = weapon.WeaponData.DamageType;
p.Zone = source.Entity.Zone;
p.AirburstDistance = target != null ? length(source.Entity.Position - target.Entity.Position) : (weapon.Range * .75f);
p.Trail.Clear();

View file

@ -102,21 +102,19 @@ public class CombatState : BaseState
var testWeapon = _agent.Ship.WeaponGroups[selectedGroup].weapons.First();
if(testWeapon.Velocity > 1)
{
// Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with FireControl.Fire's
// own snapshot and the roll's deviation measurement -- two separate predictions here and in the
// roll would let an AI aim at one point and get judged for missing a different one.
var targetHullData = _agent.ItemManager.GetData(target.Hull) as HullData;
var targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y);
var shipVelocity = float3(_agent.Ship.Velocity.x, 0, _agent.Ship.Velocity.y);
var predictedPosition = first_order_intercept(
_agent.Ship.Position,
float3.zero,
testWeapon.Velocity,
target.Position,
targetVelocity
);
var predictedPosition = FireControl.PredictedIntercept(testWeapon, _agent.Ship, target);
predictedPosition.y = _agent.Ship.Zone.GetHeight(predictedPosition.xz) + targetHullData.GridOffset;
toTarget = normalize(predictedPosition - _agent.Ship.Position);
}
var shouldFire = FireControl.InArc(testWeapon.Item, toTarget);
// 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;
foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons)
{
if (shouldFire)

View file

@ -70,7 +70,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
public event Action OnReloadBegin;
public event Action OnReloadComplete;
public event Action OnCooldownComplete;
public event Action OnFire;
// Cut 3 (docs/fire-control-cut.md): carries the ShotId FireControl.Fire assigned, so a Unity effect
// manager can bind its presentation to the one shot it belongs to instead of applying damage itself.
public event Action<int> OnFire;
public virtual void ResetEvents()
{
@ -96,6 +98,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
{
// Safed: shooter has a target and hasn't declared hostility toward it.
if (!StanceAllowsFire) return;
// Cut 3 (docs/fire-control-cut.md, Q2): arc-gated the same way AI and turret fire are (Weapon.
// ArcAllowsFire), and applied here because this is the one place every shooter's trigger passes
// 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;
// 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
@ -230,7 +237,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
_burstRemaining--;
_burstTimer -= _burstInterval;
OnFire?.Invoke();
// 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.
OnFire?.Invoke(FireControl.Fire(this, Item, Entity));
if(!firedThisFrame)
{
Item.FireAudioEvent(WeaponAudioEvent.Fire);

View file

@ -59,7 +59,13 @@ public class LockWeapon : InstantWeapon
{
get => saturate(_lock);
}
// Cut 3 (docs/fire-control-cut.md): the same threshold CanFire above already gates firing on, exposed so
// FireControl.HitProbability can zero an unlocked LockWeapon's probability for AI/HUD decisions made
// before a shot is ever attempted.
public bool IsLocked => _lock > .99f;
public LockWeapon(LockWeaponData data, EquippedItem item) : base(data, item)
{
_data = data;

View file

@ -78,7 +78,10 @@ public class TurretController : Behavior, IInitializableBehavior
foreach (var x in _weapons)
{
var data = x.Data as WeaponData;
var fire = FireControl.InArc(x.Item, Entity.LookDirection);
// 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)
{
x.Activate();

View file

@ -100,6 +100,24 @@ public abstract class Weapon : Behavior, IActivatedBehavior
// With no target set, behaviour is unchanged (nothing to be safe about).
public bool StanceAllowsFire => Entity.Target.Value == null || Entity.IsHostileTo(Entity.Target.Value);
// Cut 3 (docs/fire-control-cut.md, Q2): player fire is arc-gated like everyone else's. This is the same
// predicate (FireControl.InArc) Combat.cs and TurretController.cs gate AI and turret fire with, read at
// the one point every shooter's trigger passes through (InstantWeapon.Trigger) -- not a parallel check, and
// not the AI's own AgentMinHitProbability heuristic, which is a "worth it" decision layered above this
// gate, not the gate itself. With no target set, behaviour is unchanged (nothing to bear on). A target at
// this weapon's own exact position leaves the bearing undefined rather than out of arc -- point-blank
// range can't fail a bearing test.
public bool ArcAllowsFire
{
get
{
var target = Entity.Target.Value;
if (target == null) return true;
var toTarget = target.Position - Entity.Position;
return lengthsq(toTarget) < 1e-6f || FireControl.InArc(Item, toTarget);
}
}
public Weapon(WeaponData data, EquippedItem item) : base(data, item)
{
_data = data;

View file

@ -316,6 +316,85 @@ 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)
{
var hullData = ItemManager.GetData(Hull) as HullData;
foreach (var v in hitShape.Coordinates)
hitShape[v] = hitShape[v] && hullData.Shape[v];
float hullDamage = 0;
var damagePerCell = damage / hitShape.Coordinates.Length;
foreach (var v in hitShape.Coordinates)
{
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;
}
if (hullDamage > .1f)
{
Hull.Durability -= hullDamage;
HullDamage.OnNext(hullDamage);
}
}
// Cut 3: the shape construction moved from EntityInstance.cs's Unity HullCollider subscription, with two
// changes forced by R7 (the simulation is 2D): the hit cell is the rolled Cell FireControl already chose,
// not a UV texture coordinate; and the penetration march rotates the firer-to-target direction into this
// entity's own frame with a planar rotation by -Direction, in place of transform.InverseTransformDirection
// -- no 3D transform anywhere in the march. `hitDirection` is the planar (x,z) firer-to-target unit vector.
public void ApplyHit(Entity source, int2 cell, float spread, float penetration, float damage, float2 hitDirection)
{
IncomingHit.OnNext(source);
var hullData = ItemManager.GetData(Hull) as HullData;
var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height);
hitShape[cell] = true;
for (var i = 0; i < (int) floor(spread + .5f); i++)
hitShape = hitShape.Expand();
if (penetration > .5f)
{
var forward = normalize(Direction);
var right = float2(forward.y, -forward.x);
var penetrationVector = normalize(float2(dot(hitDirection, right), dot(hitDirection, forward)));
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);
}
// Another entity's stance toward THIS one, as far as this entity can perceive it: unknown (null)
// until this entity detects them, mirroring the existing detection model (VisibleEntities, driven
// by EntityInfoGathered crossing TargetDetectionInfoThreshold). Detected but not yet hostility-rated

View file

@ -2,9 +2,13 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System.Collections.Generic;
using System.Linq;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
// Cut 1 (docs/fire-control-cut.md): geometry -- whether a weapon bears on a point is decided here and
// nowhere else -- no prefab, transform, ArticulationPoint or renderer may influence it (R6, R7). Mount
@ -100,4 +104,300 @@ public static class FireControl
var system = entity.GetBehavior<TargetingSystem>();
return system != null && system.Item.Active.Value ? system.Tracking : 0f;
}
// Cut 3, the risk this map names explicitly: Combat.cs used to run its own first_order_intercept call to
// aim, while the roll measured deviation against a separately-computed prediction -- two functions
// claiming to answer the same question will eventually disagree, and an AI would then aim at one point and
// be judged against another. This is the one function; CombatState (and FireControl.Fire below) both call
// it instead of touching CultMath.first_order_intercept directly. Planar: source velocity is never fed in
// (no shooter here leads its own motion into the shot), matching every existing call site this replaces.
public static float3 PredictedIntercept(Weapon weapon, Entity source, Entity target)
{
var targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y);
return weapon.Velocity > .01f
? first_order_intercept(source.Position, float3.zero, weapon.Velocity, target.Position, targetVelocity)
: target.Position;
}
// Cut 3, R1/R3: the targeting solution (0b table) -- pure, no draw, recomputed fresh on every call. AI
// reads this to decide whether a shot is worth taking; the HUD reads it to draw a number; Fire below reads
// it once, at the instant the trigger is pulled, and freezes the result into the shot (R10, Q6). Two reads
// a tick apart may differ; nothing here or anywhere else caches one.
public static float HitProbability(Weapon weapon, Entity source, Entity target)
{
if (target == null) return 0f;
if (!source.VisibleEntities.Contains(target)) return 0f;
var toTarget = target.Position - source.Position;
var range = length(toTarget);
if (range < weapon.MinRange || range > weapon.Range) return 0f;
if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f;
if (!InArc(weapon.Item, toTarget)) return 0f;
var settings = source.ItemManager.GameplaySettings;
var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
var pSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, Resolution(source), info));
float pSpread;
if (weapon.Spread > 0)
{
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
var halfExtent = .5f * max(targetHull.Shape.Width, targetHull.Shape.Height) * settings.SchematicCellSize;
var angularRadius = degrees(atan(halfExtent / range));
pSpread = saturate(angularRadius / (weapon.Spread / 2f));
}
else pSpread = 1f;
return Accuracy(source) * pSensor * pSpread;
}
// Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes the predicted intercept and
// flight time, freezes the payload snapshot (R10, Q6 -- the gun that fired it, not a re-read later), and
// queues a PendingShot for Zone.Step to age and eventually resolve. Returns the ShotId so the caller's
// OnFire event can carry it to presentation.
public static int Fire(Weapon weapon, EquippedItem item, Entity source)
{
var zone = source.Zone;
var target = source.Target.Value;
var now = zone.Time;
// R1's engage gate and probability, evaluated now and frozen: nothing at commit time re-reads a stat,
// an info level or a range. Only live target *position* (deviation) is read again, at commit.
var pBase = target != null ? HitProbability(weapon, source, target) : 0f;
var targetVelocity = float3.zero;
var targetPosition = source.Position;
var flightTime = 0f;
var intercept = source.Position;
if (target != null)
{
targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y);
targetPosition = target.Position;
intercept = PredictedIntercept(weapon, source, target);
var range = length(targetPosition - source.Position);
flightTime = weapon.Velocity > .01f ? range / weapon.Velocity : 0f;
}
var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon;
var shot = new PendingShot
{
ShotId = zone.NextShotId(),
Source = source,
Target = target,
Weapon = item,
Aimed = source.ResolvedTargetItem,
Damage = weapon.Damage,
Penetration = weapon.Penetration,
DamageSpread = weapon.DamageSpread,
DamageType = weapon.WeaponData.DamageType,
PBase = pBase,
Tracking = Tracking(source),
Precision = Precision(source),
FireTime = now,
FireTargetPosition = targetPosition,
FireTargetVelocity = targetVelocity,
PredictedIntercept = intercept,
FlightTime = flightTime,
ArrivalTime = now + flightTime,
CommitTime = now + max(0f, flightTime - commitHorizon),
Committed = false
};
zone.PendingShots.Add(shot);
return shot.ShotId;
}
// Cut 3, R4: ages every shot in the zone, commits the ones that have reached their horizon and resolves
// the ones that have arrived. Called from Zone.Update after the entity loop. A shot whose source or target
// has left the zone resolves as a miss and is removed outright (0b table), whichever stage it is at.
public static void Step(Zone zone, float dt)
{
var shots = zone.PendingShots;
if (shots.Count == 0) return;
var now = zone.Time;
for (var i = shots.Count - 1; i >= 0; i--)
{
var shot = shots[i];
var sourceGone = !zone.Entities.Contains(shot.Source);
var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);
if (sourceGone || targetGone)
{
if (!shot.Committed)
{
shot.Outcome = MakeOutcome(shot, false, false, int2.zero, null, now);
zone.ShotCommitted.OnNext(shot.Outcome);
}
zone.ShotResolved.OnNext(shot.Outcome);
shots.RemoveAt(i);
continue;
}
if (!shot.Committed && now >= shot.CommitTime)
{
shot.Outcome = Commit(zone, shot, now);
shot.Committed = true;
zone.ShotCommitted.OnNext(shot.Outcome);
shots[i] = shot;
}
if (shot.Committed && now >= shot.ArrivalTime)
{
Apply(shot);
zone.ShotResolved.OnNext(shot.Outcome);
shots.RemoveAt(i);
}
}
}
// R3/R4: the one roll. p_base was frozen at fire; the only thing measured live is how far the target has
// actually strayed, by now, from where a straight-line projection of its fire-time velocity said it would
// be -- forgiven by the targeting system's Tracking, likewise frozen at fire. A shot gated to zero at fire
// (out of arc, out of range, unlocked, undetected) draws nothing here (short-circuit below): the RNG
// sequence is exactly as if the shot had never queued.
private static ShotOutcome Commit(Zone zone, PendingShot shot, float now)
{
var random = shot.Source.ItemManager.Random;
var p = shot.PBase;
if (p > 0f && shot.Target != null)
{
var elapsed = now - shot.FireTime;
var predicted = shot.FireTargetPosition + shot.FireTargetVelocity * elapsed;
var deviation = length((shot.Target.Position - predicted).xz);
var pDeviation = shot.Tracking > 0f
? saturate(1f - deviation / shot.Tracking)
: (deviation < .01f ? 1f : 0f);
p *= pDeviation;
}
var hit = p > 0f && random.NextFloat() < p;
var cell = int2.zero;
EquippedItem aimed = null;
var shielded = false;
if (hit)
{
var hullData = shot.Source.ItemManager.GetData(shot.Target.Hull) as HullData;
var aimedCells = shot.Aimed != null ? CellsOf(shot.Target, shot.Aimed) : null;
if (aimedCells != null && aimedCells.Length > 0 && random.NextFloat() < shot.Precision)
{
aimed = shot.Aimed;
cell = aimedCells[random.NextInt(aimedCells.Length)];
}
else
{
var coords = hullData.Shape.Coordinates;
cell = coords[random.NextInt(coords.Length)];
}
var shield = shot.Target.Shield;
if (shield != null && shield.Item.Active.Value && shield.CanTakeHit(shot.DamageType, shot.Damage))
shielded = true;
}
shot.Source.ItemManager.Random = random;
return MakeOutcome(shot, hit, shielded, cell, aimed, now);
}
// R4: the commit is authoritative and immutable from here on -- this only performs what Commit already
// decided. The one owner of the shield-absorbs-or-hull-takes-it branch (it used to exist seven times,
// once per Unity effect).
private static void Apply(PendingShot shot)
{
if (!shot.Outcome.Hit) return;
if (shot.Outcome.Shielded)
{
shot.Target.Shield.TakeHit(shot.DamageType, shot.Damage);
return;
}
var hitDirection = float2(0, 1);
var toTarget = (shot.Target.Position - shot.Source.Position).xz;
if (lengthsq(toTarget) > 1e-6f) hitDirection = normalize(toTarget);
shot.Target.ApplyHit(shot.Source, shot.Outcome.Cell, shot.DamageSpread, shot.Penetration, shot.Damage, hitDirection);
}
private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, int2 cell, EquippedItem aimed, float now)
{
return new ShotOutcome
{
ShotId = shot.ShotId,
Source = shot.Source,
Target = shot.Target,
Weapon = shot.Weapon,
Hit = hit,
Shielded = shielded,
Aimed = aimed,
Cell = cell,
ArrivalIn = max(0f, shot.ArrivalTime - now),
DamageType = shot.DamageType
};
}
// The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source
// of truth for where an equipped item's footprint lands, the same table Entity.DamageSchematic reads.
private static int2[] CellsOf(Entity target, EquippedItem item)
{
var hullData = target.ItemManager.GetData(target.Hull) as HullData;
var cells = new List<int2>();
foreach (var v in hullData.Shape.Coordinates)
if (target.GearOccupancy[v.x, v.y] == item)
cells.Add(v);
return cells.ToArray();
}
}
// Cut 3 (docs/fire-control-cut.md, 0b table): a shot in flight. Born in FireControl.Fire, aged by
// FireControl.Step (called from Zone.Update after the entity loop), never serialised -- a zone saved mid-
// flight loses its shots, which is correct, a save is a scene boundary. Zone owns the collection and the
// ShotId; FireControl owns every transition. Nothing else may add, remove or mutate one.
public struct PendingShot
{
public int ShotId;
public Entity Source;
public Entity Target;
public EquippedItem Weapon;
public EquippedItem Aimed;
// The frozen payload (R10, Q6): the gun's own stats at the instant the trigger was pulled. Nothing
// downstream re-evaluates any of these.
public float Damage;
public float Penetration;
public float DamageSpread;
public DamageType DamageType;
public float PBase;
public float Tracking;
public float Precision;
public float3 FireTargetPosition;
public float3 FireTargetVelocity;
public float3 PredictedIntercept;
public float FlightTime;
public float FireTime;
public float CommitTime;
public float ArrivalTime;
public bool Committed;
public ShotOutcome Outcome;
}
// Cut 3 (docs/fire-control-cut.md, 0b table): a commit. Created once, at ArrivalTime - CommitHorizon (or at
// fire time when the flight is shorter than the horizon), immutable from then on, published once on
// Zone.ShotCommitted and again (unchanged) on Zone.ShotResolved at arrival. Presentations are the only
// readers, and nothing outside presentation may read one to change state.
public sealed class ShotOutcome
{
public int ShotId;
public Entity Source;
public Entity Target;
public EquippedItem Weapon;
public bool Hit;
public bool Shielded;
public EquippedItem Aimed;
public int2 Cell;
public float ArrivalIn;
public DamageType DamageType;
}

View file

@ -224,6 +224,22 @@ public class GameplaySettings
public float AgentRangeExponent = .25f;
public float AgentForwardLerp = .5f;
public float AgentMaxForwardDistance = 50;
// Cut 3 (docs/fire-control-cut.md): first guesses: the headless fixture is the tuning harness, the
// operator smoke is the arbiter (§ Risks).
//
// How long before impact a shot's outcome commits (R4). Deviation counts up to this horizon; after it
// the result is frozen and the rest of the flight is pure choreography.
public float CommitHorizon = .5f;
// World units per hull-schematic cell, used only by FireControl.HitProbability's angular-size term
// (pSpread) to turn a hull's cell footprint into a real-world silhouette size at range.
public float SchematicCellSize = 2f;
// The floor an AI's own predicted hit probability (FireControl.HitProbability) must clear before it counts
// a firing solution worth taking (Combat.cs, TurretController.cs). Player fire is gated on arc alone
// (Weapon.ArcAllowsFire, Q2) -- this threshold is an AI fire-discipline heuristic, not part of the roll.
public float AgentMinHitProbability = .2f;
}
[Serializable, MessagePackObject(keyAsPropertyName: true), JsonObject]

View file

@ -33,6 +33,16 @@ public class Zone
private Random _random;
public List<Agent> Agents = new List<Agent>();
// Cut 3 (docs/fire-control-cut.md, 0b table): Zone owns the pending-shot collection and the ShotId
// namespace; FireControl owns every transition a shot goes through. ShotCommitted publishes once a shot's
// outcome is decided (R4's commit horizon); ShotResolved republishes the same, unchanged outcome at
// arrival, right before the shot is removed. Presentations are the only readers of either.
public List<PendingShot> PendingShots = new List<PendingShot>();
public Subject<ShotOutcome> ShotCommitted = new Subject<ShotOutcome>();
public Subject<ShotOutcome> ShotResolved = new Subject<ShotOutcome>();
private int _nextShotId;
public int NextShotId() => ++_nextShotId;
private List<Task> BeltUpdates = new List<Task>();
public float Time
@ -148,6 +158,11 @@ public class Zone
agent.Update(deltaTime);
foreach (var entity in Entities.ToArray()) entity.Update(deltaTime);
// Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that
// firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and
// resolves in this same call.
FireControl.Step(this, deltaTime);
}
// Determine orbital position recursively, caching parent positions to avoid repeated calculations

View file

@ -0,0 +1,522 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using UniRx;
using Xunit;
using static CultMath.math;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
using Random = CultMath.Random;
// Cut 3 (docs/fire-control-cut.md): pins hit authority itself -- the roll's inputs, the commit horizon, frozen
// payloads (Q6), seeding (Q5), the arc gate on player fire (Q2), and the damage rule moved verbatim onto
// Entity. Builds its own shooter/target fixture rather than reusing Cut 1's or Cut 2's private helpers, the
// same convention those files established. Each test's own comment names the mutation that must kill it.
public sealed class FireAuthorityTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-fireauthority-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
public FireAuthorityTests() => Directory.CreateDirectory(_root);
private readonly List<CultCache> _openCaches = new List<CultCache>();
public void Dispose()
{
foreach (var c in _openCaches) c.Dispose();
Directory.Delete(_root, true);
}
private static GameplaySettings TestSettings() => new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f,
FiringArc = 170,
CommitHorizon = .5f,
SchematicCellSize = 1f,
UnaidedAccuracy = .05f,
AgentMinHitProbability = 0f
};
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
private sealed class Engagement
{
public ItemManager Items;
public Zone Zone;
public Ship Shooter;
public Ship Target;
public EquippedItem WeaponItem;
public InstantWeapon Weapon;
}
// A shooter (one Sensors-hardpoint weapon + a Tool-slot targeting system) and a target ship, both
// activated in the same zone, the target `targetRange` metres dead ahead (well within the wide default
// arc), fully detected and marked hostile so StanceAllowsFire and the reveal/detection gates never
// interfere with a test that isn't about them.
private Engagement Build(
GameplaySettings settings,
float damage = 10, float range = 1000, float minRange = 0, float velocity = 0,
float spread = 0, float damageSpread = 0, float penetration = 0,
float accuracy = 1, float resolution = 1, float precision = 0, float tracking = 1000,
float targetRange = 100, bool equipTargeting = true, float hullDurability = 1000, float armor = 0,
Action<ItemManager, Ship, Ship> beforeActivate = null)
{
// 5x5 so InteriorCells (Shape.Shrink -- every cell with all 8 neighbours present) is the inner 3x3,
// room enough for more than one Tool item (ShieldTakesHit needs a Reactor and a Shield both).
var hullShape = new Shape(5, 5);
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
var hullData = new HullData
{
Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = hullDurability, Mass = 1000, Armor = armor,
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
};
var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true);
_openCaches.Add(cache);
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
cache.Upsert(hullData);
cache.Upsert(new GearData
{
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new InstantWeaponData
{
Damage = Constant(damage), Range = Constant(range), MinRange = Constant(minRange),
Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(damageSpread),
Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000),
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
} }
});
cache.Upsert(new GearData
{
Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new TargetingSystemData
{
Accuracy = Constant(accuracy), Resolution = Constant(resolution), Precision = Constant(precision), Tracking = Constant(tracking)
} }
});
// Authored for every engagement (harmless unless equipped): a shield/reactor pair strong and fast
// enough to fill from cold in a handful of ticks, for ShieldTakesHit. Catalog entries are looked up by
// name through this same already-open CultCache handle, which does not see writes made to it after
// this point without a reopen (GetByName is a build-time index) -- so these are authored up front
// rather than upserted later from inside a test's beforeActivate.
cache.Upsert(new GearData
{
Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } }
});
cache.Upsert(new GearData
{
Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(1000), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } }
});
cache.FlushAsync().Wait();
var ledger = new ProvenanceLedger();
ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 };
ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 };
ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 };
ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 };
for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(cache, ledger, settings, _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
var shooterHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 1 };
var shooter = new Ship(items, zone, shooterHull, new EntitySettings());
var gunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Gun"));
var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 };
Assert.True(shooter.TryEquip(gun, new int2(0, 0)));
var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun);
var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon);
if (equipTargeting)
{
var targetingRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Targeting"));
var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 };
Assert.True(shooter.TryFindSpace(targeting, out var tpos));
Assert.True(shooter.TryEquip(targeting, tpos));
}
var targetHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 4 };
var target = new Ship(items, zone, targetHull, new EntitySettings());
// Entity._orderedEquipment is populated only by TryEquip -- a ship that never equips anything leaves
// it null and Entity.Update throws regardless of this cut (FireControlTests' own fixture notes the
// same rake). Equip a harmless gun so the target is an ordinarily-constructed ship.
var targetGunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Gun"));
var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 };
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
// Entity.TryEquip refuses once an entity is active ("don't allow equipping while deployed") -- any
// extra gear a test needs (a second aimable item, a shield, a reactor) must go on before Activate().
beforeActivate?.Invoke(items, shooter, target);
zone.Entities.Add(shooter);
zone.Entities.Add(target);
shooter.Activate();
target.Activate();
shooter.Position = float3.zero;
target.Position = float3(0, 0, targetRange);
shooter.Target.Value = target;
shooter.EntityInfoGathered[target] = 1f;
shooter.SetIff(target, true);
// A zero-dt warm-up tick populates the resolved weapon/targeting stats (UpdateStats runs inside
// Execute) before FireControl.Fire reads them, the same warm-up InputCapacitorTests uses.
zone.Update(0f);
return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon };
}
private static InstantWeaponData DataOf(EquippedItem weaponItem) =>
(InstantWeaponData) weaponItem.Data.Behaviors.Single(b => b is InstantWeaponData);
// R1/Q5: two zones built identically, seeded identically, fire the same 50 shots (instant, so each
// commits and resolves in the tick it is stepped) and get byte-identical Hit/Cell/Shielded sequences.
// Mutation: FireControl draws from `new Random()` instead of the shooter's ItemManager.Random.
[Fact]
public void RollsAreSeeded()
{
List<(bool Hit, bool Shielded, int2 Cell)> Run(uint seed)
{
var e = Build(TestSettings(), accuracy: .6f, precision: .5f);
e.Items.Random = new Random(seed);
var results = new List<(bool, bool, int2)>();
e.Zone.ShotResolved.Subscribe(outcome => results.Add((outcome.Hit, outcome.Shielded, outcome.Cell)));
for (var i = 0; i < 50; i++)
{
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
FireControl.Step(e.Zone, 0f);
}
return results;
}
var a = Run(777u);
var b = Run(777u);
Assert.Equal(a, b);
}
// FireControl.HitProbability, R1: zero with no draw when undetected or out of range; rises with info
// toward Resolution; falls with Spread; caps at UnaidedAccuracy with no targeting system equipped.
// Mutations: drop pSensor (probability stops depending on info); drop the range gate (probability nonzero
// outside [MinRange,Range]); drop the UnaidedAccuracy cap (an unaided shooter rolls as well as Accuracy).
[Fact]
public void ProbabilityFollowsInputs()
{
var e = Build(TestSettings(), accuracy: .8f, resolution: 1f, spread: 0f, targetRange: 100, range: 200);
// Undetected: below TargetDetectionInfoThreshold (.1) -- zero regardless of everything else.
e.Shooter.EntityInfoGathered[e.Target] = 0f;
Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target));
// Rising with info toward Resolution (1): more info, more probability, saturating at Resolution.
e.Shooter.EntityInfoGathered[e.Target] = .2f;
var low = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
e.Shooter.EntityInfoGathered[e.Target] = .6f;
var mid = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
e.Shooter.EntityInfoGathered[e.Target] = 1f;
var full = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
Assert.True(low < mid);
Assert.True(mid <= full);
Assert.True(full > 0f);
// Out of range: zero.
e.Target.Position = float3(0, 0, 500);
Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target));
e.Target.Position = float3(0, 0, 100);
// Falling with Spread: a wide-spread weapon on a small target scores lower than a zero-spread one.
DataOf(e.WeaponItem).Spread = Constant(0f);
e.Weapon.Execute(0f);
var noSpread = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
DataOf(e.WeaponItem).Spread = Constant(60f);
e.Weapon.Execute(0f);
var wideSpread = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
Assert.True(wideSpread < noSpread);
// Capped at UnaidedAccuracy with no targeting system: a second engagement, otherwise identical, but
// never equips one.
var unaided = Build(TestSettings(), accuracy: .8f, resolution: 1f, spread: 0f, targetRange: 100, range: 200, equipTargeting: false);
unaided.Shooter.EntityInfoGathered[unaided.Target] = 1f;
Assert.Equal(unaided.Items.GameplaySettings.UnaidedAccuracy, FireControl.HitProbability(unaided.Weapon, unaided.Shooter, unaided.Target), 3);
}
// The gate is a gate, not a penalty: an out-of-arc commit consumes exactly zero draws from the shared RNG
// stream. Mutation: roll first and multiply the result by zero -- that still advances the stream by one
// draw, which this test would catch by seeing a different next value than an untouched stream produces.
[Fact]
public void OutOfArcConsumesNoDraw()
{
var e = Build(TestSettings());
// Directly abeam -- outside even the wide 170-degree default arc dead ahead.
e.Target.Position = float3(100, 0, 0);
const uint seed = 4242u;
var untouched = new Random(seed);
var expected = untouched.NextFloat();
e.Items.Random = new Random(seed);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
FireControl.Step(e.Zone, 0f);
var actual = e.Items.Random.NextFloat();
Assert.Equal(expected, actual);
}
// R3: no durability change before the shot arrives (range/Velocity after this cut's fire), and a target
// removed mid-flight takes none. Mutation: apply the outcome at fire/commit time instead of arrival.
[Fact]
public void ShotResolvesOnArrival()
{
var e = Build(TestSettings(), damage: 100, velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50);
// range 50, velocity 10 -> flight time 5s, well past the .5s commit horizon.
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var before = e.Target.Hull.Durability;
e.Zone.Update(4f); // still short of arrival (5s)
Assert.Equal(before, e.Target.Hull.Durability);
e.Zone.Update(2f); // now past arrival
Assert.True(e.Target.Hull.Durability < before);
}
// 0b table: a shot whose target left the zone resolves as a miss and is removed, taking nothing.
[Fact]
public void DeadEntityStopsTakingShots()
{
var e = Build(TestSettings(), damage: 100, velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var before = e.Target.Hull.Durability;
e.Zone.Entities.Remove(e.Target);
e.Zone.Update(10f); // well past arrival
Assert.Equal(before, e.Target.Hull.Durability);
Assert.Empty(e.Zone.PendingShots);
}
// R4: ShotCommitted fires exactly CommitHorizon before ShotResolved, once per shot. Mutation: publish only
// at arrival, or publish twice.
[Fact]
public void OutcomeCommitsBeforeImpact()
{
var e = Build(TestSettings(), velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50);
var commits = new List<float>();
var resolves = new List<float>();
e.Zone.ShotCommitted.Subscribe(_ => commits.Add(e.Zone.Time));
e.Zone.ShotResolved.Subscribe(_ => resolves.Add(e.Zone.Time));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
for (var i = 0; i < 100; i++) e.Zone.Update(.1f); // 10s of ticks, well past a 5s flight
Assert.Single(commits);
Assert.Single(resolves);
Assert.Equal(e.Items.GameplaySettings.CommitHorizon, resolves[0] - commits[0], 2);
}
// R4's second half: deviation counts up to the commit horizon and not after. A target that jinks off the
// predicted straight-line path before commit is judged on that jink; the identical jink after commit
// changes nothing, because the outcome is already frozen. Mutation: measure deviation at arrival instead
// of commit (both jinks would then matter), or measure it at fire (neither would).
[Fact]
public void EvasionCountsUntilCommitAndNotAfter()
{
// Tracking small enough that a real jink defeats it, Precision moot (no aim), full accuracy/detection.
GameplaySettings Settings() => TestSettings();
bool RunWithJink(float jinkTime, float commitHorizon)
{
var settings = Settings();
settings.CommitHorizon = commitHorizon;
var e = Build(settings, damage: 10, velocity: 10, accuracy: 1, resolution: 1, spread: 0, tracking: 1f, targetRange: 50);
// Target sits still (velocity 0) so the "predicted" position is just its fire-time position;
// jinking means moving it away from that point before the check time.
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var t = 0f;
const float dt = .05f;
var jinked = false;
var before = e.Target.Hull.Durability;
while (t < 6f && e.Zone.PendingShots.Count > 0)
{
if (!jinked && t >= jinkTime)
{
e.Target.Position += float3(50, 0, 0); // far outside Tracking's forgiveness
jinked = true;
}
e.Zone.Update(dt);
t += dt;
}
return e.Target.Hull.Durability < before; // true if the shot still landed
}
// Flight time 5s, horizon 0.5s -> commit at t=4.5s. A jink well before commit (t=1s) is judged.
Assert.False(RunWithJink(jinkTime: 1f, commitHorizon: .5f));
// The identical jink AFTER commit (t=4.6s, horizon .5s means commit at 4.5s) must not matter.
Assert.True(RunWithJink(jinkTime: 4.6f, commitHorizon: .5f));
}
// R4's graceful degradation: a velocity-0 (or otherwise sub-horizon) weapon commits and resolves within
// the same tick it fires, publishing both events, in order. Mutation: skip the commit for short flights
// (only ShotResolved fires, or damage applies with no ShotCommitted at all).
[Fact]
public void ShortFlightCommitsAtFire()
{
var e = Build(TestSettings(), velocity: 0, accuracy: 1, resolution: 1, spread: 0, damage: 10);
var order = new List<string>();
e.Zone.ShotCommitted.Subscribe(_ => order.Add("committed"));
e.Zone.ShotResolved.Subscribe(_ => order.Add("resolved"));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.016f); // one ordinary tick
Assert.Equal(new[] { "committed", "resolved" }, order);
Assert.Empty(e.Zone.PendingShots);
}
// R10/Q6: a shot's parameters freeze when the trigger is pulled. Changing the weapon's authored Damage
// stat (and refreshing it into the live behaviour before arrival) must not change what an already-fired
// shot deals. Mutation: re-evaluate Damage at arrival instead of using the frozen snapshot.
[Fact]
public void OutcomeIsSnapshotNotReread()
{
var e = Build(TestSettings(), damage: 10, velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50, armor: 0);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
var before = e.Target.Hull.Durability;
// Mid-flight (before arrival at 5s): the gun's Damage stat collapses, and a fresh Execute re-reads it
// into the live Weapon.Damage -- simulating the "power cut, heat spike" scenario R10 names.
e.Zone.Update(2f);
DataOf(e.WeaponItem).Damage = Constant(99999f);
e.Weapon.Execute(0f);
Assert.Equal(99999f, e.Weapon.Damage, 1);
e.Zone.Update(4f); // past arrival
var dealt = before - e.Target.Hull.Durability;
Assert.True(dealt > 0f);
Assert.True(dealt < 100f); // nowhere near the inflated 99999 value -- the original 10 landed
}
// R5's payoff: with Precision 1 and p 1, only the aimed item's durability falls.
// Mutation: the roll ignores Aimed and always picks a uniform random hull cell.
[Fact]
public void AimedHitLandsOnSelectedItem()
{
EquippableItem aimedGear = null;
var e = Build(TestSettings(), damage: 50, velocity: 0, accuracy: 1, resolution: 1, spread: 0, precision: 1, armor: 0,
beforeActivate: (items, shooter, target) =>
{
// A second, distinct interior item on the target to aim at, equipped before Activate() (Entity.
// TryEquip refuses once an entity is active).
var targetingRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Targeting"));
aimedGear = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 5 };
Assert.True(target.TryFindSpace(aimedGear, out var pos));
Assert.True(target.TryEquip(aimedGear, pos));
});
var aimedItem = e.Target.Equipment.Single(x => x.EquippableItem == aimedGear);
e.Shooter.EntityInfoGathered[e.Target] = 1f; // fully revealed
Assert.True(e.Shooter.TrySelectTargetItem(aimedItem));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.1f);
Assert.True(aimedItem.EquippableItem.Durability < 1f);
}
// The damage rule moved verbatim (Entity.DamageSchematic/ApplyHit): damage above armor plus item
// durability zeroes the item and the remainder hits the hull. Mutation: skip subtracting armor, or skip
// passing the remainder on to the hull.
[Fact]
public void HardpointHitDamagesItemThenHull()
{
var cell = new int2(1, 1);
EquippableItem occupant = null;
var e = Build(TestSettings(), armor: 2, hullDurability: 1000,
beforeActivate: (items, shooter, target) =>
{
// No item occupies (1,1) in this fixture's hull -- occupy it before Activate() so the
// item-then-hull chain has an item to pass through at a controlled cell.
var occupantRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Targeting"));
occupant = new EquippableItem { Data = occupantRef, Durability = 3, Lot = 6 };
Assert.True(target.TryEquip(occupant, cell));
});
var occupantItem = e.Target.Equipment.Single(x => x.EquippableItem == occupant);
var beforeHull = e.Target.Hull.Durability;
e.Target.ApplyHit(e.Shooter, cell, spread: 0, penetration: 0, damage: 10, hitDirection: float2(0, 1));
Assert.Equal(0f, e.Target.Armor[cell.x, cell.y]); // 2 armor consumed
Assert.Equal(0f, occupantItem.EquippableItem.Durability); // 3 item durability consumed
Assert.Equal(beforeHull - 5f, e.Target.Hull.Durability, 2); // remaining 5 hits the hull
}
// R7: the penetration march is planar, rotated into the target's own facing. Firing straight into a
// target's nose (hitDirection opposed to Direction) marches the hit shape toward the target's stern, along
// +Y in this hull's local grid regardless of which way the entity happens to be facing in world space.
// Mutation: skip rotating by Direction and march along the raw world hitDirection instead -- a target
// facing anywhere other than the world +Z axis would then penetrate the wrong cells.
[Fact]
public void PenetrationMarchIsPlanar()
{
var e = Build(TestSettings(), armor: 5);
e.Target.Direction = float2(1, 0); // facing world +X, not the default +Z
var cell = new int2(1, 0);
var hitDirection = normalize(float2(1, 0)); // the shot arrived travelling along the target's own forward
e.Target.ApplyHit(e.Shooter, cell, spread: 0, penetration: 1.5f, damage: 30, hitDirection: hitDirection);
// Rotating hitDirection into this Direction's frame (forward=(1,0), right=(0,-1)) makes the local
// penetration vector (0,1): the march should have advanced from (1,0) into (1,1), consuming that
// cell's armor too. Mutation: skip the rotation and march along the raw world hitDirection (1,0)
// instead, which would consume (2,0) rather than (1,1).
Assert.True(e.Target.Armor[1, 1] < e.Target.MaxArmor[1, 1]);
Assert.Equal(e.Target.MaxArmor[2, 0], e.Target.Armor[2, 0]); // untouched -- proves it wasn't a world-space march
}
// The absorb rule now exists once, in FireControl: an active shield that CanTakeHit absorbs, and the
// schematic is untouched. Mutation: remove the shield branch (the hit always reaches the hull).
[Fact]
public void ShieldTakesHit()
{
var e = Build(TestSettings(), damage: 10, velocity: 0, accuracy: 1, resolution: 1, spread: 0, armor: 0,
beforeActivate: (items, shooter, target) =>
{
// The Reactor/Shield catalog entries are authored in Build's own upsert batch (see its
// comment). Equipped before Activate() (Entity.TryEquip refuses once an entity is active).
var reactorRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Reactor"));
var reactor = new EquippableItem { Data = reactorRef, Durability = 10, Lot = 7 };
Assert.True(target.TryFindSpace(reactor, out var rpos));
Assert.True(target.TryEquip(reactor, rpos));
var shieldRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Shield"));
var shield = new EquippableItem { Data = shieldRef, Durability = 10, Lot = 8 };
Assert.True(target.TryFindSpace(shield, out var spos));
Assert.True(target.TryEquip(shield, spos));
});
e.Target.Shield.Item.Enabled.Value = true;
for (var i = 0; i < 20; i++) e.Zone.Update(.1f); // charge the reserve
Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 10));
var beforeHull = e.Target.Hull.Durability;
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.1f);
Assert.Equal(beforeHull, e.Target.Hull.Durability);
}
}

View file

@ -206,6 +206,15 @@ public sealed class FireControlTests : IDisposable
target.Position = float3(0, 0, 10); // dead ahead of the None-rotation hardpoint, well within range and arc
shooter.Target.Value = target;
// Cut 3: CombatState now gates on FireControl.HitProbability, which is zero for an undetected target
// (the target is not in VisibleEntities) regardless of arc -- cross TargetDetectionInfoThreshold
// directly, the same way a Sensor's own info accrual eventually would.
shooter.EntityInfoGathered[target] = 1f;
// This fixture's shooter carries no targeting system, so Accuracy falls back to UnaidedAccuracy (Q4:
// authored deliberately bad). This test's own concern is the headless pipeline reaching a fire
// decision at all (Cut 1's regression), not AgentMinHitProbability tuning -- zero the threshold so an
// unaided shot still counts as "worth it."
items.GameplaySettings.AgentMinHitProbability = 0f;
zone.Agents.Add(new Minion(shooter));
var ex = Record.Exception(() =>

View file

@ -132,7 +132,7 @@ public sealed class InputCapacitorTests : IDisposable
var (ship, _, _) = BuildShip(cache, reactorCharge: 5);
var gun = EquipGun(cache, ship);
var fired = false;
gun.OnFire += () => fired = true;
gun.OnFire += _ => fired = true;
// Trigger() reads BurstCount/Cooldown off cached properties that only exist after UpdateStats has run
// once (inside Execute) -- a zero-dt warm-up tick populates them without spending any time or charge,
@ -158,7 +158,7 @@ public sealed class InputCapacitorTests : IDisposable
var (ship, _, _) = BuildShip(cache, reactorCharge: 10);
var gun = EquipGun(cache, ship);
var fired = false;
gun.OnFire += () => fired = true;
gun.OnFire += _ => fired = true;
ship.Update(0f); // warm-up: populates BurstCount/Cooldown before Trigger() reads them
gun.Activate();