diff --git a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs index 85223b510..69109a622 100644 --- a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs +++ b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs @@ -159,7 +159,7 @@ namespace Aetheria.EditorTools MaxRange = 1e6f, FireRange = 1e6f, CommitTime = 1e5f, ArrivalTime = 2e5f }; var projectile = new GameObject("Wiring Round").AddComponent(); - projectile.Launch(zone, shot, new Vector3(0, 0, 0)); + projectile.Launch(null, zone, shot, new Vector3(0, 0, 0)); yield return null; zone.Update(.5f); diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 7917b0f7d..2af138d91 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -248,7 +248,7 @@ public class ActionGameManager : MonoBehaviour public ItemManager ItemManager { get; private set; } public Zone Zone { get; private set; } - public static SimClock Clock { get; } = new SimClock(1f / 60f); + public static SimClock Clock { get; } = SimClock.ForGame(); private bool _zoneEntered; private readonly (float2 direction, string name)[] _directions = { diff --git a/Assets/Scripts/Gameplay/Weapons/Projectile.cs b/Assets/Scripts/Gameplay/Weapons/Projectile.cs index e5f0b1e86..a585e92d0 100644 --- a/Assets/Scripts/Gameplay/Weapons/Projectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/Projectile.cs @@ -24,19 +24,16 @@ public class Projectile : MonoBehaviour private CultMath.float3 _barrel; private ShotOutcome _known; private bool _alive; - private CompositeDisposable _binding; + private ProjectileManager _owner; // Takes the shot as the sim holds it, the zone whose sim time it is drawn at, and where the barrel is as this round is first seen. - public void Launch(Zone zone, PendingShot shot, Vector3 barrel) + public void Launch(ProjectileManager owner, Zone zone, PendingShot shot, Vector3 barrel) { + _owner = owner; _zone = zone; _shot = shot; _barrel = new CultMath.float3(barrel.x, barrel.y, barrel.z); _known = null; - _binding?.Dispose(); - _binding = new CompositeDisposable( - zone.ShotCommitted.Where(o => o.ShotId == shot.ShotId).Subscribe(Committed), - zone.ShotResolved.Where(o => o.ShotId == shot.ShotId).Subscribe(Resolve)); _alive = true; transform.forward = new Vector3(shot.TravelDirection.x, 0, shot.TravelDirection.y); Draw(); @@ -56,14 +53,14 @@ public class Projectile : MonoBehaviour } // The sim's commit: the record it holds now (a contact burst's arrival is shortened here) and what it decided. - private void Committed(ShotOutcome outcome) + internal void Committed(ShotOutcome outcome) { if (_zone.TryGetShot(outcome.ShotId, out var record)) _shot = record; _known = outcome; } // The simulation's verdict. A round that hit or burst is drawn at its end and stops; a miss keeps flying. - private void Resolve(ShotOutcome outcome) + internal void Resolve(ShotOutcome outcome) { if (!_alive) return; _known = outcome; @@ -78,16 +75,14 @@ public class Projectile : MonoBehaviour private void Finish() { _alive = false; - _binding?.Dispose(); - _binding = null; + _owner?.Forget(_shot.ShotId); StartCoroutine(Fade()); } void OnDisable() { _alive = false; - _binding?.Dispose(); - _binding = null; + _owner?.Forget(_shot.ShotId); } IEnumerator Fade() diff --git a/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs index 216756cf9..3254d3177 100644 --- a/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs @@ -1,17 +1,46 @@ -using UnityEngine; +using System.Collections.Generic; +using UniRx; +using UnityEngine; public class ProjectileManager : InstantWeaponEffectManager { public Prototype ProjectilePrototype; + // The rounds this manager has in the air by shot id, told of the sim's commit and verdict by one subscription per zone: a + // gun that fires hundreds of rounds in a step costs the dictionary an entry per round and nothing else (no subscription, + // closure or disposable per round). + private readonly Dictionary _live = new Dictionary(); + private readonly CompositeDisposable _listening = new CompositeDisposable(); + private Zone _zone; + // The round is drawn by DrawAhead from what the simulation publishes, so there is nothing to aim, jitter // or inherit here: spread is priced by FireControl, and the sim's round inherits nothing from its shooter. + // A round the sim fired inside the step (rounds per step) is first seen where its shooter's barrel was at that moment: + // the barrel as it is now, carried back along the shooter's motion for the time since. public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var zone = source.Entity.Zone; if (!zone.TryGetShot(shotId, out var shot)) return; + Listen(zone); + var age = (float) (zone.ExactTime - shot.FireTime); + var velocity = source.Entity.Velocity; var p = ProjectilePrototype.Instantiate(); - p.Launch(zone, shot, source.GetBarrel(item.Hardpoint).position); + _live[shotId] = p; + p.Launch(this, zone, shot, source.GetBarrel(item.Hardpoint).position - new Vector3(velocity.x, 0, velocity.y) * age); p.Trail.Clear(); } + + internal void Forget(int shotId) => _live.Remove(shotId); + + private void Listen(Zone zone) + { + if (_zone == zone) return; + _listening.Clear(); + _live.Clear(); + _zone = zone; + zone.ShotCommitted.Subscribe(outcome => { if (_live.TryGetValue(outcome.ShotId, out var p)) p.Committed(outcome); }).AddTo(_listening); + zone.ShotResolved.Subscribe(outcome => { if (_live.TryGetValue(outcome.ShotId, out var p)) p.Resolve(outcome); }).AddTo(_listening); + } + + void OnDestroy() => _listening.Dispose(); } diff --git a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs index 814b2875d..1de3acbdb 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs @@ -9,23 +9,17 @@ using static CultMath.math; [Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] public class AutoWeaponData : InstantWeaponData { - public override Behavior CreateInstance(EquippedItem item) - { - return new AutoWeapon(this, item); - } + [IgnoreMember] + public override bool Automatic => true; } +// A fully automatic gun: it fires every round its cooldown allows while its trigger is held, from the press (Activate) +// to the let-go (Deactivate). A plain InstantWeapon is semi-automatic. public class AutoWeapon : InstantWeapon { - + protected override bool Repeats => _firing; + public AutoWeapon(InstantWeaponData data, EquippedItem item) : base(data, item) { } public AutoWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item) { } - - public override bool Execute(float dt) - { - if(_firing && _burstRemaining == 0 && _cooldown < 0) - Trigger(); - return base.Execute(dt); - } } diff --git a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs index 4d2bb5647..c3229622c 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs @@ -157,8 +157,7 @@ public class ChargedWeapon : InstantWeapon if (_data.FailureCharge > 1 && _charge > _data.FailureCharge) { _charging = false; - _cooldown = 1; - _coolingDown = true; + StartCountdown(); _charge = 0; OnFailed?.Invoke(); CauseDamage(_data.FailureDamage); diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index a2fc9f802..b90a124da 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -146,9 +146,10 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC } _ammoInterval -= dt / _data.AmmoInterval; - if (_ammoInterval < 0) + // An interval shorter than the step spends every round it spans. + while (_ammoInterval < 0) { - _ammoInterval = 1; + _ammoInterval += 1; if (_data.MagazineSize > 1 && _ammo > 0) _ammo--; else { diff --git a/Assets/Scripts/ServerShared/Behaviors/Cooldown.cs b/Assets/Scripts/ServerShared/Behaviors/Cooldown.cs index 88336cfe5..fb18a6054 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Cooldown.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Cooldown.cs @@ -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); } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs b/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs index c20b2b8b5..f6b61f3d6 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs @@ -42,6 +42,18 @@ public class InputCapacitor public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity); + // A weapon held through a step may fire several rounds in it (ruling rounds-per-step): the charge it holds and what flows in + // during the step pay for them, so for that step its buffer is a full activation plus the step's inflow; Settle trims what + // the step did not spend back to Capacity. The fill rate is unchanged, so the energy a gun draws is still exactly its + // rounds' cost. A sensor or a shield, and a weapon not held, keep the one-activation buffer above. + private float StepReach(float dt) => Capacity + Rate * dt; + + public float RequestedStepFill(float dt) => min(Rate * dt, max(0f, StepReach(dt) - Charge)); + + public void AddStepCharge(float amount, float dt) => Charge = clamp(Charge + amount, 0f, StepReach(dt)); + + public void Settle() => Charge = min(Charge, Capacity); + public bool CanSpend(float cost) => Charge >= cost; // Atomic and whole-or-nothing, mirroring Entity.TrySpendCapacitorCharge's old contract exactly (down to the diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index a6b3dcd2f..da23bc107 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -21,14 +21,39 @@ public class InstantWeaponData : WeaponData [Inspectable, JsonProperty("ammoInterval"), Key(20)] public bool SingleAmmoBurst; + // The fire mode, the one fact creation and the semi-auto catalog check both read: an automatic gun fires every round its + // cooldown allows while its trigger is held, every other kind is semi-automatic (a press fires one burst). + [IgnoreMember] + public virtual bool Automatic => false; + public override Behavior CreateInstance(EquippedItem item) { - return new InstantWeapon(this, item); + return Automatic ? new AutoWeapon(this, item) : new InstantWeapon(this, item); } public override Behavior CreateInstance(ConsumableItemEffect item) { - return new InstantWeapon(this, item); + return Automatic ? new AutoWeapon(this, item) : new InstantWeapon(this, item); + } + + // The shortest cooldown a plain (semi-automatic) gun may be authored with, 0.1 s: about ten presses a second. A faster one + // is a fire-as-fast-as-you-can-press mechanic, unfriendly to accessibility and a special case for the AI (ruling + // fire-mode-is-the-design); a gun that cycles faster is an AutoWeapon. + public const float ShortestSemiAutoCooldown = .1f; + + // The semi-automatic weapons of a catalog (gear and consumables, every kind that is not Automatic: plain, guided, mine, + // launcher, charged) that cycle faster than a finger should have to: one fault line per weapon. + public static IEnumerable SemiAutoFaults(IEnumerable catalog) + { + foreach (var item in catalog) + { + var behaviors = (item as GearData)?.Behaviors ?? (item as ConsumableItemData)?.Behaviors; + if (behaviors == null) continue; + foreach (var behavior in behaviors) + if (behavior is InstantWeaponData weapon && !weapon.Automatic && + Math.Min(weapon.Cooldown.Min, weapon.Cooldown.Max) < ShortestSemiAutoCooldown) + yield return $"{item.Name}: a {weapon.GetType().Name} with a {Math.Min(weapon.Cooldown.Min, weapon.Cooldown.Max):0.###} s cooldown, below {ShortestSemiAutoCooldown} s; make it an AutoWeapon or slow it"; + } } // The one formula of an instant weapon's damage per second at a range; the live weapon calls it with its evaluated @@ -58,6 +83,11 @@ 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; + // The shortest cycle a countdown divides by: a cooldown of zero would otherwise fire without end in one step. + private const float ShortestCycle = .0005f; public float BurstCount { get; protected set; } public float BurstTime { get; protected set; } @@ -87,6 +117,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // manager can bind its presentation to the one shot it belongs to instead of applying damage itself. public event Action OnFire; + // Whether a down trigger keeps firing: a plain gun is semi-automatic, one burst per press (a held key does not repeat, and + // an agent presses it again each time it is ready); an AutoWeapon repeats while its trigger is held, every round its + // cooldown allows at any step length. Fire mode is authored by the kind of the weapon (ruling fire-mode-is-the-design). + protected virtual bool Repeats => false; + public virtual void ResetEvents() { OnReloadBegin = null; @@ -165,11 +200,15 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _capacitor.UpdateStats(perShotEnergy, cooldown, rateOverride: RateOverride); } + // What the capacitor asks the bus for this step: a weapon held through it may fire several rounds in it, so it asks for + // the step's whole inflow; any other tops up to one activation. + private float Fill(float dt) => Repeats ? _capacitor.RequestedStepFill(dt) : _capacitor.RequestedFill(dt); + // Cut 4: the request PowerBus needs before Execute runs, mirroring EnergyDraw's IPowerConsumer pattern. public float PowerRequest(float dt) { RefreshInputCapacitor(); - return _capacitor.RequestedFill(dt); + return Fill(dt); } // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense. The ruling's own example of what @@ -183,7 +222,40 @@ 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 bool UseAmmo() + // Ends the countdown once it is below zero, keeping what it ran past zero (at most one step) for the next one. + // True when it ended the countdown now. + private bool FinishCountdown(float dt) + { + if (!_coolingDown || !(_cooldown < 0)) return false; + _overshoot = min(-_cooldown * CountdownSpan, dt); + _coolingDown = false; + if (_data.MagazineSize > 0 && _ammo == 0) + { + _ammo = _data.MagazineSize; + OnReloadComplete?.Invoke(); + } + else + OnCooldownComplete?.Invoke(); + return true; + } + + private float CountdownSpan => max(ShortestCycle, _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown); + + // A countdown that starts outside a round (a charge failing) carries the last one's overshoot like every restart. + protected void StartCountdown() + { + _cooldown = 1 - TakeOvershoot() / CountdownSpan; + _coolingDown = true; + } + + private float TakeOvershoot() + { + var overshoot = _overshoot; + _overshoot = 0; + return overshoot; + } + + private bool UseAmmo(float dt) { if (_data.MagazineSize <= 1) return true; @@ -208,7 +280,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if(hasAmmo) { OnReloadBegin?.Invoke(); - _cooldown = 1; + _cooldown = 1 - (_burstTimer + TakeOvershoot()) / _data.ReloadTime; _coolingDown = true; _firing = false; } @@ -218,29 +290,32 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo } public override bool Execute(float dt) + { + try { return Step(dt); } + finally { _capacitor.Settle(); } + } + + // One step of rounds. A round is due at a moment inside the step (_burstTimer is how long ago, at the step's end), and a + // cooldown that ends inside the step while the trigger is held starts the next burst at that moment, so a gun fires + // every round its cooldown allows in a step, each with its own roll, however short the cooldown or long the step. + private bool Step(float dt) { base.Execute(dt); // Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item // instance never reaches PowerBus.Step (see TrySpendActivationEnergy) and so never accrues charge. if (Item != null) - _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply); + _capacitor.AddStepCharge(Fill(dt) * Item.PowerSupply, dt); + var ended = false; + var retriggered = false; if (_coolingDown) { - _cooldown -= dt / (_data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown); - if (_cooldown < 0) - { - _coolingDown = false; - if (_data.MagazineSize > 0 && _ammo == 0) - { - _ammo = _data.MagazineSize; - OnReloadComplete?.Invoke(); - } - else - OnCooldownComplete?.Invoke(); - } + _cooldown -= dt / CountdownSpan; + ended = FinishCountdown(dt); } _burstTimer += dt; + while (true) + { while (_burstRemaining > 0 && _burstTimer > 0) { // A fused round whose arming distance Range cannot reach is refused, per round, before anything is @@ -249,7 +324,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo var refused = FireControl.Refuses(this, Entity); // Ammo and energy are consumed here, per round, or once per burst at its first unrefused round // (SingleAmmoBurst). UseAmmo returns false when triggering reload; cancel firing if that is the case - if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo())) + if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo(dt))) { _burstRemaining = 0; return false; @@ -260,8 +335,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 - (_burstTimer + 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; } } @@ -277,12 +353,27 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // whole expression, argument included, whenever OnFire has no subscriber (a headless run with no // Unity EntityInstance wiring, docs/headless-playground-cut.md), so a shot silently never fired. // Evaluate it into a local first. - var shotId = FireControl.Fire(this, Item, Entity); + var shotId = FireControl.Fire(this, Item, Entity, age: _burstTimer + _burstInterval); OnFire?.Invoke(shotId); CauseWearDamage(1); AddHeat(Heat); Entity.SetSignature(this, SignatureChannel.LaunchFlash, Visibility, Item); } + // A cooldown shorter than the step it started in is over in that step. + ended |= FinishCountdown(dt); + // The trigger is held and the weapon is ready: the next burst starts now, at the moment the cooldown ended (what it + // ran past zero is how long ago, at the step's end), or at the step's start when the weapon was already ready. + if (!Repeats || _burstRemaining > 0 || !CanFire || (retriggered && !_burstStarted)) break; + retriggered = true; + Trigger(); + if (_burstRemaining == 0) break; + if (ended) + { + _burstTimer = TakeOvershoot(); + ended = false; + } + else _burstTimer = dt; + } return true; } diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index d98437e59..e549688b0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -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; } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index f98ef8c32..510e3a51d 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1782,13 +1782,18 @@ public abstract class Entity Parent = null; } + // Indexed, not foreach: Equipment is a ReactiveCollection whose enumerator is heap-allocated, and every round a gun fires + // asks for the targeting system several times (rounds per step: hundreds of rounds a step must not allocate each). public T GetBehavior() where T : Behavior { - foreach (var equippedItem in Equipment) - if(equippedItem.Behaviors != null) - foreach (var behavior in equippedItem.Behaviors) - if (behavior is T b) - return b; + for (var i = 0; i < Equipment.Count; i++) + { + var behaviors = Equipment[i].Behaviors; + if (behaviors == null) continue; + foreach (var behavior in behaviors) + if (behavior is T b) + return b; + } return null; } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 8f3cc5018..1581e3457 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -698,7 +698,11 @@ public static class FireControl // Damage * interval, through this exact same freeze-and-queue path (a flight time of zero, since a beam's // authored Velocity is 0, commits and resolves in the tick it fires -- R4's short-flight degradation, // unchanged). Every other caller keeps reading the frozen weapon.Damage it always did. - public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null) + // Rounds per step (ruling rounds-per-step): a gun fires every round its cooldown allows in a step, so a round is born + // `age` seconds before the step's end, where the shooter and its target were then (carried back along their velocities). + // FireTime, the origin, the target's frozen pose and the arrival and commit times all follow, so a presenter draws the round + // from where it left and the commit measures the target's stray from the line it was fired along. + public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null, float age = 0f) { var zone = source.Zone; if (IsMineLayer(weapon)) @@ -708,7 +712,7 @@ public static class FireControl } // Mining Cut 3: a chunk target fires as an untargeted round until Cut 4 routes shots at chunks. var target = source.Target.Value.Entity; - var now = zone.Time; + var now = zone.Time - age; // R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time // re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share -- @@ -724,14 +728,14 @@ public static class FireControl var burstPosition = solution.BurstPosition; var fuse = solution.Fuse; var blastRadius = solution.BlastRadius; - var origin = source.Position; + var origin = source.Position - float3(source.Velocity.x, 0, source.Velocity.y) * age; var targetVelocity = float3.zero; var targetPosition = origin; if (engaged != null) { targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y); - targetPosition = engaged.Position; + targetPosition = engaged.Position - targetVelocity * age; } var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f; @@ -779,7 +783,12 @@ public static class FireControl // point is decided at its commit (CommitBurst), which reads it there. Each shot reads it once. if (!shot.DeliveredAtCommit) Deliver(ref shot, solution.BurstReach); - zone.PendingShots.Add(shot); + // The queue is in fire-time order, ties in shot-id order (ids rise as shots are made): Step walks it oldest first. Rounds + // of one step are born shooter by shooter, so a later shooter's earlier round goes in before an earlier shooter's later one. + var queue = zone.PendingShots; + var place = queue.Count; + while (place > 0 && queue[place - 1].FireTime > shot.FireTime) place--; + queue.Insert(place, shot); return shot.ShotId; } @@ -845,7 +854,10 @@ public static class FireControl if (shots.Count == 0) return; var now = zone.Time; - for (var i = shots.Count - 1; i >= 0; i--) + // Commits walk the queue in fire-time order, oldest first (Fire keeps it in it). Shots queued while the walk runs are + // left to the next step. + List<(float Arrival, int Id)> arrived = null; + for (int i = 0, end = shots.Count; i < end;) { var shot = shots[i]; @@ -859,6 +871,7 @@ public static class FireControl // target is gone. Republishing the committed outcome here used to put a hit marker on a corpse. var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now); shots.RemoveAt(i); + end--; if (!shot.Committed) zone.ShotCommitted.OnNext(miss); zone.ShotResolved.OnNext(miss); continue; @@ -879,16 +892,41 @@ public static class FireControl if (shot.Committed && now >= shot.ArrivalTime) { - // Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of - // which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage - // branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here - // the same way; only Apply decides what that means. - // Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one - // that throws would resolve, and hurt, again on every later tick. - shots.RemoveAt(i); - Apply(zone, shot); - zone.ShotResolved.OnNext(shot.Outcome); + (arrived ??= new List<(float, int)>()).Add((shot.ArrivalTime, shot.ShotId)); } + i++; + } + if (arrived == null) return; + + // The rounds that arrive in this step apply in arrival order, ties in shot-id order (ids rise as shots are made), so + // which of them breaks a shield or lands a kill follows when each hit, not the order the shooters were stepped in or the + // order their rounds left. + arrived.Sort((a, b) => a.Arrival != b.Arrival ? a.Arrival.CompareTo(b.Arrival) : a.Id.CompareTo(b.Id)); + foreach (var (_, id) in arrived) + { + var i = shots.FindIndex(q => q.ShotId == id); + if (i < 0) continue; + var shot = shots[i]; + + var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target); + if (targetGone) + { + // As in the commit walk: a shot whose target left the zone in this step resolves as a miss. + var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now); + shots.RemoveAt(i); + zone.ShotResolved.OnNext(miss); + continue; + } + + // Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of + // which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage + // branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here + // the same way; only Apply decides what that means. + // Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one + // that throws would resolve, and hurt, again on every later tick. + shots.RemoveAt(i); + Apply(zone, shot); + zone.ShotResolved.OnNext(shot.Outcome); } } diff --git a/Assets/Scripts/ServerShared/SimClock.cs b/Assets/Scripts/ServerShared/SimClock.cs index 6d00c46d0..ee260a50b 100644 --- a/Assets/Scripts/ServerShared/SimClock.cs +++ b/Assets/Scripts/ServerShared/SimClock.cs @@ -14,11 +14,19 @@ public sealed class SimClock // catch-up steps. public const float MaxFrameSeconds = .1f; + // Game steps per real second at design speed 1, and the sim seconds one step carries. The one owner of the game's + // tick: every driver of a game history steps Zone.Update by GameStep. Design speed (ruling sim-speed-surface) will + // set Step = DesignSpeed / TickRate in ForGame and nowhere else. + public const int TickRate = 30; + public const float GameStep = 1f / TickRate; + + public static SimClock ForGame() => new SimClock(GameStep, TickRate); + private readonly double _period; private double _accumulator; private bool _advancing; - public SimClock(float step, float stepsPerRealSecond = 60) + public SimClock(float step, float stepsPerRealSecond) { if (!(step > 0)) throw new ArgumentOutOfRangeException(nameof(step)); if (!(stepsPerRealSecond > 0)) throw new ArgumentOutOfRangeException(nameof(stepsPerRealSecond)); diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 96983ad12..55da2e5e9 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8df83e71feb44a18e4419f3b75beac5915a85def2829a93550f72622b2c92a77 -size 195808 +oid sha256:cf937f0b1ef1c0f732ccfd14728ecb786601b1ebf50699bcd7bc47cd81716fcc +size 195939 diff --git a/docs/aetheria-arena-sim-rate-map.md b/docs/aetheria-arena-sim-rate-map.md new file mode 100644 index 000000000..11646654a --- /dev/null +++ b/docs/aetheria-arena-sim-rate-map.md @@ -0,0 +1,200 @@ +# Aetheria arena, cut sim-rate: one game tick, lowered for game and arena alike + +Date: 2026-10-22. Status: map, drafted by Imagination for Self to commit beside `docs/aetheria-arena-target.md`. +Body pinned at `GameCult/Aetheria` origin/master `0267a191` (view-seam merged). Every claim below was probed on that tree. +Spec: `aetheria-arena:cut_spec:cut-sim-rate.r1`. Question: `aetheria-arena:question:sim-tick-rate`. + +## 1. What the operator said + +On 2026-10-21, while we were talking about Colosseum throughput (session transcript `53e9c70e`, lines 4811-4879): + +- "So we can expect 160x realtime for the Colosseum? That's not bad, we can gather a few thousand matches per hour per + thread on Yggdrasil, then?" +- "I think we can fudge the time step just a little, as long as there's enough resolution for all the relevant decisions" +- "Projectiles are roll based, their flight is pure presentation, I made that decision partly with this in mind" +- "Physics definitely doesn't need 60, not at the pace we're setting. I don't think it even needs 30." +- Answering Self's proposal (lower the whole game's step, chosen by a timer check, an equivalence test and her playtest, + landing before the runner): "Go for it." + +That is ruling `aetheria-arena:ruling:lower-game-sim-rate`. Her words decide two things: physics needs less than 60 Hz, +and probably less than 30. The proposal she approved decides the rest: the game's own rate is lowered (`A-one-sim` holds +unchanged), and the rate is chosen by the timer check, the equivalence test and her playtest. + +Rulings already in force on the same clock (2026-10-07, campaign `aetheria-release`): +- `sim-speed-knob`: the sim's own clock owns pace; Unity's time scale is not touched. +- `sim-speed-surface`: a design speed in game data times the player's accessibility slow-down. +- `sim-speed-tenth`: the design speed will be about a tenth of today's; the tick rate stays hidden; the player's ship may be + extrapolated. +- `tick-rate-follows-design-speed`: the step follows the design speed, so the real-time tick rate is a chosen constant. + "6hz is the kind of rate players notice. But we definitely don't *need* 60." +- `tick-30hz-floor-half`: "30 Hz with 0.5 floor". TickRate is 30 Hz at design speed. The player's factor ranges from 0.5 to 1, + so the slowest tick is 15 Hz. + +The design-speed and accessibility cut (`cut-sim-speed-scale.r2`) was withdrawn under `map-only-what-runs`. So on master the +game still runs at design speed 1 and 60 ticks per real second. The 30 Hz in `tick-30hz-floor-half` never landed. Her +2026-10-21 words ("I don't think it even needs 30") go below it, which is the fork in section 6. + +## 2. Body facts + +### Where the rate lives + +- **Two hidden 60s.** `ActionGameManager.Clock = new SimClock(1f / 60f)` (`ActionGameManager.cs:251`) is the only clock + that steps the game. `SimClock`'s constructor defaults `stepsPerRealSecond = 60` (`SimClock.cs:21`). The two are + independent: changing the step to 1/20 while leaving the default would run 60 steps of 1/20 s per real second, which + makes the game three times faster. The 60 is decided twice, by two writers that do not know about each other. +- **ServerShared has no game rate.** `Zone.Update(float deltaTime)` (`Zone.cs:356`) steps by whatever it is given. Every + sim rule integrates the `dt` it is handed. Nothing in ServerShared knows the game's step. +- **The headless drivers choose their own step.** There is no runner yet. The tests that pin game histories step with + literals: the two-AI digest (`DeterminismLedgerTests.cs:217`, `arena.Update(1f / 60f)`, 3600 steps) and the Duel history + (`SimClockTests.OneHistoryTwice`, `ClockFrame = 1f / 60f`, `:134`). The tests contain 64 `1f / 60` literals, 31 of them + `Update(1f / 60f)` calls. Most are physics fixtures that need some small step, not the game's step. +- **Unity's fixed timestep is not the sim.** No `FixedUpdate` or `Rigidbody` exists in `Assets/Scripts`. The sim steps + only through `Clock.Advance(Time.deltaTime, ...)` (`ActionGameManager.cs:1333`). `ProjectSettings/TimeManager.asset` + decides nothing about the sim and is left alone. + +### What assumes 60 Hz + +The question is not whether a rule mentions 60. It is whether the rule's per-second meaning depends on the step. + +- **Timers that drop their overshoot.** These rules restart a normalized countdown at 1 and discard what the last step + overshot: + - `InstantWeapon.Execute` (`InstantWeapon.cs:227-265`): the cooldown ends when `_cooldown < 0`. The next burst starts + only when a pilot triggers it again on a later step. + - `Cooldown.cs:51,60`. + - `Sensor` ping cooldown (`Sensor.cs:144-148,183`). + - `ConstantWeapon` ammo interval (`ConstantWeapon.cs:148-151`). + + For the cooldown this works out to `floor(C/dt) + 1` steps per cycle, not `C/dt`. A 0.25 s cooldown fires every 16 + steps at 60 Hz (0.267 s, 6.7% slow) and every 6 steps at 20 Hz (0.30 s, 20% slow). Damage per second is therefore a + function of the tick rate today, at 60 Hz as well. This is the rule that would make an equivalence test fail for the + wrong reason. +- **Timers that already carry.** Two rules keep their overshoot, so their per-second rates survive any step: + - the beam's roll cadence (`ConstantWeapon.cs:185-196`, `while (_beamTimer >= interval) _beamTimer -= interval`); + - the burst's round cadence (`InstantWeapon.cs:243-270`, several rounds in one step if the interval is shorter). + + Transfer-verb's give budget (`CargoTransfer.cs:80-94` on its branch) carries too. +- **Passive sensing is a forward-Euler decay.** `Sensor.Execute` applies `next = saturate(previous + G dt)` and then + `next *= 1 - TargetInfoDecay * dt` (`Sensor.cs:225-231`). Its fixed point is `G (1 - k dt) / k`, not the `G / k` that + `Entity.PassiveInfo` documents and returns (`Entity.cs:518-532`). The bias is `k dt`: detection ranges shrink as the + step grows. The exact form, `x e^{-k dt} + (G/k)(1 - e^{-k dt})`, settles at `G / k` at every step. A ping is an impulse + with no `dt` (`Sensor.cs:251-254`) and is step-free. +- **Rules that are step-free already.** + - Drag is `decay(v, drag, delta)` (`Ship.cs:132`). + - Shots are rolls: commit and arrival are sim times checked each step (`FireControl.Step`, `CommitHorizon = .5 s`), so a + resolution lands up to one step late, never differently. + - Tunnelling does not exist for rounds (ruling `projectile-flight-is-presentation`). + - One-tick notifications (paints, ping crossings, `IncomingPaints`) are defined per tick and read per tick + (`ActionGameManager.cs:1336-1340`). +- **Integration error that remains.** Ship motion is stepped explicitly. At a larger step, a hard-turning ship's arc and the + in-arc and in-range test at commit move by O(dt). This is the residual her "fudge" admits. The equivalence test measures + it. +- **Hull contact.** `Contacts.Step` tests overlap after every move. A closing speed times the step that exceeds the + smallest hull's radius can pass through. The cut checks the catalog's fastest ship against its smallest hull at the + chosen step. + +### How the render covers the step + +`DrawAhead` (`DrawAhead.cs:13-19,114-139`) draws every moving thing at `T = Zone.ExactTime + Clock.Lead`: +- closed-form poses (orbits, belts, the field) at T; +- integrated poses (ships, bodies) as the last step plus step velocity times `Lead`; +- the ship's heading turned by `TurnRate * Lead`. + +This is extrapolation (dead reckoning), not interpolation. It adds no latency, and presenters store no previous state, by +rule. At 20 Hz, `Lead` reaches 50 ms. The visible cost is a snap whenever velocity or turn rate changes between steps: +about `Δω * Lead` in heading (a full turn-rate reversal of 3 rad/s gives 0.15 rad at 20 Hz, against 0.05 at 60). Positions +move by `½ a Lead²`, which is negligible. + +Input reaches the sim through `PilotCommand`. Held fields persist, and one-shots queue in order until the next step applies +them (`Pilot.cs:43-102`). A tap shorter than a step still fires, because a pull and then a release in one step both apply, +in order. The step costs the player up to one step of input lag: 33 ms at 30 Hz, 50 ms at 20 Hz. + +### What the digest pins + +`TwoAiDigestIsPinned` pins one SHA-256 over 3600 per-step `Snapshot`s of the two-AI fight at `1/60`, for host class +`linux-x64` (`DeterminismLedgerTests.cs:31-34,186-249`). It has been repinned twice, each for a stated cause. This cut +repins it once, for two causes landing together: the game step (the history has a different number of steps of a +different length), and the carried timers and exact sensor decay (cadence and detection change at any step). It still +pins the same simulated length: 60 s, which is `60 * TickRate` steps. `OneHistoryTwice` and `TwoAiFightIsOneHistory` +compare two runs and need no pin. + +### Measured on Yggdrasil + +**Not measured yet.** +- **The probe is ready.** `SimRateProbe.cs` sits beside this map. It runs a catalog timer survey and the two-AI fight over + 120 simulated seconds at 60, 30, 20, 15 and 10 Hz, three reps each after a warm-up, and reports the best wall time, + shots, hits, first hull loss, first death and survivors. +- **Why it has not run.** It waited about an hour for a Yggdrasil slot. Then three runs failed in the fixture: the test + catalog now loads the first-party mod `aetheria.nakovalnya`, whose `ship.cc` and `ship.glb` are LFS objects the old + probe recipe did not resolve. With every pointer under `GameData` resolved, one is still missing. +- **The recipe to use.** transfer-verb's job script resolves LFS for current master + (`F:\Projects\eureka-handoff-aux-2026-10-19\imag-nak-r2\job.sh`). +- **What the earlier probe showed.** At d52b50f9 the Duel ran about 9,700 steps/s at 60 Hz, mostly with one ship alive. +- **Expectation, not a measurement.** The cost per step does not depend on dt, so cost per simulated second scales with + the tick rate: 2x at 30, 3x at 20, 4x at 15. + +## 3. Prior art + +- **Glenn Fiedler, "Fix Your Timestep!" (2004).** Use a fixed `dt`, an accumulator fed by real time, a clamp against the + spiral of death, and then draw either by interpolating the previous and current states (smooth, one step of added + latency) or by extrapolating. `SimClock` already is the accumulator and the clamp (`MaxFrameSeconds = .1`), and + `DrawAhead` is the extrapolating branch. +- **Supreme Commander (2007)** runs its sim at 10 ticks per second and draws interpolated, for fleets of hundreds. It is + the closest precedent for a large-unit, non-twitch game at a low tick. +- **StarCraft II** runs 16 game loops per sim second (22.4 per real second at Faster), with deterministic lockstep and + interpolated rendering. +- **Factorio** is fixed at 60 UPS, with every timer counted in ticks and lockstep multiplayer, and no decoupling of sim and + render rate. It is the counter-example: it keeps 60 because its belts and inserters are tick-quantised content. Ours are + per-second content. +- **EVE Online** runs its Destiny ball physics on a 1 Hz server tick, and clients interpolate and predict. It shows that + tactical space combat at a slow pace tolerates very coarse ticks when the player commands intent rather than twitch. +- **X4** steps the sectors the player is in at frame rate and the others at a much coarser, abstracted rate. That is the + living-world campaign's concern, not this cut's. + +The lesson that carries over: content authored in seconds survives a step change only if every timer and integrator is +step-exact. Fiedler's accumulator gives that for the clock, and section 2's two families (timers that drop overshoot, +Euler decay) are the places where Aetheria does not have it yet. + +## 4. Authority map + +- **Owner.** `SimClock` owns the game's tick: `SimClock.TickRate` (steps per real second at design speed 1) and + `SimClock.GameStep = 1f / TickRate` (sim seconds per step). `SimClock.ForGame()` builds the game's clock from both. +- **Inputs.** None at run time. It is a code constant, so it is part of the code revision in `A-pure`'s tuple. +- **Outputs.** The `dt` every game-rate driver hands to `Zone.Update`, and `Lead`. +- **Derived.** + - `ActionGameManager.Clock` is `SimClock.ForGame()`. + - The digest, the Duel history and later the runner step by `SimClock.GameStep`. + - A timer's cadence and a sensor's settling level are functions of seconds, not of steps. +- **Demoted.** + - `SimClock`'s `stepsPerRealSecond` default is no longer an owner. The parameter becomes required, and the game's value is + derived from `TickRate`. + - `1f / 60f` in `ActionGameManager` is no longer an owner; it is derived from `SimClock.GameStep`. + - Physics fixtures may keep a literal step of their own, because they test physics, not the game's history. +- **Forbidden writers.** + - Any `new SimClock(` in `Assets/Scripts` other than `ForGame`. + - Any literal step in a driver of a pinned game history. + - A timer that resets to 1 and discards its overshoot. + - A per-step decay written as `1 - k * dt`. +- **Shared paths.** The player's game, the digest and Duel histories, and the future runner all step `Zone.Update` by + `SimClock.GameStep`. The design-speed cut, when re-mapped, sets `Step = DesignSpeed / TickRate` inside `ForGame` and + touches nothing else. +- **Deletion line.** The default `= 60` and the `1f / 60f` in `ActionGameManager` go first. The overshoot-dropping resets go + before the rate changes, so the rate change is measured on step-exact rules. + +## 5. Not this cut + +- **Brains deciding below the physics step.** The ruling allows it, and `PilotCommand`'s held fields already make it + possible. No brain-rate machinery lands here. +- **Design speed and the player's slow-down** (`sim-speed-scale`, withdrawn). `ForGame` is the one place where it will + land. +- **Render interpolation.** Extrapolation is ruled (`sim-speed-tenth`, DrawAhead's no-stored-state rule). Her playtest is + where a heading snap would show. If it does, interpolating the player's ship only is the follow-up, at one step of + latency. +- **Unity's `TimeManager` fixed timestep**, which the sim does not read. + +## 6. The fork + +`aetheria-arena:question:sim-tick-rate`: which tick rate Hands lands for her playtest, given `tick-30hz-floor-half`. +Options and recommendation are in the question. In short: +- 30 Hz keeps the in-force ruling and halves the cost. +- 20 Hz follows her 2026-10-21 words and costs a third, but at the 0.5 accessibility floor the real tick is 10 Hz. +- 15 Hz is a quarter of the cost and 7.5 Hz at the floor, near the 6 Hz she called noticeable. diff --git a/docs/aetheria-arena-target.md b/docs/aetheria-arena-target.md index d6f344501..bbd027a2a 100644 --- a/docs/aetheria-arena-target.md +++ b/docs/aetheria-arena-target.md @@ -85,7 +85,8 @@ Harness: - `A-pure`: A match is a pure function of (code revision, catalog revision, scenario id, match seed, brain ids per side, host determinism class). The same inputs give the same per-tick state digest, in any process, run alone or - in a batch. + in a batch. The step is part of the code revision: `SimClock.GameStep`, never a + driver's own literal. - `A-no-clock`: No input to a match comes from the wall clock, process identity, hash-code ordering of reference types, or a stream another subsystem advances. Every draw a match reaches comes from `SimulationDice` (or a successor owner) keyed by a structured seed. A missing seed is refused, never defaulted to the clock. @@ -169,10 +170,17 @@ No cell is empty. The Scenario row exposes a gap: `Name` is today's only identit 3. **View seam.** Add a `PilotView` built from sensors and book. Brains lose their live `Ship`, `Zone` and `ItemManager` references. `Flight` and `Minion` become `DoctrineBrain` behind an `IBrain` registry. Aiming moves to the view if `observation-fidelity` rules for it. -4. **Replay.** The recorder writes inputs, commands and digests every N ticks as CultCache. The replayer reproduces +4. **Sim rate** (ruling `lower-game-sim-rate`, map `docs/aetheria-arena-sim-rate-map.md`, spec `cut-sim-rate.r1`). + - One game tick, `SimClock.TickRate`, lowered for game and arena alike, so `A-one-sim` holds unchanged. + - Before it, timers carry their overshoot and passive sensing decays exactly, so content authored in seconds means the + same thing at any tick. + - The two-AI digest is repinned once, at the game step, for those two causes. + - It lands before replay and the runner, so recorded digests and measured throughput are at the real rate. + - The rate is the one question `sim-tick-rate` decides, then the equivalence test, then her playtest. +5. **Replay.** The recorder writes inputs, commands and digests every N ticks as CultCache. The replayer reproduces them and names the first divergent tick. Replay tests read an existing match. -5. **Runner.** `AetherDb arena`: a scenario, two brain ids and a seed range produce results and replays in a given +6. **Runner.** `AetherDb arena`: a scenario, two brain ids and a seed range produce results and replays in a given directory. It runs in Yggdrasil's dotnet container, with two-ship throughput measured and recorded. -6. **First tournament.** Round-robin over two or more brains (`DoctrineBrain` plus one variant) and the combat +7. **First tournament.** Round-robin over two or more brains (`DoctrineBrain` plus one variant) and the combat scenarios, with enough seeds per pairing. Ratings are derived with confidence intervals, and the results document is ready to feed `aetheria-matchups`. diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs index 5fdf7bffd..7a19d8173 100644 --- a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -30,12 +30,12 @@ public sealed partial class RunStartTests var view = arena.Agents.Single(a => a.Ship == east).View; var startGap = east.Position.x - west.Position.x; - arena.Update(1f / 60f); - arena.Update(1f / 60f); + arena.Update(SimClock.GameStep); + arena.Update(SimClock.GameStep); Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); Assert.True(east.Velocity.x < -.5f, $"the follower thrusts toward its anchor from the first steps (vx {east.Velocity.x})"); - for (var i = 0; i < 120; i++) arena.Update(1f / 60f); + for (var i = 0; i < System.MathF.Round(2f / SimClock.GameStep); i++) arena.Update(SimClock.GameStep); Assert.True(east.Position.x - west.Position.x < startGap - 20f, "the follower closed on its anchor"); } @@ -50,10 +50,10 @@ public sealed partial class RunStartTests var sentinel = new float3(0, 0, 12345); Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); - for (var i = 0; i < 3; i++) arena.Update(1f / 60f); + for (var i = 0; i < 3; i++) arena.Update(SimClock.GameStep); Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); east.Command.Aim = sentinel; - arena.Update(1f / 60f); + arena.Update(SimClock.GameStep); Assert.NotEqual(sentinel, east.Command.Aim.Value); var steps = 0; diff --git a/tests/Aetheria.Shared.Tests/BeltScenarioTests.cs b/tests/Aetheria.Shared.Tests/BeltScenarioTests.cs index 2c352b6e8..d8eaa848a 100644 --- a/tests/Aetheria.Shared.Tests/BeltScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/BeltScenarioTests.cs @@ -35,7 +35,7 @@ public sealed partial class RunStartTests var field = arena.Planets.Single(body => body.Value == belt).Key; // A weapon's reach and a sensor's gain are read once the ship's stats have settled, so the first second passes first. - for (var tick = 0; tick < 60; tick++) arena.Update(1f / 60f); + for (var tick = 0; tick < System.MathF.Round(1f / SimClock.GameStep); tick++) arena.Update(SimClock.GameStep); var inReach = new List(); player.VisibleChunksInReach(inReach); Assert.NotEmpty(inReach); diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 1a8df874c..b7d1b88ab 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -354,6 +354,7 @@ public sealed partial class ContactRevealTests : IDisposable capacitor.AddCharge(1000f); ship.SetTarget(TargetRef.None); gun.Activate(); + gun.Deactivate(); for (var i = 0; i < 10; i++) { Tick(.1f); @@ -1277,6 +1278,7 @@ public sealed partial class ContactRevealTests : IDisposable ((InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(killerGun)).AddCharge(1000f); killer.SetTarget(TargetRef.None); killerGun.Activate(); + killerGun.Deactivate(); for (var ticks = 0; !killed && ticks < 20; ticks++) Tick(.1f); Assert.True(killed, "fixture: the kill lands inside a tick"); Assert.False(_zone.Entities.Contains(e), "fixture: the kill took the subject out of the zone"); diff --git a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs index a21aa7f19..9c827b968 100644 --- a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs @@ -119,16 +119,16 @@ public sealed partial class RunStartTests private static CrossingPass FlyPass(Zone arena, Ship player, Entity crosser) { - for (var tick = 0; tick < 60 * CrossingSeconds; tick++) + for (var tick = 0; tick < System.MathF.Round(CrossingSeconds / SimClock.GameStep); tick++) { var before = crosser.Position - player.Position; - arena.Update(1f / 60f); + arena.Update(SimClock.GameStep); var after = crosser.Position - player.Position; if (before.x < 0 && after.x >= 0) { var at = float2(after.x, after.z); var relative = crosser.Velocity - player.Velocity; - return new CrossingPass(true, (tick + 1) / 60f, at, length(relative), abs(at.x * relative.y - at.y * relative.x) / dot(at, at)); + return new CrossingPass(true, (tick + 1) * SimClock.GameStep, at, length(relative), abs(at.x * relative.y - at.y * relative.x) / dot(at, at)); } } diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 65be67d68..07f684727 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -30,8 +30,21 @@ public sealed partial class RunStartTests // Then for staged knowledge (ruling repin-for-staged-knowledge), which moved it twice: the fighters are staged knowing // each other, so they engage at step 0 (f19b9992); and Follow now flies only on an anchor the pilot itself tracks, so // once the sensors drop the staged anchor the follower stops following it (ce81a426). - private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33"; - private const int TwoAiSteps = 3600; + // Repinned a third time, once, for two causes (sim-rate cut): the rules went step-exact (timers keep their overshoot, + // passive sensing decays exactly), which alone gives 4f4a1c8b88308d0a368527549f33baf0e157a056b4c0fd33aeb110b027b862e1 + // at 60 Hz; and the game step is now SimClock.GameStep (30 Hz), 1800 steps of the same 60 simulated seconds: d5e16969d5366349f2cddeec53f7e8e8c69074dcf5aa7aabb365e9a91a34511f. + // Repinned a fourth time, once (rounds-per-step): a round is born at its due moment inside its step, so its FireTime, origin + // and frozen target pose are carried back by its age (a pull's first round leaves at the step's start, not its end) and + // its commit and arrival times follow. Rebasing onto master moved nothing (d5e16969 measured at the rebased h1 head). + // Repinned a fifth time, once (h3): the shot queue is kept in fire-time order and resolved oldest first, ties by shot id, so within a step a + // later shooter's earlier round resolves before an earlier shooter's later one (19710609 before). The trigger being the weapon's own state moved nothing + // (measured alone: 19710609). + // Repinned a sixth time, once (h5): a step commits its due shots in fire-time order, then applies the arrived ones in arrival + // order, ties by shot id, so every commit of a step precedes its applies (it was commit then apply, shot by shot, in fire + // order). The two parts measured: commit-then-apply split alone a1318ee0, with the arrival sort 55ecd858; the old walk at the + // h5 head, rebased onto master, still measures 108c93dc, so the rebase and the fire-mode property moved nothing. + private const string TwoAiPinnedDigest = "55ecd858875ca6a5418d6badd40e71d9bbd363ca782843005483a308bcf07d74"; + private const float TwoAiSeconds = 60f; [Fact] public void ItemManagerRefusesSeedZero() @@ -186,8 +199,9 @@ public sealed partial class RunStartTests } // Two piloted fighters about 900 apart, each approaching the other, hostile both ways; the player bare, unpiloted - // and far from the fight. Steps 3600 times at 1/60 s and returns the SHA-256 of the joined snapshots. - private (string digest, bool bothPiloted, int hits, bool hullLost) TwoAiFight() + // and far from the fight. Steps TwoAiSeconds at `dt` (the game's step by default) and returns the SHA-256 of the joined + // snapshots, the hull each fighter ends with and the sim time a hull first reaches zero (TwoAiSeconds when none does). + private (string digest, bool bothPiloted, int hits, bool hullLost, float eastHull, float westHull, float firstKill, double stepSeconds) TwoAiFight(uint seed = GalaxySeed, float dt = SimClock.GameStep) { Ship east = null, west = null; var (_, arena, _, failures) = Launch(new Scripted(false, stage => @@ -197,7 +211,7 @@ public sealed partial class RunStartTests west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship; stage.Approach(east, west); stage.Approach(west, east); - })); + }, seed)); Assert.True(failures.Count == 0, string.Join("; ", failures)); east.SetIff(west, true); west.SetIff(east, true); @@ -212,13 +226,17 @@ public sealed partial class RunStartTests if (outcome.Hit) hits++; }); var history = new StringBuilder(); - for (var step = 0; step < TwoAiSteps; step++) + var firstKill = TwoAiSeconds; + var steps = (int) MathF.Round(TwoAiSeconds / dt); + var clock = System.Diagnostics.Stopwatch.StartNew(); + for (var step = 0; step < steps; step++) { - arena.Update(1f / 60f); + arena.Update(dt); history.Append(Snapshot(arena, shots)).Append('\n'); + if (firstKill == TwoAiSeconds && (east.Hull.Durability <= 0 || west.Hull.Durability <= 0)) firstKill = (step + 1) * dt; } var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(history.ToString()))).ToLowerInvariant(); - return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart); + return (digest, bothPiloted, hits, east.Hull.Durability + west.Hull.Durability < hullAtStart, east.Hull.Durability, west.Hull.Durability, firstKill, clock.Elapsed.TotalSeconds); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs index d288cce27..37111b114 100644 --- a/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs +++ b/tests/Aetheria.Shared.Tests/DrawnPoseTests.cs @@ -171,7 +171,7 @@ public sealed class DrawnPoseTests : IDisposable { var w = Build(); var ship = new Ship(w.Items, w.Zone, w.Hull, new EntitySettings()); - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); var xs = new List(); var ys = new List(); var leads = new List(); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 3a06a63f7..4b7b7027b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -1012,11 +1012,12 @@ public sealed class FireControlPerWeaponTests : IDisposable } // Holding the trigger fires once per Cooldown: the round that starts the cooldown pays for the tick it flew in. - // Cooldown 1 at .25 s ticks is a shot every 5 ticks, 12 in 15 s. And a burst whose every round is refused - // leaves no cooldown: the same held trigger is ready every tick. Kills: a first round that starts the cooldown - // at 1 after the tick's decrement (a shot every 6 ticks, 10 in 15 s); a cooldown left by a refused burst. + // Cooldown 1 at .25 s ticks is a shot a second, 15 in 15 s (+-1: the first cycle has no carried overshoot). And a burst + // whose every round is refused leaves no cooldown: the same held trigger is ready every tick. Kills: a first round + // that starts the cooldown at 1 after the tick's decrement (a shot every 5 ticks, 12 in 15 s); a cooldown left by a + // refused burst. [Theory] - [InlineData(100f, 12)] + [InlineData(100f, 15)] [InlineData(29.5f, 0)] public void AHeldTriggerFiresOncePerCooldownAndARefusedBurstLeavesNone(float range, int shots) { @@ -1036,7 +1037,7 @@ public sealed class FireControlPerWeaponTests : IDisposable if (weapon.CanFire) readyTicks++; } - Assert.Equal(shots, fired); + if (shots == 0) Assert.Equal(0, fired); else Assert.InRange(fired, shots - 1, shots + 1); if (shots == 0) Assert.Equal(60, readyTicks); } @@ -1051,6 +1052,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var weapon = (InstantWeapon) r.Gun.Weapon; r.Zone.Update(.25f); weapon.Activate(); + weapon.Deactivate(); r.Zone.Update(.25f); Assert.False(weapon.CanFire, "control: the first burst fired and is cooling"); for (var i = 0; i < 10 && !weapon.CanFire; i++) r.Zone.Update(.25f); diff --git a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs index 380ffe62c..f93b24df0 100644 --- a/tests/Aetheria.Shared.Tests/FlowFieldTests.cs +++ b/tests/Aetheria.Shared.Tests/FlowFieldTests.cs @@ -183,9 +183,9 @@ public sealed class FlowFieldTests : IDisposable // Zone.Flow and the lowered scroll are one zone-time value: the flow at p is the law evaluated at the zone's // own FlowScroll, after any run of steps (a flow that ran ahead of or behind the shader's scroll differs here). var zone = ZoneWith(Flow(), 3.25); - for (var step = 0; step < 90; step++) + for (var step = 0; step < System.MathF.Round(1.5f / SimClock.GameStep); step++) { - zone.Update(1f / 60f); + zone.Update(SimClock.GameStep); foreach (var p in Probes) AssertClose(FlowField.Global(Flow(), p, zone.FlowScroll), zone.Flow(p), 1e-4f); } } diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index 8ddf775a7..c3c395f6f 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -168,6 +168,7 @@ public sealed class MineTests : IDisposable { var before = lab.Zone.Mines.Count; lab.Weapon.Activate(); + lab.Weapon.Deactivate(); lab.Zone.Update(dt); Assert.Equal(before + 1, lab.Zone.Mines.Count); return lab.Zone.Mines.Last(); @@ -515,8 +516,8 @@ public sealed class MineTests : IDisposable [Fact] public void LayingIsDeterministicAndFillsItsSpread() { - var a = Build(spread: 40f, velocity: 5f, cooldown: .01f, layerFaction: A); - var b = Build(spread: 40f, velocity: 5f, cooldown: .01f, layerFaction: A); + var a = Build(spread: 40f, velocity: 5f, cooldown: .199f, layerFaction: A); + var b = Build(spread: 40f, velocity: 5f, cooldown: .199f, layerFaction: A); var angles = new List(); using var sub = a.Zone.Mines.ObserveAdd().Subscribe(e => { @@ -532,6 +533,7 @@ public sealed class MineTests : IDisposable foreach (var lab in new[] { a, b }) { lab.Weapon.Activate(); + lab.Weapon.Deactivate(); lab.Zone.Update(.1f); lab.Zone.Update(.1f); } @@ -803,6 +805,7 @@ public sealed class MineTests : IDisposable lab.Zone.Log = log.Add; lab.Weapon.Activate(); + lab.Weapon.Deactivate(); lab.Zone.Update(.1f); Assert.Empty(lab.Zone.Mines); diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 484116eb3..52c2fe53f 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -245,7 +245,7 @@ public sealed partial class MiningCut3Tests : IDisposable // at different ranges and bearings, seen by a weak sensor so neither saturates, a ping part way through) // yields the same EntityInfoGathered trace, bit for bit, as it did before Sensor.Gain existed. The golden hash // was recorded against the pre-extraction Sensor.Execute (cb4a1dbd with this scene patched in). - private const string Golden = "919B7EE7C5D1B40F774EE34283D62DE016862FE799B3EF16362D7FC75FD73C79"; + private const string Golden = "A721BC4A05E9BC53FA29437F5D2B38F5417389CB0668C623D00254971D4AB794"; [Fact] public void SensorGainExtractionChangesNothing() @@ -280,8 +280,9 @@ public sealed partial class MiningCut3Tests : IDisposable // Q14 A's parity: one rule, two integrators. A ship carrying a reflector of the chunk's own cross-section // (the kind's per-cell reflectivity × the chunk's area in cells), held still where the chunk is, settles under - // the per-tick loop at the value ChunkInfo returns on demand. The discrete loop's fixed point sits one decay - // step below the continuous one, exactly: x* (1 - k dt). The observer is off the sensor's axis, so the + // the per-tick loop at the value ChunkInfo returns on demand. The per-tick decay is exact, so the loop settles at G / k + // at any step. (Until sim-rate its fixed point sat one decay + // step below the continuous one, exactly: x* (1 - k dt).) The observer is off the sensor's axis, so the // bearing term is live. [Fact] public void ChunkInfoMatchesAnEntityHeldStill() @@ -300,7 +301,7 @@ public sealed partial class MiningCut3Tests : IDisposable var settled = observer.ChunkInfo(chunk); var perTick = observer.EntityInfoGathered[rock]; Assert.InRange(settled, .12f, .9f); // visible, unsaturated: the balance, not the cap, decides it - var discrete = settled * (1f - GameSettings().TargetInfoDecay * Dt); + var discrete = settled; Assert.True(abs(perTick - discrete) <= 1e-3f * settled, $"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})"); } @@ -704,7 +705,7 @@ public sealed partial class MiningCut3Tests : IDisposable var settled = observer.ChunkInfo(chunk); var perTick = observer.EntityInfoGathered[rock]; Assert.InRange(settled, .12f, .9f); - var discrete = settled * (1f - GameSettings().TargetInfoDecay * Dt); + var discrete = settled; Assert.True(abs(perTick - discrete) <= 2e-3f * settled, $"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})"); } @@ -1049,7 +1050,8 @@ public sealed partial class MiningCut3Tests : IDisposable if (far.Max() < .999f && far.Zip(farQuiet, (a, b) => abs(a - b)).Max() > .01f) unsaturatedPings++; } Assert.True(unsaturatedPings > 0, "no configuration saw a ping land unsaturated"); - Assert.Equal("35DE0E26ACC5BD51F6B6E170AB890C3FE5D8BB2BE3A9DC3F3EB66CDA0FA23728", Convert.ToHexString(SHA256.HashData(bytes.ToArray()))); + var pingHash = Convert.ToHexString(SHA256.HashData(bytes.ToArray())); + Assert.True(pingHash == "283A6129533784AE08FDA7D9FFA2BB0862FA8F64AD32C6726E80BC1138443FE3", $"the ping-arm trace hash {pingHash}"); } // One observer and two reflecting ships in an empty zone, 80 ticks, a ping at tick 20 when `ping`. diff --git a/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs new file mode 100644 index 000000000..8bedd9bd0 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// Fire mode is a design property (ruling fire-mode-is-the-design): a plain InstantWeapon is semi-automatic, and its cycle +// stays at a pace a finger can keep. A gun that cycles faster is an AutoWeapon. +public sealed class SemiAutoCatalogTests +{ + private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v }; + + private static GearData Gun(string name, InstantWeaponData data) => new GearData { Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, Behaviors = { data } }; + + [Fact] + public void TheShippedCatalogAndItsPackagesHoldNoFasterPlainGun() + { + var root = RunStartTests.FindRepoRoot(); + using var work = new TempDirectory(); + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(Path.Combine(root, "GameData", "Aetheria.cc"), Path.Combine(work.Path, "derived.cc"), + Path.Combine(root, "GameData", "Mods"), TestCatalog.Registry()); + Assert.Empty(excluded); + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalog, true), AetheriaStores.CatalogTypes); + var gear = cache.GetAll().ToArray(); + Assert.Empty(InstantWeaponData.SemiAutoFaults(cache.GetAll())); + // The check reads a catalog that holds guns, and the two automatic ones are AutoWeapons. + foreach (var name in new[] { "Slug Rifle", "Long Slug Rifle" }) + Assert.IsType(gear.Single(g => g.Name == name).Behaviors.Single(b => b is InstantWeaponData)); + Assert.Contains(gear, g => g.Behaviors.Any(b => b is InstantWeaponData)); + } + + [Fact] + public void ThePlainGunCheckRefusesAFastSemiAutoAndOnlyThat() + { + var floor = InstantWeaponData.ShortestSemiAutoCooldown; + var fast = Gun("Fast", new InstantWeaponData { Cooldown = Const(floor / 2) }); + var edge = Gun("Edge", new InstantWeaponData { Cooldown = Const(floor) }); + var auto = Gun("Auto", new AutoWeaponData { Cooldown = Const(.01f) }); + var wide = Gun("Wide", new InstantWeaponData { Cooldown = new PerformanceStat { Min = floor / 2, Max = floor * 2 } }); + var faults = InstantWeaponData.SemiAutoFaults(new[] { fast, edge, auto, wide }).ToArray(); + // The floor is 0.1 s: ten presses a second is allowed, twenty is not. + Assert.Equal(.1f, floor); + Assert.Single(InstantWeaponData.SemiAutoFaults(new[] { Gun("Twenty", new InstantWeaponData { Cooldown = Const(.05f) }) })); + Assert.Empty(InstantWeaponData.SemiAutoFaults(new[] { Gun("Ten", new InstantWeaponData { Cooldown = Const(.1f) }) })); + Assert.Equal(2, faults.Length); + Assert.Contains(faults, f => f.StartsWith("Fast:")); + Assert.Contains(faults, f => f.StartsWith("Wide:")); + } + + // Fire mode is one property of the weapon's data: every kind but AutoWeaponData is semi-automatic, and the floor holds each of + // them, as gear or as a consumable. An automatic gun is exempt wherever it is carried. + public static IEnumerable Kinds() => new[] + { + new object[] { "plain", (Func) (() => new InstantWeaponData()) }, + new object[] { "guided", (Func) (() => new GuidedWeaponData()) }, + new object[] { "mine", (Func) (() => new MineLayerData()) }, + new object[] { "launcher", (Func) (() => new LauncherData()) }, + new object[] { "charged", (Func) (() => new ChargedWeaponData { CanFireEarly = true }) }, + }; + + [Theory, MemberData(nameof(Kinds))] + public void TheFloorRefusesEverySemiAutoKindBelowItAsGearOrConsumable(string kind, Func make) + { + InstantWeaponData At(float cooldown) { var data = make(); data.Cooldown = Const(cooldown); return data; } + Assert.False(make().Automatic); + var floor = InstantWeaponData.ShortestSemiAutoCooldown; + var slow = At(floor); + var fast = At(floor / 2); + Assert.Empty(InstantWeaponData.SemiAutoFaults(new ItemData[] { Gun(kind, slow), new ConsumableItemData { Name = kind + " dose", Behaviors = { slow } } })); + var faults = InstantWeaponData.SemiAutoFaults(new ItemData[] { Gun(kind, fast), new ConsumableItemData { Name = kind + " dose", Behaviors = { fast } } }).ToArray(); + Assert.Equal(2, faults.Length); + Assert.Contains(faults, f => f.StartsWith(kind + ":")); + Assert.Contains(faults, f => f.StartsWith(kind + " dose:")); + } + + [Fact] + public void AnAutomaticGunIsExemptAsGearOrConsumable() + { + var auto = new AutoWeaponData { Cooldown = Const(.01f) }; + Assert.True(auto.Automatic); + Assert.Empty(InstantWeaponData.SemiAutoFaults(new ItemData[] { Gun("Auto", auto), new ConsumableItemData { Name = "Auto dose", Behaviors = { auto } } })); + } +} diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 922e3e5b5..50d0bc824 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -172,6 +172,7 @@ public sealed class SignatureTests : IDisposable } r.Ship.SetTarget(TargetRef.None); r.Weapon.Activate(); + if (r.Weapon is InstantWeapon) r.Weapon.Deactivate(); // a gun is tapped; a beam burns while held for (var i = 0; i < 5; i++) r.Zone.Update(.1f); } diff --git a/tests/Aetheria.Shared.Tests/SimClockTests.cs b/tests/Aetheria.Shared.Tests/SimClockTests.cs index 6426fe47d..75c2437f5 100644 --- a/tests/Aetheria.Shared.Tests/SimClockTests.cs +++ b/tests/Aetheria.Shared.Tests/SimClockTests.cs @@ -34,7 +34,7 @@ public sealed class SimClockTests { foreach (var frames in new[] { new[] { 1f / 60 }, Irregular, new[] { .09f, .004f, 0f, 3f } }) { - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); var passed = Drive(clock, frames, 500); Assert.True(passed.Count >= 500); Assert.All(passed, dt => Assert.Equal(clock.Step, dt)); @@ -47,7 +47,7 @@ public sealed class SimClockTests public void StepsFollowRealTime() { // Ten real seconds of irregular frames. - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); var elapsed = 0.0; for (var frame = 0; elapsed < 10.0; frame++) { @@ -71,7 +71,7 @@ public sealed class SimClockTests [Fact] public void AHitchIsClamped() { - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); var ran = clock.Advance(5f, _ => { }); Assert.Equal(6, ran); Assert.Equal(6, clock.Steps); @@ -80,7 +80,7 @@ public sealed class SimClockTests [Fact] public void AStepCannotResetItsClock() { - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); Assert.Throws(() => clock.Advance(1f / 60f, _ => clock.Reset())); clock.Advance(.005f, _ => { }); Assert.True(clock.Lead > 0); @@ -92,7 +92,7 @@ public sealed class SimClockTests [Fact] public void PauseFeedsNothing() { - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); clock.Advance(.025f, _ => { }); var lead = clock.Lead; var steps = clock.Steps; @@ -124,14 +124,14 @@ public sealed class SimClockTests [Fact] public void ABadClockIsRefusedByFieldName() { - Assert.Equal("step", Assert.Throws(() => new SimClock(0f)).ParamName); + Assert.Equal("step", Assert.Throws(() => new SimClock(0f, 60)).ParamName); Assert.Equal("stepsPerRealSecond", Assert.Throws(() => new SimClock(1f, 0f)).ParamName); } } public sealed partial class RunStartTests { - private const float ClockFrame = 1f / 60f; + private const float ClockFrame = SimClock.GameStep; private const int HistorySteps = 1800; // The Duel launched as the game launches it, its item random reseeded so play draws the same numbers every time. @@ -173,7 +173,7 @@ public sealed partial class RunStartTests // Feeds the zone through one SimClock with the frames cycled, snapshotting every step until HistorySteps have run. private static List History(Zone zone, float[] frames) { - var clock = new SimClock(1f / 60f); + var clock = new SimClock(1f / 60f, 60); var shots = new List(); using var _ = zone.ShotResolved.Subscribe(outcome => shots.Add($"{outcome.ShotId}:{outcome.Result}")); var history = new List(); diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs new file mode 100644 index 000000000..dc005e4c0 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -0,0 +1,654 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using CultMath; +using UniRx; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut sim-rate (ruling lower-game-sim-rate, map docs/aetheria-arena-sim-rate-map.md): the game's tick has one owner, +// and content authored in seconds means the same at every tick, because a restarted timer keeps what the last step +// overshot and passive sensing decays exactly. +public sealed class SimRateClockTests +{ + [Fact] + public void OneTickOwner() + { + var clock = SimClock.ForGame(); + Assert.Equal(1f / SimClock.TickRate, SimClock.GameStep); + Assert.Equal(SimClock.GameStep, clock.Step); + Assert.Equal(SimClock.TickRate, clock.StepsPerRealSecond); + + // Ten real seconds of irregular frames pay ten seconds of steps, so the game runs at real time. + var frames = new[] { .004f, .031f, .016f, .0083f, .05f, .022f, .0125f, .027f }; + var real = 0f; + for (var i = 0; real < 10f; i++) + { + var frame = Math.Min(frames[i % frames.Length], 10f - real); + real += frame; + clock.Advance(frame, _ => { }); + } + 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. + // Automatic by default, as a gun that holds is; a semi-automatic one is the plain InstantWeaponData. + private string GunWithCooldown(float cooldown, bool auto = true, float velocity = 0f) + { + var name = $"{(auto ? "Auto" : "Gun")}@{cooldown:R}{(velocity > 0 ? $"v{velocity:R}" : "")}"; + if (_openCache.GetByName(name) != null) return name; + InstantWeaponData data = auto + ? new AutoWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) } + : new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) }; + var gun = Armed(data, 0); + gun.Velocity = Const(velocity); + gun.Energy = Const(1); // the fixture reactor pays a round a hundredth of a second apart; the rounds-per-step tests top up nothing + _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 HoldTrigger(Ship ship, EquippedItem item, float dt, float seconds, List fireTimes = null) + { + 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(); + gun.OnFire += id => + { + rounds.Add(_zone.Time); + if (fireTimes != null && _zone.TryGetShot(id, out var shot)) fireTimes.Add(shot.FireTime); + }; + ship.SetTarget(TargetRef.None); + for (var step = 0; step < (int) MathF.Round(seconds / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + gun.Activate(); + Tick(dt); + } + gun.Deactivate(); + 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(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) MathF.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(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().Single(); + var fired = 0; + for (var step = 0; step < (int) MathF.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(name) == null) + { + var beam = (ConstantWeaponData) Armed(new ConstantWeaponData(), 100000); + beam.AmmoInterval = interval; + beam.AmmoType = _openCache.RefOf(_openCache.GetByName("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().Single(); + typeof(ConstantWeapon).GetField("_ammo", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 100000); + ship.SetTarget(TargetRef.None); + weapon.Activate(); + for (var step = 0; step < (int) MathF.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 marker = new object(); + void Glow() => target.SetSignature(marker, SignatureChannel.Thermal, 5f, null); // held as bright as a thruster + Glow(); + Tick(0f); + 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) MathF.Round(40f / dt); step++) + { + Glow(); + Tick(dt); + } + Assert.InRange(Track(observer, target), documented - 1e-3f, documented + 1e-3f); + } + } + + // An idle timer banks at most one step: after ten idle seconds the first two restarts are still a cooldown apart. + [Fact] + public void AnIdleSensorBanksAtMostOneStep() + { + foreach (var dt in CadenceSteps) + { + var name = "Pinger@.5"; + if (_openCache.GetByName(name) == null) + { + var sensor = SensorBehavior(sensitivity: 2f); + sensor.PingCooldown = Const(.5f); + _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 times = new List(); + SensorOf(ship).OnPingStart += () => times.Add(_zone.Time); + for (var step = 0; step < (int) MathF.Round(10f / dt); step++) Tick(dt); + for (var step = 0; step < (int) MathF.Round(3f / dt); step++) + { + SensorOf(ship).Ping(); + Tick(dt); + } + Assert.True(times.Count >= 4); + for (var i = 1; i < times.Count; i++) + Assert.True(times[i] - times[i - 1] >= .5f - dt - 1e-4f, $"dt {dt}: pings at {times[i - 1]} and {times[i]}"); + } + } + + [Fact] + public void AnIdleCooldownBanksAtMostOneStep() + { + foreach (var dt in CadenceSteps) + { + var name = "Metronome@.5"; + if (_openCache.GetByName(name) == null) + _openCache.Upsert(Tool(name, new CooldownData { Cooldown = Const(.5f) })); + var ship = Add(float3(0, 0, 0), null, new[] { name }); + var clock = ship.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); + // Idle: the timer is updated but its chain never reaches Execute (an earlier behaviour gates it, or the item is + // offline). Zone ticks would not do: an Active tool reaches Execute every step and restarts the timer each time. + for (var step = 0; step < (int) MathF.Round(10f / dt); step++) clock.Update(dt); + var restarts = new List(); + for (var step = 0; step < (int) MathF.Round(2f / dt); step++) + { + if (clock.Execute(dt)) restarts.Add(step); + clock.Update(dt); + } + Assert.True(restarts.Count >= 3, $"dt {dt}: {restarts.Count} restarts in 2 s"); + for (var i = 1; i < restarts.Count; i++) + Assert.True((restarts[i] - restarts[i - 1]) * dt >= .5f - dt - 1e-4f, $"dt {dt}: restarts at steps {restarts[i - 1]} and {restarts[i]}"); + } + } + + // A magazine of three, cooldown .1 s and a .9 s reload is three rounds a 1.1 s cycle, at any step. + // Ruling rounds-per-step: cadence is authored in seconds at any tick rate. A gun of 0.01 s fires 100 rounds a second at 15, 30 + // and 60 Hz (several in one step), each born at its own moment inside the step, one cooldown after the one before. + [Fact] + public void AFastGunDeliversItsAuthoredRoundsAtEveryRate() + { + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + var fireTimes = new List(); + var rounds = HoldTrigger(ship, Guns(ship)[0], dt, 10f, fireTimes); + var probe = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var cap = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(probe); + Assert.True(abs(rounds.Count - 1000f) <= 1.01f, $"dt {dt}: {rounds.Count} rounds in 10 s, expected 1000 +-1; capacity {cap.Capacity} rate {cap.Rate} charge {cap.Charge} cooldown {probe.Cooldown} canfire {probe.CanFire} firing {probe.Firing}"); + Assert.Equal(rounds.Count, fireTimes.Count); + for (var i = 1; i < fireTimes.Count; i++) + Assert.True(abs(fireTimes[i] - fireTimes[i - 1] - .01f) < 2e-4f, $"dt {dt}: rounds {i - 1} and {i} born {fireTimes[i - 1]:R} and {fireTimes[i]:R}"); + } + } + + private InputCapacitor CapacitorOf(InstantWeapon gun) => (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun); + + // A press that is let go before the step runs fires one burst, whatever the cooldown, the tick and the kind of gun: the + // trigger is the weapon's state, not a pulse once per step. The press goes through the pilot's command (the player's + // action bar and the agents' Combat both do) and straight to the weapon (a turret does). + [Fact] + public void ATapFiresOneBurstAtEveryRate() + { + foreach (var auto in new[] { false, true }) + foreach (var cooldown in auto ? new[] { .01f, .025f, .1f } : new[] { .1f, .25f }) + foreach (var viaCommand in new[] { false, true }) + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown, auto) }, new[] { "Targeting", "Reactor" }); + var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var capacitor = CapacitorOf(gun); + var rounds = 0; + gun.OnFire += _ => rounds++; + ship.SetTarget(TargetRef.None); + capacitor.AddStepCharge(1000f, dt); + if (viaCommand) + { + ship.Command.Pull(gun); + ship.Command.Release(gun); + } + else + { + gun.Activate(); + gun.Deactivate(); + } + for (var step = 0; step < (int) MathF.Round(1f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + Tick(dt); + } + Assert.True(rounds == 1, $"auto {auto} cooldown {cooldown} at dt {dt}, command {viaCommand}: {rounds} rounds from one tap, expected 1"); + } + } + + // A trigger pressed once and held: an AutoWeapon delivers the authored cadence (the rounds of ten seconds are the same at + // 15, 30 and 60 Hz) and stops when the trigger is let go; a plain gun is semi-automatic and fires its one burst however + // long the key stays down. + [Fact] + public void AHeldTriggerFiresPerTheKindOfGun() + { + foreach (var auto in new[] { false, true }) + foreach (var cooldown in auto ? new[] { .01f, .1f } : new[] { .1f, .25f }) + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(cooldown, auto) }, new[] { "Targeting", "Reactor" }); + var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var capacitor = CapacitorOf(gun); + var rounds = 0; + gun.OnFire += _ => rounds++; + ship.SetTarget(TargetRef.None); + capacitor.AddStepCharge(1000f, dt); + gun.Activate(); + for (var step = 0; step < (int) MathF.Round(10f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + Tick(dt); + } + var expected = auto ? 10f / cooldown : 1f; + Assert.True(abs(rounds - expected) <= (auto ? 1.01f : 0f), $"auto {auto} cooldown {cooldown} at dt {dt}: {rounds} rounds held 10 s, expected {expected}"); + gun.Deactivate(); + var held = rounds; + for (var step = 0; step < (int) MathF.Round(1f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + Tick(dt); + } + Assert.True(rounds - held <= 1, $"auto {auto} cooldown {cooldown} at dt {dt}: {rounds - held} rounds after the trigger was let go"); + } + } + + // An agent's Combat pulls every weapon it means to fire each step. A plain gun it taps: each pull is a press, and the gun + // fires when it is ready, its authored cadence on average at any tick. An AutoWeapon it holds: the pulls land on a + // trigger already down. Both deliver the cadence; the plain one stops when the pulls stop, with no release needed. + [Fact] + public void CombatTapsAPlainGunAndHoldsAnAutoWeapon() + { + foreach (var auto in new[] { false, true }) + foreach (var dt in new[] { 1f / 60f, 1f / 30f, 1f / 15f }) + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(.1f, auto) }, new[] { "Targeting", "Reactor" }); + var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var capacitor = CapacitorOf(gun); + var rounds = 0; + gun.OnFire += _ => rounds++; + ship.SetTarget(TargetRef.None); + for (var step = 0; step < (int) MathF.Round(10f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + ship.Command.Pull(gun); + Tick(dt); + } + Assert.True(abs(rounds - 100f) <= 1.01f, $"auto {auto} at dt {dt}: {rounds} rounds pulled each step for 10 s, expected 100 +-1"); + var pulled = rounds; + for (var step = 0; step < (int) MathF.Round(1f / dt); step++) + { + capacitor.AddStepCharge(1000f, dt); + if (auto) ship.Command.Release(gun); + Tick(dt); + } + Assert.True(rounds - pulled <= 1, $"auto {auto} at dt {dt}: {rounds - pulled} rounds after the pulls stopped"); + } + } + + // Within a step, rounds resolve in the order they were fired across shooters, not one shooter's block after the other's + // and not newest first: which round breaks a shield or lands the kill follows sim time. Equal moments go by shot id. + [Fact] + public void RoundsOfOneStepResolveInFireTimeOrderAcrossShooters() + { + var dt = 1f / 30f; + var a = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + var b = Add(float3(5, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + var guns = new[] { a, b }.Select(s => (InstantWeapon) Guns(s)[0].Behaviors.Single(x => x is InstantWeapon)).ToArray(); + var fired = new Dictionary(); + foreach (var gun in guns) + gun.OnFire += id => { if (_zone.TryGetShot(id, out var shot)) fired[id] = shot.FireTime; }; + foreach (var s in new[] { a, b }) s.SetTarget(TargetRef.None); + var order = new List(); + var sources = new List(); + _zone.ShotResolved.Subscribe(outcome => { order.Add(outcome.ShotId); sources.Add(ReferenceEquals(outcome.Source, a)); }); + var checkedSteps = 0; + for (var step = 0; step < 8; step++) + { + foreach (var gun in guns) { CapacitorOf(gun).AddStepCharge(1000f, dt); gun.Activate(); } + order.Clear(); + sources.Clear(); + Tick(dt); + if (step < 3) continue; + checkedSteps++; + Assert.True(order.Count >= 4, $"step {step}: {order.Count} rounds resolved"); + for (var i = 1; i < order.Count; i++) + { + var before = (fired[order[i - 1]], order[i - 1]); + var after = (fired[order[i]], order[i]); + Assert.True(before.CompareTo(after) < 0, $"step {step}: shot {order[i - 1]} (fired {before.Item1:R}) resolved before shot {order[i]} (fired {after.Item1:R})"); + } + Assert.True(sources.Zip(sources.Skip(1), (x, y) => x != y).Count(changed => changed) > 1, $"step {step}: one shooter's rounds resolved as a block"); + } + Assert.True(checkedSteps >= 4); + } + + // Within a step, rounds apply in the order they arrive, not the order they were fired: a slow gun's earlier rounds and a + // fast gun's later ones that land in the same step apply by when each lands. Equal moments go by shot id. + [Fact] + public void RoundsOfOneStepApplyInArrivalOrderWhateverTheirFlightTimes() + { + var dt = 1f / 30f; + var slow = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f, velocity: 100f) }, new[] { "Targeting", "Reactor" }); + var fast = Add(float3(5, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + var guns = new[] { slow, fast }.Select(s => (InstantWeapon) Guns(s)[0].Behaviors.Single(x => x is InstantWeapon)).ToArray(); + var fired = new Dictionary(); + foreach (var gun in guns) + gun.OnFire += id => { if (_zone.TryGetShot(id, out var shot)) fired[id] = (shot.FireTime, shot.ArrivalTime); }; + foreach (var s in new[] { slow, fast }) s.SetTarget(TargetRef.None); + var order = new List(); + _zone.ShotResolved.Subscribe(outcome => order.Add(outcome.ShotId)); + var inverted = 0; + for (var step = 0; step < 40; step++) + { + foreach (var gun in guns) { CapacitorOf(gun).AddStepCharge(1000f, dt); gun.Activate(); } + order.Clear(); + Tick(dt); + for (var i = 1; i < order.Count; i++) + { + var before = (fired[order[i - 1]].arrival, order[i - 1]); + var after = (fired[order[i]].arrival, order[i]); + Assert.True(before.CompareTo(after) < 0, $"step {step}: shot {order[i - 1]} (arrives {before.Item1:R}) applied before shot {order[i]} (arrives {after.Item1:R})"); + if (fired[order[i - 1]].fire > fired[order[i]].fire) inverted++; + } + } + // The guns really differ: rounds were applied out of fire order, which fire-order resolution would not do. + Assert.True(inverted > 10, $"{inverted} rounds applied ahead of an earlier-fired one"); + } + + // Rounds born in one step leave from where the shooter was at their own moment: the origins of two rounds of a step are + // apart by the shooter's velocity times the time between them. + [Fact] + public void RoundsOfOneStepLeaveFromWhereTheShooterWasAtTheirMoment() + { + var dt = 1f / 15f; + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + ship.Velocity = float2(30, 0); + var gun = (InstantWeapon) Guns(ship)[0].Behaviors.Single(b => b is InstantWeapon); + var born = new List<(float step, float time, float x, float velocity)>(); + gun.OnFire += id => + { + if (_zone.TryGetShot(id, out var shot)) born.Add((_zone.Time, shot.FireTime, shot.FireOrigin.x, ship.Velocity.x)); + }; + ship.SetTarget(TargetRef.None); + for (var i = 0; i < 20; i++) + { + gun.Activate(); + Tick(dt); + } + var pairs = 0; + for (var i = 1; i < born.Count; i++) + { + if (born[i].step != born[i - 1].step) continue; + pairs++; + var expected = born[i].velocity * (born[i].time - born[i - 1].time); + Assert.True(abs(born[i].x - born[i - 1].x - expected) < 1e-2f, $"round {i}: origins {born[i - 1].x} and {born[i].x}, expected {expected} apart"); + } + Assert.True(pairs > 100, $"{pairs} pairs of rounds in one step"); + } + + // A cooldown of nothing is a gun bounded by the shortest cycle (and by what its power can pay), not a step that never ends. + [Fact] + public void AGunWithNoCooldownStillEndsItsStep() + { + var ship = Add(float3(0, 0, 0), new[] { GunWithCooldown(0f) }, new[] { "Targeting", "Reactor" }); + var rounds = HoldTrigger(ship, Guns(ship)[0], 1f / 30f, 1f); + Assert.InRange(rounds.Count, 1, 2001); + } + + // The cluster's cost: a point-defence battery of 0.005 s guns holding its trigger, every round a queued shot with its own + // roll. The step wall time and the heap bytes per round are written beside the test for the report. + [Fact, Trait("Category", "Slow")] + public void ADenseClusterStepsAtAffordableCost() + { + const int guns = 16; + var dt = SimClock.GameStep; + var ships = Enumerable.Range(0, guns).Select(i => Add(float3(i * 3, 0, 0), new[] { GunWithCooldown(.005f) }, new[] { "Targeting", "Reactor" })).ToList(); + var weapons = ships.Select(s => (InstantWeapon) Guns(s)[0].Behaviors.Single(b => b is InstantWeapon)).ToList(); + var capacitors = weapons.Select(w => (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(w)).ToList(); + var rounds = 0; + foreach (var w in weapons) w.OnFire += _ => rounds++; + foreach (var s in ships) s.SetTarget(TargetRef.None); + void Step() + { + for (var i = 0; i < guns; i++) { capacitors[i].AddStepCharge(1000f, dt); weapons[i].Activate(); } + Tick(dt); + } + for (var i = 0; i < 30; i++) Step(); // warm the JIT and the shot queue + foreach (var w in weapons) w.Deactivate(); // the triggers stay down until released + for (var i = 0; i < 60; i++) Tick(dt); // let the in-flight rounds resolve + var steps = 300; + var idleBytes = GC.GetAllocatedBytesForCurrentThread(); + var idle = System.Diagnostics.Stopwatch.StartNew(); + for (var i = 0; i < steps; i++) Tick(dt); + idle.Stop(); + idleBytes = GC.GetAllocatedBytesForCurrentThread() - idleBytes; + rounds = 0; + var bytes = GC.GetAllocatedBytesForCurrentThread(); + var watch = System.Diagnostics.Stopwatch.StartNew(); + for (var i = 0; i < steps; i++) Step(); + watch.Stop(); + bytes = GC.GetAllocatedBytesForCurrentThread() - bytes; + // Where the bytes go: a round's birth (FireControl.Fire) alone, and the queued rounds' commit and resolution alone. + var item0 = Guns(ships[0])[0]; + for (var i = 0; i < 2000; i++) FireControl.Fire(weapons[0], item0, ships[0]); // grows the shot queue once + Tick(dt); + var born = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 1000; i++) FireControl.Fire(weapons[0], item0, ships[0]); + born = GC.GetAllocatedBytesForCurrentThread() - born; + var settle = GC.GetAllocatedBytesForCurrentThread(); + Tick(dt); + settle = GC.GetAllocatedBytesForCurrentThread() - settle; + var line = $"born {born / 1000.0:F0} B per round, 1050 rounds resolved in one step {settle / 1050.0:F0} B per round; {guns} guns at .005 s, {SimClock.TickRate} Hz: {rounds / (double) steps:F1} rounds per step, {watch.Elapsed.TotalMilliseconds / steps:F3} ms per step, {(bytes - idleBytes) / (double) rounds:F0} heap bytes per round beyond the idle step (idle step {idle.Elapsed.TotalMilliseconds / steps:F3} ms, {idleBytes / (double) steps:F0} bytes)"; + System.IO.File.WriteAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "simrate-stepcost.txt"), line); + Assert.True(Math.Abs(rounds / (double) steps - guns * dt / .005) < 1.0 * guns, line); + } + + [Fact] + public void MagazineCadenceIsPerSecond() + { + foreach (var dt in CadenceSteps) + { + var name = "Magazine3"; + if (_openCache.GetByName(name) == null) + { + var gun = Armed(new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(.1f), ReloadTime = .9f }, 3); + _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 } + }); + } + var ship = Add(float3(0, 0, 0), new[] { name }, new[] { "Targeting", "Reactor" }); + var rounds = HoldTrigger(ship, Guns(ship)[0], dt, 33f); + Assert.True(abs(rounds.Count - 90f) <= 3.01f, $"dt {dt}: {rounds.Count} rounds in 33 s, expected 90 +-3"); + } + } +} + +public sealed partial class RunStartTests +{ + private static readonly string EquivalenceTable = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "simrate-equivalence.txt"); + + // The two-AI fight over 40 match seeds at the old 60 Hz and at the game's step: the same fight to within noise. The + // win rate (east's hull above west's) differs by less than a 95% two-proportion interval, and the mean survivor hull + // and the mean time to first kill each differ by less than their 95% interval. The table is written beside the test for the report. + [Fact, Trait("Category", "Slow")] + public void SimRateEquivalence() + { + const int seeds = 40; + var table = new System.Text.StringBuilder(); + (double wins, double survivor, double firstKill, double seconds, double survivorVar, double firstKillVar) Run(float dt) + { + var wins = 0; + double survivor = 0, firstKill = 0, seconds = 0; + var survivors = new List(); + var kills = new List(); + for (uint seed = 1; seed <= seeds; seed++) + { + var fight = TwoAiFight(seed, dt); + if (fight.eastHull > fight.westHull) wins++; + survivor += Math.Max(fight.eastHull, fight.westHull); + survivors.Add(Math.Max(fight.eastHull, fight.westHull)); + kills.Add(fight.firstKill); + firstKill += fight.firstKill; + seconds += fight.stepSeconds; + table.AppendLine($"dt {dt:R} seed {seed}: east {fight.eastHull:R} west {fight.westHull:R} first kill {fight.firstKill:R} s, {fight.stepSeconds:F2} s wall"); + } + double Variance(List xs) { var m = xs.Average(); return xs.Sum(x => (x - m) * (x - m)) / (xs.Count - 1); } + return (wins / (double) seeds, survivor / seeds, firstKill / seeds, seconds, Variance(survivors), Variance(kills)); + } + + var old = Run(1f / 60f); + var now = Run(SimClock.GameStep); + var se = Math.Sqrt(old.wins * (1 - old.wins) / seeds + now.wins * (1 - now.wins) / seeds); + table.AppendLine($"60 Hz: east wins {old.wins:P0}, mean survivor hull {old.survivor:F1}, mean first kill {old.firstKill:F2} s, {old.seconds:F1} s wall"); + table.AppendLine($"{SimClock.TickRate} Hz: east wins {now.wins:P0}, mean survivor hull {now.survivor:F1}, mean first kill {now.firstKill:F2} s, {now.seconds:F1} s wall"); + table.AppendLine($"win-rate difference {Math.Abs(old.wins - now.wins):F3} (95% interval +-{1.96 * se:F3}); wall-time speedup {old.seconds / now.seconds:F2}x"); + System.IO.File.WriteAllText(EquivalenceTable, table.ToString()); + + Assert.True(Math.Abs(old.wins - now.wins) <= 1.96 * se, table.ToString()); + // The survivor hull is heavy-tailed (a dead hull reads near zero, a survivor up to a few hundred), so its tolerance is the + // 95% interval of the difference of the two means, as the win rate's is, not a fixed fraction of one mean. + var survivorSe = Math.Sqrt(old.survivorVar / seeds + now.survivorVar / seeds); + var killSe = Math.Sqrt(old.firstKillVar / seeds + now.firstKillVar / seeds); + table.AppendLine($"mean survivor hull difference {Math.Abs(now.survivor - old.survivor):F2} (95% interval +-{1.96 * survivorSe:F2}); mean first kill difference {Math.Abs(now.firstKill - old.firstKill):F2} s (95% interval +-{1.96 * killSe:F2})"); + System.IO.File.WriteAllText(EquivalenceTable, table.ToString()); + Assert.True(Math.Abs(now.survivor - old.survivor) <= 1.96 * survivorSe, table.ToString()); + Assert.True(Math.Abs(now.firstKill - old.firstKill) <= 1.96 * killSe, table.ToString()); + } +} diff --git a/tools/AetherDb/AutoGunsCatalog.cs b/tools/AetherDb/AutoGunsCatalog.cs new file mode 100644 index 000000000..d62206681 --- /dev/null +++ b/tools/AetherDb/AutoGunsCatalog.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using System.Reflection; + +// Makes the named plain guns fully automatic (ruling fire-mode-is-the-design): each one's InstantWeaponData becomes an +// AutoWeaponData carrying every authored stat unchanged, so cadence and damage do not move. A gun that is already an +// AutoWeapon (or any other kind) is left alone, so a rerun writes nothing. Dry run unless apply. +public static class AutoGunsCatalog +{ + public static readonly string[] Guns = { "Slug Rifle", "Long Slug Rifle" }; + + public static int Run(CultCache cache, bool apply, TextWriter output) + { + var changed = new System.Collections.Generic.List(); + foreach (var name in Guns) + { + var gear = cache.GetAll().SingleOrDefault(g => g.Name == name); + if (gear == null) { output.WriteLine($"{name}: no such gear, skipped"); continue; } + var index = gear.Behaviors.FindIndex(b => b.GetType() == typeof(InstantWeaponData)); + if (index < 0) { output.WriteLine($"{name}: no plain gun, kept"); continue; } + var plain = (InstantWeaponData) gear.Behaviors[index]; + var auto = new AutoWeaponData(); + // AutoWeaponData adds no member of its own: every field of the plain gun, from every base, carries over. + for (var type = typeof(InstantWeaponData); type != null; type = type.BaseType) + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + field.SetValue(auto, field.GetValue(plain)); + gear.Behaviors[index] = auto; + output.WriteLine($"{name}: InstantWeaponData -> AutoWeaponData (cooldown {plain.Cooldown.Max:0.###} s)"); + changed.Add(gear); + } + + if (!apply) { output.WriteLine("\nDry run. Pass \"apply\" to author them."); return 0; } + if (changed.Count == 0) { output.WriteLine("Nothing to author."); return 0; } + foreach (var gear in changed) CultRecordRefs.Validate(gear); + cache.Commit(batch => { foreach (var gear in changed) batch.Upsert(typeof(GearData), gear, cache.RefOf(gear).Key); }); + output.WriteLine($"\nAuthored {changed.Count} guns in Aetheria.cc"); + return 0; + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 1dd65bda1..665ce766b 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -77,9 +77,10 @@ public static class Program return code; } case "economy": return EconomyCommand.Run(args.Skip(1).ToArray()); + case "auto-guns": return AutoGunsCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], loadout-commit [,,,[@]]... [--group ,...]... [--replace] [--boss-of ], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [hull \"\" ] [apply], economy report|author ... [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], loadout-commit [,,,[@]]... [--group ,...]... [--replace] [--boss-of ], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], auto-guns [apply], assembly-from-maker [apply], station-kinds [hull \"\" ] [apply], economy report|author ... [apply]"); return 1; } }