diff --git a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs index 398a1bbe1..1de3acbdb 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AutoWeapon.cs @@ -9,10 +9,8 @@ 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) diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index c58783097..da23bc107 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -21,14 +21,19 @@ 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 @@ -36,14 +41,19 @@ public class InstantWeaponData : WeaponData // fire-mode-is-the-design); a gun that cycles faster is an AutoWeapon. public const float ShortestSemiAutoCooldown = .1f; - // The plain guns of a catalog that cycle faster than a finger should have to: one fault line per gun. - public static IEnumerable SemiAutoFaults(IEnumerable catalog) + // The semi-automatic weapons of a catalog (gear and consumables, every kind that is not Automatic: plain, guided, mine, + // launcher, charged) that cycle faster than a finger should have to: one fault line per weapon. + public static IEnumerable SemiAutoFaults(IEnumerable catalog) { - foreach (var gear in catalog) - foreach (var behavior in gear.Behaviors) - if (behavior.GetType() == typeof(InstantWeaponData) && behavior is InstantWeaponData plain && - Math.Min(plain.Cooldown.Min, plain.Cooldown.Max) < ShortestSemiAutoCooldown) - yield return $"{gear.Name}: a plain gun with a {Math.Min(plain.Cooldown.Min, plain.Cooldown.Max):0.###} s cooldown, below {ShortestSemiAutoCooldown} s; make it an AutoWeapon or slow it"; + 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 diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 3b3b78cf3..1581e3457 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -854,9 +854,9 @@ public static class FireControl if (shots.Count == 0) return; var now = zone.Time; - // In fire-time order, oldest first (the queue is kept in it by Fire), so which of a step's rounds breaks a shield or - // lands a kill follows sim time, not the order the shooters were stepped in. Shots queued while this walk runs are + // 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]; @@ -892,20 +892,42 @@ 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); - end--; - Apply(zone, shot); - zone.ShotResolved.OnNext(shot.Outcome); - continue; + (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); + } } // R3/R4: the one roll. p_base was frozen at fire, PFire carrying the mount factor; the only thing measured live is how far the target has diff --git a/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs index cca1d8c22..8bedd9bd0 100644 --- a/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/SemiAutoCatalogTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; @@ -23,7 +25,7 @@ public sealed class SemiAutoCatalogTests using var cache = new CultCache(TestCatalog.Registry()); cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalog, true), AetheriaStores.CatalogTypes); var gear = cache.GetAll().ToArray(); - Assert.Empty(InstantWeaponData.SemiAutoFaults(gear)); + Assert.Empty(InstantWeaponData.SemiAutoFaults(cache.GetAll())); // The check reads a catalog that holds guns, and the two automatic ones are AutoWeapons. foreach (var name in new[] { "Slug Rifle", "Long Slug Rifle" }) Assert.IsType(gear.Single(g => g.Name == name).Behaviors.Single(b => b is InstantWeaponData)); @@ -47,4 +49,38 @@ public sealed class SemiAutoCatalogTests Assert.Contains(faults, f => f.StartsWith("Fast:")); Assert.Contains(faults, f => f.StartsWith("Wide:")); } + + // Fire mode is one property of the weapon's data: every kind but AutoWeaponData is semi-automatic, and the floor holds each of + // them, as gear or as a consumable. An automatic gun is exempt wherever it is carried. + public static IEnumerable Kinds() => new[] + { + new object[] { "plain", (Func) (() => new InstantWeaponData()) }, + new object[] { "guided", (Func) (() => new GuidedWeaponData()) }, + new object[] { "mine", (Func) (() => new MineLayerData()) }, + new object[] { "launcher", (Func) (() => new LauncherData()) }, + new object[] { "charged", (Func) (() => new ChargedWeaponData { CanFireEarly = true }) }, + }; + + [Theory, MemberData(nameof(Kinds))] + public void TheFloorRefusesEverySemiAutoKindBelowItAsGearOrConsumable(string kind, Func make) + { + InstantWeaponData At(float cooldown) { var data = make(); data.Cooldown = Const(cooldown); return data; } + Assert.False(make().Automatic); + var floor = InstantWeaponData.ShortestSemiAutoCooldown; + var slow = At(floor); + var fast = At(floor / 2); + Assert.Empty(InstantWeaponData.SemiAutoFaults(new ItemData[] { Gun(kind, slow), new ConsumableItemData { Name = kind + " dose", Behaviors = { slow } } })); + var faults = InstantWeaponData.SemiAutoFaults(new ItemData[] { Gun(kind, fast), new ConsumableItemData { Name = kind + " dose", Behaviors = { fast } } }).ToArray(); + Assert.Equal(2, faults.Length); + Assert.Contains(faults, f => f.StartsWith(kind + ":")); + Assert.Contains(faults, f => f.StartsWith(kind + " dose:")); + } + + [Fact] + public void AnAutomaticGunIsExemptAsGearOrConsumable() + { + var auto = new AutoWeaponData { Cooldown = Const(.01f) }; + Assert.True(auto.Automatic); + Assert.Empty(InstantWeaponData.SemiAutoFaults(new ItemData[] { Gun("Auto", auto), new ConsumableItemData { Name = "Auto dose", Behaviors = { auto } } })); + } } diff --git a/tests/Aetheria.Shared.Tests/SimRateTests.cs b/tests/Aetheria.Shared.Tests/SimRateTests.cs index e89f14c0d..dc005e4c0 100644 --- a/tests/Aetheria.Shared.Tests/SimRateTests.cs +++ b/tests/Aetheria.Shared.Tests/SimRateTests.cs @@ -42,14 +42,15 @@ public sealed partial class ContactRevealTests // 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) + private string GunWithCooldown(float cooldown, bool auto = true, float velocity = 0f) { - var name = $"{(auto ? "Auto" : "Gun")}@{cooldown:R}"; + var name = $"{(auto ? "Auto" : "Gun")}@{cooldown:R}{(velocity > 0 ? $"v{velocity:R}" : "")}"; if (_openCache.GetByName(name) != null) return name; InstantWeaponData data = auto ? new AutoWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) } : new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(cooldown) }; var gun = Armed(data, 0); + gun.Velocity = Const(velocity); gun.Energy = Const(1); // the fixture reactor pays a round a hundredth of a second apart; the rounds-per-step tests top up nothing _openCache.Upsert(new WeaponItemData { @@ -454,6 +455,39 @@ public sealed partial class ContactRevealTests Assert.True(checkedSteps >= 4); } + // Within a step, rounds apply in the order they arrive, not the order they were fired: a slow gun's earlier rounds and a + // fast gun's later ones that land in the same step apply by when each lands. Equal moments go by shot id. + [Fact] + public void RoundsOfOneStepApplyInArrivalOrderWhateverTheirFlightTimes() + { + var dt = 1f / 30f; + var slow = Add(float3(0, 0, 0), new[] { GunWithCooldown(.01f, velocity: 100f) }, new[] { "Targeting", "Reactor" }); + var fast = Add(float3(5, 0, 0), new[] { GunWithCooldown(.01f) }, new[] { "Targeting", "Reactor" }); + var guns = new[] { slow, fast }.Select(s => (InstantWeapon) Guns(s)[0].Behaviors.Single(x => x is InstantWeapon)).ToArray(); + var fired = new Dictionary(); + foreach (var gun in guns) + gun.OnFire += id => { if (_zone.TryGetShot(id, out var shot)) fired[id] = (shot.FireTime, shot.ArrivalTime); }; + foreach (var s in new[] { slow, fast }) s.SetTarget(TargetRef.None); + var order = new List(); + _zone.ShotResolved.Subscribe(outcome => order.Add(outcome.ShotId)); + var inverted = 0; + for (var step = 0; step < 40; step++) + { + foreach (var gun in guns) { CapacitorOf(gun).AddStepCharge(1000f, dt); gun.Activate(); } + order.Clear(); + Tick(dt); + for (var i = 1; i < order.Count; i++) + { + var before = (fired[order[i - 1]].arrival, order[i - 1]); + var after = (fired[order[i]].arrival, order[i]); + Assert.True(before.CompareTo(after) < 0, $"step {step}: shot {order[i - 1]} (arrives {before.Item1:R}) applied before shot {order[i]} (arrives {after.Item1:R})"); + if (fired[order[i - 1]].fire > fired[order[i]].fire) inverted++; + } + } + // The guns really differ: rounds were applied out of fire order, which fire-order resolution would not do. + Assert.True(inverted > 10, $"{inverted} rounds applied ahead of an earlier-fired one"); + } + // Rounds born in one step leave from where the shooter was at their own moment: the origins of two rounds of a step are // apart by the shooter's velocity times the time between them. [Fact]