Step-exact rules: timers keep their overshoot, passive sensing decays exactly (group B, 60 Hz, digest not repinned)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e2f0fc9dc4
commit
f0362025d9
5 changed files with 216 additions and 12 deletions
|
|
@ -148,7 +148,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
|
|||
_ammoInterval -= dt / _data.AmmoInterval;
|
||||
if (_ammoInterval < 0)
|
||||
{
|
||||
_ammoInterval = 1;
|
||||
_ammoInterval += 1;
|
||||
if (_data.MagazineSize > 1 && _ammo > 0) _ammo--;
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ public class Cooldown : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior
|
|||
{
|
||||
if (_cooldown < 0)
|
||||
{
|
||||
_cooldown = 1;
|
||||
_cooldown += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +57,8 @@ public class Cooldown : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior
|
|||
|
||||
public void Update(float delta)
|
||||
{
|
||||
_cooldown -= delta / Evaluate(_data.Cooldown);
|
||||
// A finished timer keeps what the last step overshot, at most one step, so the restart in Execute is exact.
|
||||
var step = delta / Evaluate(_data.Cooldown);
|
||||
_cooldown = max(_cooldown - step, -step);
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +58,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
protected float _cooldown; // Normalized
|
||||
private int _ammo = 0;
|
||||
protected bool _coolingDown;
|
||||
// Seconds the last countdown ran past zero (less than one step). The next countdown starts that far along, so a held
|
||||
// trigger fires Cooldown apart on average at any step, never floor(Cooldown/dt)+1 steps apart.
|
||||
private float _overshoot;
|
||||
|
||||
public float BurstCount { get; protected set; }
|
||||
public float BurstTime { get; protected set; }
|
||||
|
|
@ -183,6 +186,15 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
// would ever fill this capacitor -- bypass it entirely rather than starving such an instance forever.
|
||||
protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity);
|
||||
|
||||
private float CountdownSpan => _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown;
|
||||
|
||||
private float TakeOvershoot()
|
||||
{
|
||||
var overshoot = _overshoot;
|
||||
_overshoot = 0;
|
||||
return overshoot;
|
||||
}
|
||||
|
||||
private bool UseAmmo()
|
||||
{
|
||||
if (_data.MagazineSize <= 1) return true;
|
||||
|
|
@ -208,7 +220,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
if(hasAmmo)
|
||||
{
|
||||
OnReloadBegin?.Invoke();
|
||||
_cooldown = 1;
|
||||
_cooldown = 1 - TakeOvershoot() / _data.ReloadTime;
|
||||
_coolingDown = true;
|
||||
_firing = false;
|
||||
}
|
||||
|
|
@ -226,9 +238,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
_capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply);
|
||||
if (_coolingDown)
|
||||
{
|
||||
_cooldown -= dt / (_data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown);
|
||||
var span = CountdownSpan;
|
||||
_cooldown -= dt / span;
|
||||
if (_cooldown < 0)
|
||||
{
|
||||
_overshoot = -_cooldown * span;
|
||||
_coolingDown = false;
|
||||
if (_data.MagazineSize > 0 && _ammo == 0)
|
||||
{
|
||||
|
|
@ -260,8 +274,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
|
|||
if (!_burstStarted)
|
||||
{
|
||||
_burstStarted = true;
|
||||
_cooldown = 1 - dt / Cooldown;
|
||||
// The round starts the cooldown after this tick's decrement above: charge the tick to it, so the cadence stays Cooldown
|
||||
_cooldown = 1 - (dt + TakeOvershoot()) / CountdownSpan;
|
||||
// The round starts the cooldown after this tick's decrement above: charge the tick and the last
|
||||
// countdown's overshoot to it, so the cadence stays Cooldown
|
||||
_coolingDown = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
|
|||
{
|
||||
_pinging = true;
|
||||
_pingZone = Entity.Zone;
|
||||
_pingCooldown = 1;
|
||||
_pingCooldown += 1;
|
||||
_pingLerp = 0;
|
||||
_pingRadius = 0;
|
||||
_reached.Clear();
|
||||
|
|
@ -180,8 +180,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
|
|||
if (_pingLerp > 1) EndPing();
|
||||
}
|
||||
|
||||
_pingCooldown -= dt / Evaluate(_data.PingCooldown);
|
||||
// A finished timer keeps what the last step overshot, at most one step, so the restart in Ping is exact.
|
||||
var pingStep = dt / Evaluate(_data.PingCooldown);
|
||||
_pingCooldown = max(_pingCooldown - pingStep, -pingStep);
|
||||
|
||||
var infoDecay = ItemManager.GameplaySettings.TargetInfoDecay;
|
||||
var decay = exp(-infoDecay * dt);
|
||||
var settle = infoDecay > 0 ? (1 - decay) / infoDecay : dt;
|
||||
|
||||
var forward = Direction.xz;
|
||||
foreach (var entity in Entity.Zone.Entities)
|
||||
{
|
||||
|
|
@ -221,14 +227,16 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
|
|||
credit += Gain(entity.Visibility, sensitivity, response, sqrt(r.IlluminatedRange * dist), dt, true, sqrt(r.IlluminatedObscuration * obscuration));
|
||||
}
|
||||
|
||||
// The exact decay at this step: x <- x e^(-k dt) + (G / k)(1 - e^(-k dt)) for the passive gain G per second,
|
||||
// which settles at G / k (Entity.PassiveInfo) at every dt. A ping's credit is an impulse added first.
|
||||
if (heard)
|
||||
next = saturate(previous + credit);
|
||||
next = saturate(previous + credit) * decay;
|
||||
else
|
||||
{
|
||||
var angle = acos(dot(forward, normalize(diff)));
|
||||
next = saturate(previous + Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration));
|
||||
var passive = Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, 1f, false, obscuration);
|
||||
next = saturate(previous * decay + passive * settle);
|
||||
}
|
||||
next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt;
|
||||
//Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%");
|
||||
Entity.EntityInfoGathered[entity] = next;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,3 +33,182 @@ public sealed class SimRateClockTests
|
|||
Assert.InRange(clock.Steps, 10 * SimClock.TickRate - 1, 10 * SimClock.TickRate + 1);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed partial class ContactRevealTests
|
||||
{
|
||||
private static readonly float[] CadenceSteps = { 1f / 60f, 1f / 30f, 1f / 20f, 1f / 15f };
|
||||
private static readonly float[] Cooldowns = { .25f, .13f };
|
||||
|
||||
// A gun of its own cooldown, bottomless magazine, so only the cooldown paces it. Upserted by name into the open catalog.
|
||||
private string GunWithCooldown(float cooldown)
|
||||
{
|
||||
var name = $"Gun@{cooldown:R}";
|
||||
if (_openCache.GetByName<ItemData>(name) != null) return name;
|
||||
var gun = Armed(new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) }, 0);
|
||||
_openCache.Upsert(new WeaponItemData
|
||||
{
|
||||
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
||||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
|
||||
Behaviors = { gun }
|
||||
});
|
||||
return name;
|
||||
}
|
||||
|
||||
// Holds the trigger for `seconds`, topping up the capacitor so only the cooldown paces the gun. Returns the zone
|
||||
// time of each round.
|
||||
private List<float> HoldTrigger(Ship ship, EquippedItem item, float dt, float seconds)
|
||||
{
|
||||
var gun = (InstantWeapon) item.Behaviors.Single(b => b is InstantWeapon);
|
||||
var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun);
|
||||
var rounds = new List<float>();
|
||||
gun.OnFire += _ => rounds.Add(_zone.Time);
|
||||
ship.SetTarget(TargetRef.None);
|
||||
for (var step = 0; step < (int) round(seconds / dt); step++)
|
||||
{
|
||||
capacitor.AddCharge(1000f);
|
||||
gun.Activate();
|
||||
Tick(dt);
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CooldownCadenceIsPerSecond()
|
||||
{
|
||||
foreach (var cooldown in Cooldowns)
|
||||
foreach (var dt in CadenceSteps)
|
||||
{
|
||||
var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown) }, new[] { "Targeting", "Reactor" });
|
||||
var rounds = HoldTrigger(ship, Guns(ship)[0], dt, 30f);
|
||||
var expected = 30f / cooldown;
|
||||
Assert.True(abs(rounds.Count - expected) <= 1.01f, $"cooldown {cooldown} at dt {dt}: {rounds.Count} rounds, expected {expected} +-1");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CooldownNeverFastFires()
|
||||
{
|
||||
foreach (var cooldown in Cooldowns)
|
||||
foreach (var dt in CadenceSteps)
|
||||
{
|
||||
var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown) }, new[] { "Targeting", "Reactor" });
|
||||
var item = Guns(ship)[0];
|
||||
for (var i = 0; i < 20; i++) Tick(dt); // idle: the finished timer banks at most one step
|
||||
var rounds = HoldTrigger(ship, item, dt, 10f);
|
||||
Assert.True(rounds.Count > 3);
|
||||
for (var i = 1; i < rounds.Count; i++)
|
||||
Assert.True(rounds[i] - rounds[i - 1] >= cooldown - dt - 1e-4f, $"cooldown {cooldown} at dt {dt}: rounds {rounds[i - 1]} and {rounds[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
private int PingsHeld(float pingCooldown, float dt, float seconds)
|
||||
{
|
||||
var name = $"Pinger@{pingCooldown:R}";
|
||||
if (_openCache.GetByName<ItemData>(name) == null)
|
||||
{
|
||||
var sensor = SensorBehavior(sensitivity: 2f);
|
||||
sensor.PingCooldown = Const(pingCooldown);
|
||||
_openCache.Upsert(new GearData
|
||||
{
|
||||
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
||||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
Behaviors = { sensor }
|
||||
});
|
||||
}
|
||||
var ship = Add(float3(0, 0, 0), new[] { name });
|
||||
var pings = 0;
|
||||
SensorOf(ship).OnPingStart += () => pings++;
|
||||
for (var step = 0; step < (int) round(seconds / dt); step++)
|
||||
{
|
||||
SensorOf(ship).Ping();
|
||||
Tick(dt);
|
||||
}
|
||||
return pings;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PingCooldownCarriesItsOvershoot()
|
||||
{
|
||||
foreach (var cooldown in new[] { .5f, .26f })
|
||||
foreach (var dt in CadenceSteps)
|
||||
{
|
||||
var pings = PingsHeld(cooldown, dt, 30f);
|
||||
Assert.True(abs(pings - 30f / cooldown) <= 1.01f, $"ping cooldown {cooldown} at dt {dt}: {pings} pings, expected {30f / cooldown} +-1");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CooldownBehaviourCarriesItsOvershoot()
|
||||
{
|
||||
foreach (var cooldown in new[] { .5f, .26f })
|
||||
foreach (var dt in CadenceSteps)
|
||||
{
|
||||
var name = $"Metronome@{cooldown:R}";
|
||||
if (_openCache.GetByName<ItemData>(name) == null)
|
||||
_openCache.Upsert(Tool(name, new CooldownData { Cooldown = Const(cooldown) }));
|
||||
var ship = Add(float3(0, 0, 0), null, new[] { name });
|
||||
var clock = ship.Equipment.SelectMany(i => i.Behaviors).OfType<Cooldown>().Single();
|
||||
var fired = 0;
|
||||
for (var step = 0; step < (int) round(30f / dt); step++)
|
||||
{
|
||||
Tick(dt);
|
||||
if (clock.Execute(dt)) fired++;
|
||||
}
|
||||
Assert.True(abs(fired - 30f / cooldown) <= 1.01f, $"cooldown {cooldown} at dt {dt}: {fired} restarts, expected {30f / cooldown} +-1");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmmoIntervalCarriesItsOvershoot()
|
||||
{
|
||||
_openCache.Upsert(new SimpleCommodityData { Name = "Slug", Shape = new Shape(), Price = 1, MaxStack = 100 });
|
||||
foreach (var interval in new[] { .5f, .26f })
|
||||
foreach (var dt in CadenceSteps)
|
||||
{
|
||||
var name = $"Stream@{interval:R}";
|
||||
if (_openCache.GetByName<ItemData>(name) == null)
|
||||
{
|
||||
var beam = (ConstantWeaponData) Armed(new ConstantWeaponData(), 100000);
|
||||
beam.AmmoInterval = interval;
|
||||
beam.AmmoType = _openCache.RefOf(_openCache.GetByName<SimpleCommodityData>("Slug"));
|
||||
_openCache.Upsert(new WeaponItemData
|
||||
{
|
||||
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
||||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
|
||||
Behaviors = { beam }
|
||||
});
|
||||
}
|
||||
var ship = Add(float3(0, 0, 0), new[] { name }, new[] { "Targeting", "Reactor" });
|
||||
var weapon = ship.Equipment.SelectMany(i => i.Behaviors).OfType<ConstantWeapon>().Single();
|
||||
typeof(ConstantWeapon).GetField("_ammo", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 100000);
|
||||
ship.SetTarget(TargetRef.None);
|
||||
weapon.Activate();
|
||||
for (var step = 0; step < (int) round(30f / dt); step++) Tick(dt);
|
||||
var spent = 100000 - weapon.Ammo;
|
||||
Assert.True(abs(spent - 30f / interval) <= 1.01f, $"ammo interval {interval} at dt {dt}: {spent} spent, expected {30f / interval} +-1");
|
||||
}
|
||||
}
|
||||
|
||||
// A fixed target under passive sensing settles where Entity.PassiveInfo says (G / k), at any step.
|
||||
[Fact]
|
||||
public void PassiveInfoSettlesAtTheDocumentedLevel()
|
||||
{
|
||||
foreach (var dt in new[] { 1f / 60f, 1f / 15f })
|
||||
{
|
||||
var observer = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
var target = Add(float3(0, 0, 100));
|
||||
var distance = 100f;
|
||||
while (observer.PassiveInfo(float2(0, distance), target.Visibility) > .5f)
|
||||
{
|
||||
distance *= 2;
|
||||
target.Position = float3(0, 0, distance);
|
||||
}
|
||||
var documented = observer.PassiveInfo(target.Position.xz, target.Visibility);
|
||||
Assert.InRange(documented, 1e-4f, .5f);
|
||||
for (var step = 0; step < (int) round(40f / dt); step++) Tick(dt);
|
||||
Assert.InRange(Track(observer, target), documented - 1e-3f, documented + 1e-3f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue