diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index 965aa0cd9..ab3550b2b 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -156,9 +156,13 @@ public class Flight var held = picture.Where(e => _tracks[e].Phase != Phase.None).ToList(); if (held.Count > 0) { + // An engaged track outranks a hailed one; the held anchor is kept only while nothing outranks it. + var anyEngaged = held.Any(e => _tracks[e].Phase == Phase.Engaged); var anchor = member.Agent.Task is FollowTask follow && held.Contains(follow.Anchor) + && (!anyEngaged || _tracks[follow.Anchor].Phase == Phase.Engaged) ? follow.Anchor - : held.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); + : held.OrderBy(e => _tracks[e].Phase == Phase.Engaged ? 0 : 1) + .ThenBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); if (!(member.Agent.Task is FollowTask current) || current.Anchor != anchor) member.Agent.Task = new FollowTask { Anchor = anchor, Standoff = Standoff(ship) }; } diff --git a/Assets/Scripts/ServerShared/Doctrine.cs b/Assets/Scripts/ServerShared/Doctrine.cs index 40ce188e0..4a9b636ed 100644 --- a/Assets/Scripts/ServerShared/Doctrine.cs +++ b/Assets/Scripts/ServerShared/Doctrine.cs @@ -15,7 +15,7 @@ public enum EngageOn Identified, // Only a hostile that a member holds a hostile IFF override on (a grudge). Provoked, - // A hostile whose presence this faction's zone does not permit. + // A hostile whose presence the zone's owner does not permit (Entity.PresencePermitted, the security of the zone it is in), whichever flight sees it. Trespass, // Never, not even for a grudge. Never diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index d6efd6847..5648c0db8 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -314,6 +314,47 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.True(r.Gun.Weapon.Firing, "the turret must fire a fused weapon at a designated target out of arc"); } + // The turret keeps the AgentMinHitProbability threshold for a direct-hit weapon: a shot priced near .5 is held at a + // threshold of .6 and taken at .4. Kills: the turret's fire threshold dropped to zero or fixed to a constant. + [Theory] + [InlineData(.6f, false)] + [InlineData(.4f, true)] + public void ATurretKeepsTheGlobalHitProbabilityThresholdForADirectHitWeapon(float minHit, bool fires) + { + var r = Build(accuracy: .5f, minHit: minHit, turret: true); + var p = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target); + Assert.True(p > .4f && p < .6f, $"fixture: the shot must price near .5, priced {p}"); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.Equal(fires, r.Gun.Weapon.Firing); + } + + // A flight's members hold off at 1.1 x their longest gun when the doctrine authors no standoff (every authored + // doctrine leaves it 0), and at the authored distance otherwise. Guns of Range 50 and 100 on one ship. Kills: the + // default zeroed, inverted, taken from the shortest gun, or the authored value ignored. + [Theory] + [InlineData(0f, 110f)] + [InlineData(40f, 40f)] + public void AFlightHoldsOffAtTheDefaultOrAuthoredStandoff(float holdStandoff, float expected) + { + var r = Build(range: 50f, decoy: true); + r.Shooter.SetIff(r.Target, null); + if (!r.Shooter.VisibleEnemies.Contains(r.Target)) r.Shooter.VisibleEnemies.Add(r.Target); + var settings = r.Shooter.ItemManager.GameplaySettings; + var flight = new Flight(new FactionDoctrine + { + EngageOn = EngageOn.Detection, Grace = 5, Combatant = new RoleDoctrine { MinHitProbability = .2f, HoldStandoff = holdStandoff } + }, settings); + var agent = new Minion(r.Shooter, flight.Combatant); + flight.Join(agent, new PatrolOrbitsTask()); + + flight.Update(.1f); + + Assert.Equal(expected, ((FollowTask) agent.Task).Standoff, 2); + } + // The AI aims at the height a target rides at (Entity.RideHeight: field plus the layer the target's kind rides on), // not at the plane and not at the shooter's own layer. A target on a layer 40 u up against a shooter on the ship // layer, a shot fast enough to be predicted: the turret's and Combat's aim point at the target's stepped pose. diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 89bfd2b30..896012c53 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -33,17 +33,17 @@ public sealed class FlightTests : IDisposable } }; - private Ship Pilot(Faction faction, float x = 0) + private Ship Pilot(Faction faction, float x = 0, float z = 0) { var ship = _world.NewShip(_items, _zone, faction, piloted: true); - ship.Position = new float3(x, 0, 0); + ship.Position = new float3(x, 0, z); return ship; } - private Ship Track(float x = 100) + private Ship Track(float x = 100, float z = 0) { var track = _world.NewShip(_items, _zone, Hostile); - track.Position = new float3(x, 0, 0); + track.Position = new float3(x, 0, z); return track; } @@ -102,7 +102,7 @@ public sealed class FlightTests : IDisposable Assert.True(a.Target.Value.IsNone); Assert.True(b.Target.Value.IsNone); - b.EntityInfoGathered[track] = .9f; + b.EntityInfoGathered[track] = .8f; // exactly the threshold Tick(.1f); Assert.Same(track, a.Target.Value.Entity); Assert.Same(track, b.Target.Value.Entity); @@ -140,8 +140,8 @@ public sealed class FlightTests : IDisposable public void GraceHailsOnceFromTheNearestThenEngages() { var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."); - var near = Pilot(faction, 90); var far = Pilot(faction, 0); + var near = Pilot(faction, 90); // built second: the nearest, not the first built, speaks var track = Track(100); See(near, track); See(far, track); @@ -181,6 +181,27 @@ public sealed class FlightTests : IDisposable Assert.Empty(member.Messages); } + // Grace 3 against ComplySpeed 10: a track at 5 is slower than ComplySpeed (complied) though faster than the Grace + // number, and one at 12 is faster than both. Kills: either comparison reading Grace for ComplySpeed. + [Theory] + [InlineData(5f, false)] + [InlineData(12f, true)] + public void ComplianceIsJudgedOnComplySpeedAndNotOnGrace(float speed, bool engaged) + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 3, hail: "Hold.", comply: 10)); + var track = Track(); + track.Velocity = new float2(speed, 0); + See(member, track); + + Tick(.5f); + Tick(3.5f); + Assert.Equal(engaged, !member.Target.Value.IsNone); + + // Once complied the same speed stays complied; once engaged it stays engaged. + Tick(.1f); + Assert.Equal(engaged, !member.Target.Value.IsNone); + } + [Fact] public void AGrudgeSkipsTheGrace() { @@ -199,13 +220,13 @@ public sealed class FlightTests : IDisposable [Fact] public void FollowClosesWhenFarAndOpensWhenNear() { - var far = Pilot(null); - var near = Pilot(null); + var far = Pilot(null, -200, 50); + var near = Pilot(null, -200, 50); var anchor = Track(); float TurnAfterFollowing(Ship ship, float3 anchorAt) { - anchor.Position = anchorAt; + anchor.Position = ship.Position + anchorAt; var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; minion.Update(.1f); @@ -213,7 +234,8 @@ public sealed class FlightTests : IDisposable return ship.Turn; } - // The hull faces +z. Anchor 300 ahead: the desired velocity is dead ahead, no turn. Anchor 20 ahead: inside the + // The hull faces +z, and the pilots are off the origin so the anchor's offset from the ship counts, not its + // coordinates. Anchor 300 ahead: the desired velocity is dead ahead, no turn. Anchor 20 ahead: inside the // standoff, the desired velocity is astern, and the pilot turns to it. Assert.Equal(0, TurnAfterFollowing(far, new float3(0, 0, 300)), 3); Assert.NotEqual(0, TurnAfterFollowing(near, new float3(0, 0, 20)), 3); @@ -413,20 +435,22 @@ public sealed class FlightTests : IDisposable Assert.Same(track, member.Target.Value.Entity); } + // The member is off the origin and its nearer track is the one farther from the origin: a pick by coordinates alone + // takes the other. Kills: Flight's nearest pick reading the track's position without the member's. [Fact] public void AMemberKeepsItsTargetWhileThatTrackStaysEngaged() { - var member = Pilot(new Faction { Name = "Plain" }); - var first = Track(50); - var second = Track(200); + var member = Pilot(new Faction { Name = "Plain" }, 1000); + var first = Track(950); + var second = Track(100); See(member, first); See(member, second); Tick(.1f); Assert.Same(first, member.Target.Value.Entity); // the nearer - first.Position = new float3(500, 0, 0); - second.Position = new float3(10, 0, 0); + first.Position = new float3(-500, 0, 0); + second.Position = new float3(990, 0, 0); Tick(.1f); Assert.Same(first, member.Target.Value.Entity); // kept, though the other is nearer now } @@ -434,21 +458,40 @@ public sealed class FlightTests : IDisposable [Fact] public void HoldersCloseOnTheNearestHeldTrackAndKeepItWhileItIsHeld() { - var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.")); - var near = Track(50); - var far = Track(300); + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."), 1000); + var near = Track(950); + var far = Track(100); See(member, near); See(member, far); Tick(.1f); Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); - near.Position = new float3(900, 0, 0); - far.Position = new float3(10, 0, 0); + near.Position = new float3(-900, 0, 0); + far.Position = new float3(990, 0, 0); Tick(.1f); Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); } + // The held anchor leaves the picture: the member re-anchors on what is still held. Kills: the anchor rewritten only + // when it is unchanged (`==` for `!=`), which leaves the member following a track that is gone. + [Fact] + public void AHeldAnchorThatLeavesThePictureIsReplacedByTheRemainingTrack() + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."), 1000); + var near = Track(950); + var far = Track(100); + See(member, near); + See(member, far); + Tick(.1f); + Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + + member.VisibleEnemies.Remove(near); + Tick(.1f); + + Assert.Same(far, ((FollowTask) _zone.Agents.Single().Task).Anchor); + } + [Fact] public void ATrackThatLeavesForgetsItsPhaseAndTheMemberResumesItsPatrol() { @@ -492,8 +535,8 @@ public sealed class FlightTests : IDisposable float Turn(float anchorX) { - var ship = Pilot(null); - anchor.Position = new float3(anchorX, 0, 0); + var ship = Pilot(null, -200, 50); + anchor.Position = ship.Position + new float3(anchorX, 0, 0); var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; minion.Update(.1f); @@ -533,14 +576,32 @@ public sealed class FlightTests : IDisposable Assert.IsNotType(CurrentState(minion)); } - [Fact] - public void AMemberThatCannotSeeTheEngagedTrackClosesOnItsBearing() + public static System.Collections.Generic.IEnumerable UnseenGeometries() => + from distance in new[] { 90f, 900f } + from blindFirst in new[] { true, false } + from graced in new[] { false, true } + from right in new[] { true, false } + select new object[] { distance, blindFirst, graced, right }; + + // A member that cannot see the engaged track steers on it as a bearing, wherever it is, whoever was built first, with + // or without a hail first (the grace ends in a grudge), left or right of the member. Kills: the unseen rule holding + // the track only near, only for a non-first member, only with no grace, or only on one side. + [Theory] + [MemberData(nameof(UnseenGeometries))] + public void AMemberThatCannotSeeTheEngagedTrackClosesOnItsBearing(float distance, bool blindFirst, bool graced, bool right) { - var faction = WithDoctrine(EngageOn.Detection); - var sighted = Pilot(faction); - var blind = Pilot(faction, 10); - var track = Track(); + var faction = WithDoctrine(EngageOn.Detection, grace: graced ? 5 : 0, hail: "Hold."); + Ship blind, sighted; + if (blindFirst) { blind = Pilot(faction, 0, 0); sighted = Pilot(faction, 0, 400); } + else { sighted = Pilot(faction, 0, 400); blind = Pilot(faction, 0, 0); } + var track = Track(right ? distance : -distance); See(sighted, track); + if (graced) + { + Tick(.1f); // hailed, not yet engaged + sighted.SetIff(track, true); + } + Tick(.1f); Assert.Same(track, sighted.Target.Value.Entity); @@ -549,6 +610,57 @@ public sealed class FlightTests : IDisposable Assert.Same(track, task.Anchor); } + // The flight's engaged track outranks a hailed one a blind member does see, though that one is nearer. Kills: the + // anchor picked by distance alone, or kept once something outranks it. + [Fact] + public void ABlindMemberAnchorsOnTheEngagedTrackOverANearerHailingOne() + { + var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."); + var blind = Pilot(faction, 0, 0); + var sighted = Pilot(faction, 0, 400); + var engaged = Track(900); + var hailing = Track(50); + See(sighted, engaged); + See(blind, hailing); + Tick(.1f); + var task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); + Assert.Same(hailing, task.Anchor); // nothing outranks it yet: the nearest held + + sighted.SetIff(engaged, true); + Tick(.1f); + + Assert.Same(engaged, sighted.Target.Value.Entity); + task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); + Assert.Same(engaged, task.Anchor); + } + + // Hail then engage is the production path of every graced doctrine: the member goes from FollowState into combat, + // and out of it again while the Task is a FollowTask on another held track. Kills: the combat interrupt covering + // the patrol sub-states only, or the exit reading a patrol Task only. + [Fact] + public void CombatIsEnteredFromFollowAndLeftWhileAnotherTrackIsHeld() + { + var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.")); + var minion = (Minion) _zone.Agents.Single(); + var hot = Track(300); + var held = Track(-300); + See(member, hot); + See(member, held); + Tick(.1f); + UpdateUntil(minion, () => CurrentState(minion) is FollowState); + + member.SetIff(hot, true); + Tick(.1f); + Assert.Same(hot, member.Target.Value.Entity); + UpdateUntil(minion, () => CurrentState(minion) is CombatState); + + member.VisibleEnemies.Remove(hot); + Tick(.1f); + Assert.IsType(minion.Task); + UpdateUntil(minion, () => !(CurrentState(minion) is CombatState)); + UpdateUntil(minion, () => CurrentState(minion) is FollowState); + } + private Minion PatrollingMinion(Ship ship) { // Far from the (unresolved) circuit point, so MoveToOrbit does not hand back to patrol on arrival every tick. @@ -653,6 +765,35 @@ public sealed class FlightTests : IDisposable finally { File.Delete(path); } } + [Fact] + public void ADryRunOfTheCatalogCommandWritesNothing() + { + var path = TempCatalog(); + try + { + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new Faction { Name = "Zhestokost" }); + cache.FlushAsync().Wait(); + } + + var before = File.ReadAllBytes(path); + var output = new StringWriter(); + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + DoctrineCatalog.Run(cache, apply: false, output); + cache.FlushAsync().Wait(); + } + + Assert.Contains("Zhestokost: engage on", output.ToString()); + Assert.Equal(before, File.ReadAllBytes(path)); + using (var cache = AetheriaStores.Open(path)) + Assert.Null(cache.GetAll().Single().Doctrine); + } + finally { File.Delete(path); } + } + [Fact] public void TheCatalogCommandNamesAMissingFaction() { diff --git a/tools/AetherDb/DoctrineCatalog.cs b/tools/AetherDb/DoctrineCatalog.cs index 5b0413d62..16e83dee8 100644 --- a/tools/AetherDb/DoctrineCatalog.cs +++ b/tools/AetherDb/DoctrineCatalog.cs @@ -4,8 +4,8 @@ using System.Linq; using GameCult.Caching; // Authors the starting FactionDoctrine of the four factions the demo cast uses (aetheria-release, faction-play-1). -// Tuning is a starting point; the operator adjusts it after play. A faction with no Faction record is named, not -// created: the Pirates' record is owed by follow-up pirates-catalog-record, and re-running apply writes them once it lands. +// Tuning is a starting point; the operator adjusts it after play. The Pirates record is on master (cut pirates-record); +// a faction with no Faction record is named and skipped, and apply is re-run after a cast faction's record lands. public static class DoctrineCatalog { private static readonly (string faction, FactionDoctrine doctrine)[] Table = @@ -39,7 +39,7 @@ public static class DoctrineCatalog var changed = new System.Collections.Generic.List(); foreach (var (name, doctrine) in Table) { - var faction = factions.FirstOrDefault(f => string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)); + var faction = factions.FirstOrDefault(f => f.Name == name); if (faction == null) { output.WriteLine($"{name}: no Faction record, doctrine not written"); diff --git a/tools/AetherDb/VerseGrammarCommand.cs b/tools/AetherDb/VerseGrammarCommand.cs index e997b87fe..883fe840d 100644 --- a/tools/AetherDb/VerseGrammarCommand.cs +++ b/tools/AetherDb/VerseGrammarCommand.cs @@ -37,6 +37,8 @@ public static class VerseGrammarCommand Roles = new[] { Role("target", VerseReferentKind.Faction) } }, new VerseVerb { Name = "explore", RenderPath = VerseRenderPath.ShipAction, Description = "The actor surveys a region and charts what is there.", Roles = new[] { Role("region", VerseReferentKind.Place) } }, + new VerseVerb { Name = "follow", RenderPath = VerseRenderPath.ShipAction, Description = "The actor shadows a ship of the target faction at a standoff, as a flight does while it hails.", + Roles = new[] { Role("target", VerseReferentKind.Faction) } }, new VerseVerb { Name = "speak", RenderPath = VerseRenderPath.Conversation, Description = "The actor says something to whoever is in earshot. Shown as Aetheria dialogue: face to face when docked, by radio with the speaker's portrait over their ship when undocked.", Roles = new VerseRole[0] }, };