Merge origin/master into economy authoring command

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-23 07:16:44 +02:00
commit dd7f40b559
33 changed files with 1329 additions and 129 deletions

View file

@ -9,23 +9,17 @@ using static CultMath.math;
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class AutoWeaponData : InstantWeaponData
{
public override Behavior CreateInstance(EquippedItem item)
{
return new AutoWeapon(this, item);
}
[IgnoreMember]
public override bool Automatic => true;
}
// A fully automatic gun: it fires every round its cooldown allows while its trigger is held, from the press (Activate)
// to the let-go (Deactivate). A plain InstantWeapon is semi-automatic.
public class AutoWeapon : InstantWeapon
{
protected override bool Repeats => _firing;
public AutoWeapon(InstantWeaponData data, EquippedItem item) : base(data, item) { }
public AutoWeapon(InstantWeaponData data, ConsumableItemEffect item) : base(data, item) { }
public override bool Execute(float dt)
{
if(_firing && _burstRemaining == 0 && _cooldown < 0)
Trigger();
return base.Execute(dt);
}
}

View file

@ -157,8 +157,7 @@ public class ChargedWeapon : InstantWeapon
if (_data.FailureCharge > 1 && _charge > _data.FailureCharge)
{
_charging = false;
_cooldown = 1;
_coolingDown = true;
StartCountdown();
_charge = 0;
OnFailed?.Invoke();
CauseDamage(_data.FailureDamage);

View file

@ -146,9 +146,10 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
}
_ammoInterval -= dt / _data.AmmoInterval;
if (_ammoInterval < 0)
// An interval shorter than the step spends every round it spans.
while (_ammoInterval < 0)
{
_ammoInterval = 1;
_ammoInterval += 1;
if (_data.MagazineSize > 1 && _ammo > 0) _ammo--;
else
{

View file

@ -48,7 +48,7 @@ public class Cooldown : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior
{
if (_cooldown < 0)
{
_cooldown = 1;
_cooldown += 1;
return true;
}
@ -57,6 +57,8 @@ public class Cooldown : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior
public void Update(float delta)
{
_cooldown -= delta / Evaluate(_data.Cooldown);
// A finished timer keeps what the last step overshot, at most one step, so the restart in Execute is exact.
var step = delta / Evaluate(_data.Cooldown);
_cooldown = max(_cooldown - step, -step);
}
}

View file

@ -42,6 +42,18 @@ public class InputCapacitor
public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity);
// A weapon held through a step may fire several rounds in it (ruling rounds-per-step): the charge it holds and what flows in
// during the step pay for them, so for that step its buffer is a full activation plus the step's inflow; Settle trims what
// the step did not spend back to Capacity. The fill rate is unchanged, so the energy a gun draws is still exactly its
// rounds' cost. A sensor or a shield, and a weapon not held, keep the one-activation buffer above.
private float StepReach(float dt) => Capacity + Rate * dt;
public float RequestedStepFill(float dt) => min(Rate * dt, max(0f, StepReach(dt) - Charge));
public void AddStepCharge(float amount, float dt) => Charge = clamp(Charge + amount, 0f, StepReach(dt));
public void Settle() => Charge = min(Charge, Capacity);
public bool CanSpend(float cost) => Charge >= cost;
// Atomic and whole-or-nothing, mirroring Entity.TrySpendCapacitorCharge's old contract exactly (down to the

View file

@ -21,14 +21,39 @@ public class InstantWeaponData : WeaponData
[Inspectable, JsonProperty("ammoInterval"), Key(20)]
public bool SingleAmmoBurst;
// The fire mode, the one fact creation and the semi-auto catalog check both read: an automatic gun fires every round its
// cooldown allows while its trigger is held, every other kind is semi-automatic (a press fires one burst).
[IgnoreMember]
public virtual bool Automatic => false;
public override Behavior CreateInstance(EquippedItem item)
{
return new InstantWeapon(this, item);
return Automatic ? new AutoWeapon(this, item) : new InstantWeapon(this, item);
}
public override Behavior CreateInstance(ConsumableItemEffect item)
{
return new InstantWeapon(this, item);
return Automatic ? new AutoWeapon(this, item) : new InstantWeapon(this, item);
}
// The shortest cooldown a plain (semi-automatic) gun may be authored with, 0.1 s: about ten presses a second. A faster one
// is a fire-as-fast-as-you-can-press mechanic, unfriendly to accessibility and a special case for the AI (ruling
// fire-mode-is-the-design); a gun that cycles faster is an AutoWeapon.
public const float ShortestSemiAutoCooldown = .1f;
// The semi-automatic weapons of a catalog (gear and consumables, every kind that is not Automatic: plain, guided, mine,
// launcher, charged) that cycle faster than a finger should have to: one fault line per weapon.
public static IEnumerable<string> SemiAutoFaults(IEnumerable<ItemData> catalog)
{
foreach (var item in catalog)
{
var behaviors = (item as GearData)?.Behaviors ?? (item as ConsumableItemData)?.Behaviors;
if (behaviors == null) continue;
foreach (var behavior in behaviors)
if (behavior is InstantWeaponData weapon && !weapon.Automatic &&
Math.Min(weapon.Cooldown.Min, weapon.Cooldown.Max) < ShortestSemiAutoCooldown)
yield return $"{item.Name}: a {weapon.GetType().Name} with a {Math.Min(weapon.Cooldown.Min, weapon.Cooldown.Max):0.###} s cooldown, below {ShortestSemiAutoCooldown} s; make it an AutoWeapon or slow it";
}
}
// The one formula of an instant weapon's damage per second at a range; the live weapon calls it with its evaluated
@ -58,6 +83,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
protected float _cooldown; // Normalized
private int _ammo = 0;
protected bool _coolingDown;
// Seconds the last countdown ran past zero (less than one step). The next countdown starts that far along, so a held
// trigger fires Cooldown apart on average at any step, never floor(Cooldown/dt)+1 steps apart.
private float _overshoot;
// The shortest cycle a countdown divides by: a cooldown of zero would otherwise fire without end in one step.
private const float ShortestCycle = .0005f;
public float BurstCount { get; protected set; }
public float BurstTime { get; protected set; }
@ -87,6 +117,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
// manager can bind its presentation to the one shot it belongs to instead of applying damage itself.
public event Action<int> OnFire;
// Whether a down trigger keeps firing: a plain gun is semi-automatic, one burst per press (a held key does not repeat, and
// an agent presses it again each time it is ready); an AutoWeapon repeats while its trigger is held, every round its
// cooldown allows at any step length. Fire mode is authored by the kind of the weapon (ruling fire-mode-is-the-design).
protected virtual bool Repeats => false;
public virtual void ResetEvents()
{
OnReloadBegin = null;
@ -165,11 +200,15 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
_capacitor.UpdateStats(perShotEnergy, cooldown, rateOverride: RateOverride);
}
// What the capacitor asks the bus for this step: a weapon held through it may fire several rounds in it, so it asks for
// the step's whole inflow; any other tops up to one activation.
private float Fill(float dt) => Repeats ? _capacitor.RequestedStepFill(dt) : _capacitor.RequestedFill(dt);
// Cut 4: the request PowerBus needs before Execute runs, mirroring EnergyDraw's IPowerConsumer pattern.
public float PowerRequest(float dt)
{
RefreshInputCapacitor();
return _capacitor.RequestedFill(dt);
return Fill(dt);
}
// Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense. The ruling's own example of what
@ -183,7 +222,40 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
// would ever fill this capacitor -- bypass it entirely rather than starving such an instance forever.
protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity);
private bool UseAmmo()
// Ends the countdown once it is below zero, keeping what it ran past zero (at most one step) for the next one.
// True when it ended the countdown now.
private bool FinishCountdown(float dt)
{
if (!_coolingDown || !(_cooldown < 0)) return false;
_overshoot = min(-_cooldown * CountdownSpan, dt);
_coolingDown = false;
if (_data.MagazineSize > 0 && _ammo == 0)
{
_ammo = _data.MagazineSize;
OnReloadComplete?.Invoke();
}
else
OnCooldownComplete?.Invoke();
return true;
}
private float CountdownSpan => max(ShortestCycle, _data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown);
// A countdown that starts outside a round (a charge failing) carries the last one's overshoot like every restart.
protected void StartCountdown()
{
_cooldown = 1 - TakeOvershoot() / CountdownSpan;
_coolingDown = true;
}
private float TakeOvershoot()
{
var overshoot = _overshoot;
_overshoot = 0;
return overshoot;
}
private bool UseAmmo(float dt)
{
if (_data.MagazineSize <= 1) return true;
@ -208,7 +280,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
if(hasAmmo)
{
OnReloadBegin?.Invoke();
_cooldown = 1;
_cooldown = 1 - (_burstTimer + TakeOvershoot()) / _data.ReloadTime;
_coolingDown = true;
_firing = false;
}
@ -218,29 +290,32 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
}
public override bool Execute(float dt)
{
try { return Step(dt); }
finally { _capacitor.Settle(); }
}
// One step of rounds. A round is due at a moment inside the step (_burstTimer is how long ago, at the step's end), and a
// cooldown that ends inside the step while the trigger is held starts the next burst at that moment, so a gun fires
// every round its cooldown allows in a step, each with its own roll, however short the cooldown or long the step.
private bool Step(float dt)
{
base.Execute(dt);
// Cut 4: this tick's grant, read back via Item.PowerSupply -- Item is non-null here because a null-Item
// instance never reaches PowerBus.Step (see TrySpendActivationEnergy) and so never accrues charge.
if (Item != null)
_capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply);
_capacitor.AddStepCharge(Fill(dt) * Item.PowerSupply, dt);
var ended = false;
var retriggered = false;
if (_coolingDown)
{
_cooldown -= dt / (_data.MagazineSize > 0 && _ammo == 0 ? _data.ReloadTime : Cooldown);
if (_cooldown < 0)
{
_coolingDown = false;
if (_data.MagazineSize > 0 && _ammo == 0)
{
_ammo = _data.MagazineSize;
OnReloadComplete?.Invoke();
}
else
OnCooldownComplete?.Invoke();
}
_cooldown -= dt / CountdownSpan;
ended = FinishCountdown(dt);
}
_burstTimer += dt;
while (true)
{
while (_burstRemaining > 0 && _burstTimer > 0)
{
// A fused round whose arming distance Range cannot reach is refused, per round, before anything is
@ -249,7 +324,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
var refused = FireControl.Refuses(this, Entity);
// Ammo and energy are consumed here, per round, or once per burst at its first unrefused round
// (SingleAmmoBurst). UseAmmo returns false when triggering reload; cancel firing if that is the case
if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo()))
if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo(dt)))
{
_burstRemaining = 0;
return false;
@ -260,8 +335,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
if (!_burstStarted)
{
_burstStarted = true;
_cooldown = 1 - dt / Cooldown;
// The round starts the cooldown after this tick's decrement above: charge the tick to it, so the cadence stays Cooldown
_cooldown = 1 - (_burstTimer + TakeOvershoot()) / CountdownSpan;
// The round starts the cooldown after this tick's decrement above: charge the tick and the last
// countdown's overshoot to it, so the cadence stays Cooldown
_coolingDown = true;
}
}
@ -277,12 +353,27 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo
// whole expression, argument included, whenever OnFire has no subscriber (a headless run with no
// Unity EntityInstance wiring, docs/headless-playground-cut.md), so a shot silently never fired.
// Evaluate it into a local first.
var shotId = FireControl.Fire(this, Item, Entity);
var shotId = FireControl.Fire(this, Item, Entity, age: _burstTimer + _burstInterval);
OnFire?.Invoke(shotId);
CauseWearDamage(1);
AddHeat(Heat);
Entity.SetSignature(this, SignatureChannel.LaunchFlash, Visibility, Item);
}
// A cooldown shorter than the step it started in is over in that step.
ended |= FinishCountdown(dt);
// The trigger is held and the weapon is ready: the next burst starts now, at the moment the cooldown ended (what it
// ran past zero is how long ago, at the step's end), or at the step's start when the weapon was already ready.
if (!Repeats || _burstRemaining > 0 || !CanFire || (retriggered && !_burstStarted)) break;
retriggered = true;
Trigger();
if (_burstRemaining == 0) break;
if (ended)
{
_burstTimer = TakeOvershoot();
ended = false;
}
else _burstTimer = dt;
}
return true;
}

View file

@ -145,7 +145,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
{
_pinging = true;
_pingZone = Entity.Zone;
_pingCooldown = 1;
_pingCooldown += 1;
_pingLerp = 0;
_pingRadius = 0;
_reached.Clear();
@ -180,8 +180,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
if (_pingLerp > 1) EndPing();
}
_pingCooldown -= dt / Evaluate(_data.PingCooldown);
// A finished timer keeps what the last step overshot, at most one step, so the restart in Ping is exact.
var pingStep = dt / Evaluate(_data.PingCooldown);
_pingCooldown = max(_pingCooldown - pingStep, -pingStep);
var infoDecay = ItemManager.GameplaySettings.TargetInfoDecay;
var decay = exp(-infoDecay * dt);
var settle = infoDecay > 0 ? (1 - decay) / infoDecay : dt;
var forward = Direction.xz;
foreach (var entity in Entity.Zone.Entities)
{
@ -221,14 +227,16 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
credit += Gain(entity.Visibility, sensitivity, response, sqrt(r.IlluminatedRange * dist), dt, true, sqrt(r.IlluminatedObscuration * obscuration));
}
// The exact decay at this step: x <- x e^(-k dt) + (G / k)(1 - e^(-k dt)) for the passive gain G per second,
// which settles at G / k (Entity.PassiveInfo) at every dt. A ping's credit is an impulse added first.
if (heard)
next = saturate(previous + credit);
next = saturate(previous + credit) * decay;
else
{
var angle = acos(dot(forward, normalize(diff)));
next = saturate(previous + Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration));
var passive = Gain(entity.Visibility, sensitivity, _data.SensitivityCurve.Evaluate(angle / PI), dist, 1f, false, obscuration);
next = saturate(previous * decay + passive * settle);
}
next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt;
//Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%");
Entity.EntityInfoGathered[entity] = next;
}

View file

@ -1782,13 +1782,18 @@ public abstract class Entity
Parent = null;
}
// Indexed, not foreach: Equipment is a ReactiveCollection whose enumerator is heap-allocated, and every round a gun fires
// asks for the targeting system several times (rounds per step: hundreds of rounds a step must not allocate each).
public T GetBehavior<T>() where T : Behavior
{
foreach (var equippedItem in Equipment)
if(equippedItem.Behaviors != null)
foreach (var behavior in equippedItem.Behaviors)
if (behavior is T b)
return b;
for (var i = 0; i < Equipment.Count; i++)
{
var behaviors = Equipment[i].Behaviors;
if (behaviors == null) continue;
foreach (var behavior in behaviors)
if (behavior is T b)
return b;
}
return null;
}

View file

@ -698,7 +698,11 @@ public static class FireControl
// Damage * interval, through this exact same freeze-and-queue path (a flight time of zero, since a beam's
// authored Velocity is 0, commits and resolves in the tick it fires -- R4's short-flight degradation,
// unchanged). Every other caller keeps reading the frozen weapon.Damage it always did.
public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null)
// Rounds per step (ruling rounds-per-step): a gun fires every round its cooldown allows in a step, so a round is born
// `age` seconds before the step's end, where the shooter and its target were then (carried back along their velocities).
// FireTime, the origin, the target's frozen pose and the arrival and commit times all follow, so a presenter draws the round
// from where it left and the commit measures the target's stray from the line it was fired along.
public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null, float age = 0f)
{
var zone = source.Zone;
if (IsMineLayer(weapon))
@ -708,7 +712,7 @@ public static class FireControl
}
// Mining Cut 3: a chunk target fires as an untargeted round until Cut 4 routes shots at chunks.
var target = source.Target.Value.Entity;
var now = zone.Time;
var now = zone.Time - age;
// R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time
// re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share --
@ -724,14 +728,14 @@ public static class FireControl
var burstPosition = solution.BurstPosition;
var fuse = solution.Fuse;
var blastRadius = solution.BlastRadius;
var origin = source.Position;
var origin = source.Position - float3(source.Velocity.x, 0, source.Velocity.y) * age;
var targetVelocity = float3.zero;
var targetPosition = origin;
if (engaged != null)
{
targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y);
targetPosition = engaged.Position;
targetPosition = engaged.Position - targetVelocity * age;
}
var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f;
@ -779,7 +783,12 @@ public static class FireControl
// point is decided at its commit (CommitBurst), which reads it there. Each shot reads it once.
if (!shot.DeliveredAtCommit) Deliver(ref shot, solution.BurstReach);
zone.PendingShots.Add(shot);
// The queue is in fire-time order, ties in shot-id order (ids rise as shots are made): Step walks it oldest first. Rounds
// of one step are born shooter by shooter, so a later shooter's earlier round goes in before an earlier shooter's later one.
var queue = zone.PendingShots;
var place = queue.Count;
while (place > 0 && queue[place - 1].FireTime > shot.FireTime) place--;
queue.Insert(place, shot);
return shot.ShotId;
}
@ -845,7 +854,10 @@ public static class FireControl
if (shots.Count == 0) return;
var now = zone.Time;
for (var i = shots.Count - 1; i >= 0; i--)
// Commits walk the queue in fire-time order, oldest first (Fire keeps it in it). Shots queued while the walk runs are
// left to the next step.
List<(float Arrival, int Id)> arrived = null;
for (int i = 0, end = shots.Count; i < end;)
{
var shot = shots[i];
@ -859,6 +871,7 @@ public static class FireControl
// target is gone. Republishing the committed outcome here used to put a hit marker on a corpse.
var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now);
shots.RemoveAt(i);
end--;
if (!shot.Committed) zone.ShotCommitted.OnNext(miss);
zone.ShotResolved.OnNext(miss);
continue;
@ -879,16 +892,41 @@ public static class FireControl
if (shot.Committed && now >= shot.ArrivalTime)
{
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of
// which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage
// branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here
// the same way; only Apply decides what that means.
// Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one
// that throws would resolve, and hurt, again on every later tick.
shots.RemoveAt(i);
Apply(zone, shot);
zone.ShotResolved.OnNext(shot.Outcome);
(arrived ??= new List<(float, int)>()).Add((shot.ArrivalTime, shot.ShotId));
}
i++;
}
if (arrived == null) return;
// The rounds that arrive in this step apply in arrival order, ties in shot-id order (ids rise as shots are made), so
// which of them breaks a shield or lands a kill follows when each hit, not the order the shooters were stepped in or the
// order their rounds left.
arrived.Sort((a, b) => a.Arrival != b.Arrival ? a.Arrival.CompareTo(b.Arrival) : a.Id.CompareTo(b.Id));
foreach (var (_, id) in arrived)
{
var i = shots.FindIndex(q => q.ShotId == id);
if (i < 0) continue;
var shot = shots[i];
var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);
if (targetGone)
{
// As in the commit walk: a shot whose target left the zone in this step resolves as a miss.
var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now);
shots.RemoveAt(i);
zone.ShotResolved.OnNext(miss);
continue;
}
// Cut 12.4(b) (docs/fire-control-cut.md, "Which model a shot uses"): Apply is the one owner of
// which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage
// branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here
// the same way; only Apply decides what that means.
// Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one
// that throws would resolve, and hurt, again on every later tick.
shots.RemoveAt(i);
Apply(zone, shot);
zone.ShotResolved.OnNext(shot.Outcome);
}
}

View file

@ -14,11 +14,19 @@ public sealed class SimClock
// catch-up steps.
public const float MaxFrameSeconds = .1f;
// Game steps per real second at design speed 1, and the sim seconds one step carries. The one owner of the game's
// tick: every driver of a game history steps Zone.Update by GameStep. Design speed (ruling sim-speed-surface) will
// set Step = DesignSpeed / TickRate in ForGame and nowhere else.
public const int TickRate = 30;
public const float GameStep = 1f / TickRate;
public static SimClock ForGame() => new SimClock(GameStep, TickRate);
private readonly double _period;
private double _accumulator;
private bool _advancing;
public SimClock(float step, float stepsPerRealSecond = 60)
public SimClock(float step, float stepsPerRealSecond)
{
if (!(step > 0)) throw new ArgumentOutOfRangeException(nameof(step));
if (!(stepsPerRealSecond > 0)) throw new ArgumentOutOfRangeException(nameof(stepsPerRealSecond));