Merge origin/master into economy authoring command
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dd7f40b559
33 changed files with 1329 additions and 129 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
Binary file not shown.
200
docs/aetheria-arena-sim-rate-map.md
Normal file
200
docs/aetheria-arena-sim-rate-map.md
Normal 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.
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
86
tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs
Normal file
86
tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs
Normal 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 } } }));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
654
tests/Aetheria.Shared.Tests/SimRateTests.cs
Normal file
654
tests/Aetheria.Shared.Tests/SimRateTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
41
tools/AetherDb/AutoGunsCatalog.cs
Normal file
41
tools/AetherDb/AutoGunsCatalog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -77,9 +77,10 @@ public static class Program
|
|||
return code;
|
||||
}
|
||||
case "economy": return EconomyCommand.Run(args.Skip(1).ToArray());
|
||||
case "auto-guns": return AutoGunsCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out);
|
||||
case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out);
|
||||
default:
|
||||
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], loadout-commit <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], economy report|author <batch>... [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], economy report|author <batch>... [apply]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue