Merge eureka/aetheria-arena-sim-rate: the game ticks at 30 Hz; one owner of the rate; timers carry overshoot; guns fire every round their cooldown allows; plain guns are semi-auto

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-23 06:31:10 +02:00
commit ade0b0fbf3
33 changed files with 1329 additions and 129 deletions

View file

@ -159,7 +159,7 @@ namespace Aetheria.EditorTools
MaxRange = 1e6f, FireRange = 1e6f, CommitTime = 1e5f, ArrivalTime = 2e5f
};
var projectile = new GameObject("Wiring Round").AddComponent<Projectile>();
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);

View file

@ -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 = {

View file

@ -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()

View file

@ -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<int, Projectile> _live = new Dictionary<int, Projectile>();
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<Projectile>();
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();
}

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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
{

View file

@ -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);
}
}

View file

@ -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

View file

@ -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<string> SemiAutoFaults(IEnumerable<ItemData> 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<int> 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;
}

View file

@ -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;
}

View file

@ -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<T>() 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;
}

View file

@ -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);
}
}

View file

@ -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));

BIN
GameData/Aetheria.cc (Stored with Git LFS)

Binary file not shown.

View file

@ -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.

View file

@ -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`.

View file

@ -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;

View file

@ -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<ChunkId>();
player.VisibleChunksInReach(inReach);
Assert.NotEmpty(inReach);

View file

@ -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");

View file

@ -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));
}
}

View file

@ -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]

View file

@ -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<float>();
var ys = new List<float>();
var leads = new List<float>();

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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<float>();
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);

View file

@ -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`.

View file

@ -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<GearData>().ToArray();
Assert.Empty(InstantWeaponData.SemiAutoFaults(cache.GetAll<ItemData>()));
// 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<AutoWeaponData>(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<object[]> Kinds() => new[]
{
new object[] { "plain", (Func<InstantWeaponData>) (() => new InstantWeaponData()) },
new object[] { "guided", (Func<InstantWeaponData>) (() => new GuidedWeaponData()) },
new object[] { "mine", (Func<InstantWeaponData>) (() => new MineLayerData()) },
new object[] { "launcher", (Func<InstantWeaponData>) (() => new LauncherData()) },
new object[] { "charged", (Func<InstantWeaponData>) (() => new ChargedWeaponData { CanFireEarly = true }) },
};
[Theory, MemberData(nameof(Kinds))]
public void TheFloorRefusesEverySemiAutoKindBelowItAsGearOrConsumable(string kind, Func<InstantWeaponData> 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 } } }));
}
}

View file

@ -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);
}

View file

@ -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<InvalidOperationException>(() => 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<ArgumentOutOfRangeException>(() => new SimClock(0f)).ParamName);
Assert.Equal("step", Assert.Throws<ArgumentOutOfRangeException>(() => new SimClock(0f, 60)).ParamName);
Assert.Equal("stepsPerRealSecond", Assert.Throws<ArgumentOutOfRangeException>(() => 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<string> History(Zone zone, float[] frames)
{
var clock = new SimClock(1f / 60f);
var clock = new SimClock(1f / 60f, 60);
var shots = new List<string>();
using var _ = zone.ShotResolved.Subscribe(outcome => shots.Add($"{outcome.ShotId}:{outcome.Result}"));
var history = new List<string>();

View file

@ -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<ItemData>(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<float> HoldTrigger(Ship ship, EquippedItem item, float dt, float seconds, List<float> 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<float>();
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<ItemData>(name) == null)
{
var sensor = SensorBehavior(sensitivity: 2f);
sensor.PingCooldown = Const(pingCooldown);
_openCache.Upsert(new GearData
{
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { sensor }
});
}
var ship = Add(float3(0, 0, 0), new[] { name });
var pings = 0;
SensorOf(ship).OnPingStart += () => pings++;
for (var step = 0; step < (int) 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<ItemData>(name) == null)
_openCache.Upsert(Tool(name, new CooldownData { Cooldown = Const(cooldown) }));
var ship = Add(float3(0, 0, 0), null, new[] { name });
var clock = ship.Equipment.SelectMany(i => i.Behaviors).OfType<Cooldown>().Single();
var fired = 0;
for (var step = 0; step < (int) 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<ItemData>(name) == null)
{
var beam = (ConstantWeaponData) Armed(new ConstantWeaponData(), 100000);
beam.AmmoInterval = interval;
beam.AmmoType = _openCache.RefOf(_openCache.GetByName<SimpleCommodityData>("Slug"));
_openCache.Upsert(new WeaponItemData
{
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
Behaviors = { beam }
});
}
var ship = Add(float3(0, 0, 0), new[] { name }, new[] { "Targeting", "Reactor" });
var weapon = ship.Equipment.SelectMany(i => i.Behaviors).OfType<ConstantWeapon>().Single();
typeof(ConstantWeapon).GetField("_ammo", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 100000);
ship.SetTarget(TargetRef.None);
weapon.Activate();
for (var step = 0; step < (int) 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<ItemData>(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<float>();
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<ItemData>(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<Cooldown>().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<int>();
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<float>();
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<int, float>();
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<int>();
var sources = new List<bool>();
_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<int, (float fire, float arrival)>();
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<int>();
_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<ItemData>(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<double>();
var kills = new List<double>();
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<double> 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());
}
}

View file

@ -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<GearData>();
foreach (var name in Guns)
{
var gear = cache.GetAll<GearData>().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;
}
}

View file

@ -76,9 +76,10 @@ public static class Program
if (apply) db.Cache.FlushAsync().GetAwaiter().GetResult();
return code;
}
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 <name> <hull product> [<x>,<y>,<rotation>,<design>[@<product>]]... [--group <i>,<j>...]... [--replace] [--boss-of <faction>], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], 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 \"<name>\" <kind>] [apply]");
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], loadout-commit <name> <hull product> [<x>,<y>,<rotation>,<design>[@<product>]]... [--group <i>,<j>...]... [--replace] [--boss-of <faction>], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], 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 \"<name>\" <kind>] [apply]");
return 1;
}
}