From 04516c72cf2c4a835818a5eb22b84871fcf3f4a9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 20:31:41 +0200 Subject: [PATCH] Faction play 1: s1/s2 fix batch (Engaged-first anchor, doctrine pins, follow verb) Flight anchors a blind member on an Engaged track before a nearer Hailing one and keeps its anchor only while nothing outranks it. Doctrine.cs states the trespass rule as the zone owner's permission. DoctrineCatalog matches the faction by exact name and its comment names the landed Pirates record. VerseGrammarCommand gains the follow verb. Tests pin default and authored standoff, Grace against ComplySpeed, nearest hail, the Identified boundary, off-origin direction and anchor choice and switch, the unseen-member rule across geometry, combat from follow, the doctrine-catalog dry run and the turret's global hit threshold. Not yet run: this commit's tests have not been built. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Agents/Flight.cs | 6 +- Assets/Scripts/ServerShared/Doctrine.cs | 2 +- .../FireControlPerWeaponTests.cs | 41 ++++ tests/Aetheria.Shared.Tests/FlightTests.cs | 197 +++++++++++++++--- tools/AetherDb/DoctrineCatalog.cs | 6 +- tools/AetherDb/VerseGrammarCommand.cs | 2 + 6 files changed, 221 insertions(+), 33 deletions(-) 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] }, };