Fire control Cut 3: hit authority moves to FireControl
Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and Lightning) and replaces it with a single owner in the same cut: FireControl now decides whether a shot hits, Zone owns the pending-shot lifecycle, and Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies nothing. FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor * pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the weapon's payload and the shooter's resolved targeting stats at the instant the trigger is pulled (Q6), computes the predicted intercept through the one shared PredictedIntercept function (CombatState now calls the same function instead of duplicating first_order_intercept), and queues a PendingShot. FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon -- deviation from the predicted intercept, measured once, live, at that instant -- and applies it at ArrivalTime. A shot whose flight is shorter than the horizon commits and resolves in the same tick it fires. Rolls draw from the shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc or otherwise gated shot draws nothing. Player fire is arc-gated through the same predicate AI and turret fire use (Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every shooter's trigger passes through), closing Q2. Adds GameplaySettings.CommitHorizon, SchematicCellSize and AgentMinHitProbability (first guesses; the headless suite is the tuning harness, operator play smoke is the arbiter). FORK reported, not worked around silently: HullCollider.SendHit/SendSplash cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them from their own live Physics queries -- that's Cut 4's migration. They fire into the void today: nothing subscribes to Hit/Splash any more for the four weapon kinds this cut owns. tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its evasion window, short-flight degradation, the frozen-payload snapshot, aimed hits, the item-then-hull damage order, the planar penetration march and the shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are updated for the new AI gate and OnFire(int) signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3cf4603475
commit
b7743789c8
25 changed files with 1092 additions and 432 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
522
tests/Aetheria.Shared.Tests/FireAuthorityTests.cs
Normal file
522
tests/Aetheria.Shared.Tests/FireAuthorityTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue