diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index b96250ad8..de3c8f0c0 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -25,7 +25,7 @@ public class Flight private class Member { public Agent Agent; - public PatrolOrbitsTask Patrol; + public AgentTask Baseline; } private readonly FactionDoctrine _doctrine; @@ -49,10 +49,11 @@ public class Flight public Phase PhaseOf(Entity track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None; - public void Join(Agent agent, PatrolOrbitsTask circuit) + // The task a member holds when the flight has nothing to follow: its patrol circuit, or the task a scenario stated. + public void Join(Agent agent, AgentTask baseline) { - _members.Add(new Member { Agent = agent, Patrol = circuit }); - agent.Task = circuit; + _members.Add(new Member { Agent = agent, Baseline = baseline }); + agent.Task = baseline; } public void Leave(Agent agent) => _members.RemoveAll(m => m.Agent == agent); @@ -167,8 +168,8 @@ public class Flight if (!(member.Agent.Task is FollowTask current) || current.Anchor != anchor) member.Agent.Task = new FollowTask { Anchor = anchor, Standoff = Standoff(ship) }; } - else if (member.Agent.Task != member.Patrol) - member.Agent.Task = member.Patrol; + else if (member.Agent.Task != member.Baseline) + member.Agent.Task = member.Baseline; } private static void Aim(Ship ship, Entity target) diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index c9c287a3c..cce2ab977 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -53,10 +53,7 @@ public static class RunStart var hostile = placed.Stance == ScenarioStance.Hostile; placed.Entity.SetIff(player, hostile); player.SetIff(placed.Entity, hostile); - arena.Admit(placed.Entity, placed.Piloted); - // A stated patrol order replaces the circuit the zone drew for the pilot it just admitted. - if (placed.Circuit != null) - ((PatrolOrbitsTask) arena.Agents.Last(agent => ReferenceEquals(agent.Ship, placed.Entity)).Task).Circuit = placed.Circuit; + arena.Admit(placed.Entity, placed.Piloted, placed.Task); } return new Staged { Player = player, Credits = stage.Credits, Entities = stage.Placed.Select(placed => placed.Entity).ToList() }; } diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index 735d40775..c9531e60f 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -49,7 +49,7 @@ public sealed class ScenarioStage public Entity Entity; public ScenarioStance Stance; public bool Piloted; - public CultRecordKey[] Circuit; + public AgentTask Task; } // A hull fitted slot by slot: each slot a design or a product, the hull cell it goes in, and its rotation. A name is @@ -136,15 +136,16 @@ public sealed class ScenarioStage return entity; } - // The patrol order a piloted entity starts on: the one orbit at this index of the arena's orbits, which a galaxy seed - // fixes. A check that rests on where a pilot goes states it here, so the pilot's circuit is not a draw. An entity that - // is not placed and piloted, or an index past the orbits, is a failure. - public void Patrol(Entity entity, int orbit) + // The task a piloted entity starts on: it closes on the target and holds there (the flight's follow task, standoff + // nothing), in place of the patrol circuit the zone would draw for it. A check that rests on where a pilot goes states + // it here, so the pilot's course is the scenario's and not a draw. An entity that is not placed and piloted, or a + // target that is not placed, is a failure. + public void Approach(Entity entity, Entity target) { var placement = _placed.FirstOrDefault(p => ReferenceEquals(p.Entity, entity)); - if (placement == null || !placement.Piloted) { _failures.Add($"patrol: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; } - if (orbit < 0 || orbit >= Arena.Orbits.Count) { _failures.Add($"patrol: the arena has no orbit {orbit} (it has {Arena.Orbits.Count})"); return; } - placement.Circuit = new[] { Arena.Orbits.Keys.ElementAt(orbit) }; + if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; } + if (target == null) { _failures.Add("approach: no target to close on"); return; } + placement.Task = new FollowTask { Anchor = target, Standoff = 0f }; } // Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage diff --git a/Assets/Scripts/ServerShared/Scenarios/LockOn.cs b/Assets/Scripts/ServerShared/Scenarios/LockOn.cs index 609e6b528..171b34688 100644 --- a/Assets/Scripts/ServerShared/Scenarios/LockOn.cs +++ b/Assets/Scripts/ServerShared/Scenarios/LockOn.cs @@ -12,26 +12,33 @@ public sealed class LockOn : Scenario // The hostile starts about 4000 m off, ahead and to port of the player, facing the player. At that range its passive // sensors hold no track of the player (the test asks the sim's own PassiveInfo against the detection threshold). The - // hostile closes, sees the player some fourteen seconds in and locks. Its flanking approach and the zone's pull carry it - // off the staged line (about 5 degrees by the lock on the merged tree), and the warning must point at where the hostile - // is at the lock, which the test checks against the live bearing. At Duel's 900 m (and at 600 m) it sees and locks - // within a second. + // hostile is ordered to close on the player, finds it and locks. The zone's pull carries it off the staged line, so the + // warning must point at where the hostile was when the paint was stamped, which the test checks against the poses of + // that tick. At Duel's 900 m (and at 600 m) it sees and locks within a second. public static readonly CultMath.float2 HostileAt = float2(-2000, 3450); - // The fit's lots are minted at this quality, and the hostile's patrol order is stated, so the lock follows from the - // staged situation and not from item draws or the pilot's drawn circuit (ruling scenario-stages-hostile-task). - // .8 and not Crossing's .6: the lock comes at the hostile's closest pass, where the warning's one-tick lag reads as bearing error - // (the test allows .5 degrees); at .6 the lock came at 880 m with .57, at .7 .48, at .8 .45 (942 m); at .2 to .5 and 1 no lock in 600 ticks. - const float StagedQuality = .8f; - // The orbit it patrols: the fourth of the arena's, which carries the hostile across the player's lock envelope (lock - // at about 35 s, closest 880 m; the other twelve, patrolled alone, do not lock it in 600 ticks). - const int PatrolOrbit = 3; + // The fit's lots are minted at this quality, the one Crossing and the other RunStart scenarios stage (the brief states no + // other), and the hostile's order is to close on the player, so the lock follows from the staged situation and not + // from item draws or the pilot's drawn circuit (ruling scenario-stages-hostile-task). + const float StagedQuality = .6f; + + private readonly CultMath.float2 _hostileAt; + private readonly float _quality; + + public LockOn() : this(HostileAt, StagedQuality) { } + + // The staged inputs, open to a test that moves each a little and asks that the scenario still hold. + internal LockOn(CultMath.float2 hostileAt, float quality) + { + _hostileAt = hostileAt; + _quality = quality; + } public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(DemoTerminus.Cast); public override void Stage(ScenarioStage stage) { - var fighter = stage.Fit("Longinus", StagedQuality, + var fighter = stage.Fit("Longinus", _quality, ("Cockpit 2x2", int2(2, 6), None), ("Large Drive", int2(1, 0), Reversed), ("Large Drive", int2(3, 0), Reversed), @@ -49,8 +56,8 @@ public sealed class LockOn : Scenario ("Panopticon Prime", int2(1, 6), Clockwise), // Fire Control Array ("Store-All Plus", int2(2, 8), None)); // Cargo Bay - stage.Player(fighter, float2(0, 0), facing: float2(0, 1)); - var hostile = stage.Place(fighter, HostileAt, facing: normalize(-HostileAt), stance: ScenarioStance.Hostile, piloted: true); - stage.Patrol(hostile, PatrolOrbit); + var player = stage.Player(fighter, float2(0, 0), facing: float2(0, 1)); + var hostile = stage.Place(fighter, _hostileAt, facing: normalize(-_hostileAt), stance: ScenarioStance.Hostile, piloted: true); + stage.Approach(hostile, player); } } diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 4fc40fc53..4db63be46 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -279,13 +279,14 @@ public class Zone // turret all join through here. A piloted entity gets the zone's Minion; an unpiloted one gets no agent at all. // Only a ship can be piloted. The death watch is installed here, for every join, whichever caller admits it. // An entity that is already a member is refused: it would hold a second watch and a second agent. - public void Admit(Entity entity, bool piloted) + // A piloted entity starts on the zone's drawn patrol circuit unless the caller states its task (a scenario's approach). + public void Admit(Entity entity, bool piloted, AgentTask task = null) { if (_entities.Contains(entity)) throw new InvalidOperationException("entity: already a member of this zone"); _entities.Add(entity); _deathWatches.Add(entity, entity.Death.Subscribe(_ => { DropLoot(entity); Remove(entity); entity.Deactivate(); })); entity.Activate(); - if (piloted) Agents.Add(CreateAgent((Ship) entity)); + if (piloted) Agents.Add(CreateAgent((Ship) entity, task)); } // The one removal from this zone (death, dock, jump): membership and its death watch leave together. False, and @@ -300,20 +301,23 @@ public class Zone return true; } - private Agent CreateAgent(Ship ship) + private Agent CreateAgent(Ship ship, AgentTask stated) { var key = ship.Faction ?? Unaffiliated; if (!_flights.TryGetValue(key, out var flight)) _flights[key] = flight = new Flight(ship.Faction?.Doctrine, _itemManager.GameplaySettings); var id = NextAgentId(); var agent = new Minion(ship, flight.Combatant) { Id = id }; - var task = new PatrolOrbitsTask(); - var dice = SimulationDice.For(CombatSeed, SimulationDice.PatrolStream, id); - task.Circuit = Orbits.OrderBy(_ => dice.NextFloat()).Take(4).Select(x => x.Key).ToArray(); - flight.Join(agent, task); + flight.Join(agent, stated ?? PatrolCircuit(id)); return agent; } + private PatrolOrbitsTask PatrolCircuit(uint id) + { + var dice = SimulationDice.For(CombatSeed, SimulationDice.PatrolStream, id); + return new PatrolOrbitsTask { Circuit = Orbits.OrderBy(_ => dice.NextFloat()).Take(4).Select(x => x.Key).ToArray() }; + } + public ZonePack PackZone() { return new ZonePack diff --git a/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs b/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs index 0e326d7b0..603a6bea0 100644 --- a/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs @@ -33,8 +33,25 @@ public sealed partial class RunStartTests var settled = hostile.PassiveInfo(player.Position.xz, player.Visibility); Assert.True(settled <= threshold, $"the hostile would settle at {settled} on the player, above the detection threshold {threshold}: staged inside its sight"); + var (lockedAt, bearingError, stagedDrift, strengths) = StepToLock(arena, player, hostile, toPlayer); + Assert.True(lockedAt >= 0, "the hostile never locked the player within 600 ticks of .1 s"); + Assert.True(bearingError <= StampedBearingLimit, $"the warning points at the hostile where it was when stamped, {bearingError} deg off (the hostile is {stagedDrift} deg off its staged line at the lock)"); + Assert.True(strengths.First() < strengths.Last(), $"the lock tightens: Strength went {strengths.First()} to {strengths.Last()}"); + } + + // Float error in the stamped pose and the normalizations, not a lag budget: the warning is exact for the tick it was stamped in. + const float StampedBearingLimit = .05f; + + // The paint is stamped from the poses at the end of one tick and published at the start of the next (an entity's inbox + // publishes a tick late, so the order of the entities does not matter), so the warning first read is measured against + // the poses of the tick before: where the hostile was when the paint was stamped, not where it is now. Steps up to 600 + // ticks of .1 s, and 40 past the lock. lockedAt is -1 when it never locks. + private static (int lockedAt, float bearingError, float stagedDrift, List strengths) StepToLock(Zone arena, Ship player, Entity hostile, CultMath.float2 toPlayer) + { + var hostileThen = hostile.Position.xz; + var playerThen = player.Position.xz; var lockedAt = -1; - var bearingDot = 0f; + var bearingError = 0f; var stagedDrift = 0f; var strengths = new List(); for (var tick = 0; tick < 600 && (lockedAt < 0 || tick < lockedAt + 40); tick++) @@ -45,16 +62,40 @@ public sealed partial class RunStartTests if (lockedAt < 0) { lockedAt = tick; - // The warning points at the hostile where it is at the lock; the hostile has left the staged line by then. - var live = normalize(hostile.Position.xz - player.Position.xz); - bearingDot = dot(normalize(paint.Bearing), live); - stagedDrift = degrees(acos(clamp(dot(live, -toPlayer), -1f, 1f))); + var stamped = normalize(hostileThen - playerThen); + bearingError = degrees(acos(clamp(dot(normalize(paint.Bearing), stamped), -1f, 1f))); + stagedDrift = degrees(acos(clamp(dot(stamped, -toPlayer), -1f, 1f))); } strengths.Add(paint.Strength); } + hostileThen = hostile.Position.xz; + playerThen = player.Position.xz; + } + return (lockedAt, bearingError, stagedDrift, strengths); + } + + // The staged inputs, each moved 1%: the lock and the warning's bearing still hold, so the scenario does not rest on the + // exact numbers it was written with. Kills: a pass geometry that only the staged numbers reach. + [Fact] + public void LockOnHoldsUnderOnePercentOfEachStagedInput() + { + var at = LockOn.HostileAt; + var variants = new (string what, LockOn scenario)[] + { + ("staged", new LockOn()), + ("x +1%", new LockOn(float2(at.x * 1.01f, at.y), .6f)), ("x -1%", new LockOn(float2(at.x * .99f, at.y), .6f)), + ("y +1%", new LockOn(float2(at.x, at.y * 1.01f), .6f)), ("y -1%", new LockOn(float2(at.x, at.y * .99f), .6f)), + ("quality +1%", new LockOn(at, .6f * 1.01f)), ("quality -1%", new LockOn(at, .6f * .99f)) + }; + foreach (var (what, scenario) in variants) + { + var (_, arena, staged, failures) = Launch(scenario, Inputs(() => GalaxySeed)); + Assert.True(failures.Count == 0, $"{what}: {string.Join("; ", failures)}"); + var hostile = Assert.Single(staged.Entities); + arena.Update(.1f); + var (lockedAt, bearingError, _, _) = StepToLock(arena, staged.Player, hostile, normalize(staged.Player.Position.xz - hostile.Position.xz)); + Assert.True(lockedAt >= 0, $"{what}: the hostile never locked the player within 600 ticks"); + Assert.True(bearingError <= StampedBearingLimit, $"{what}: the warning is {bearingError} deg off the pose it was stamped from"); } - Assert.True(lockedAt >= 0, "the hostile never locked the player within 600 ticks of .1 s"); - Assert.True(bearingDot >= cos(radians(.5f)), $"the warning points at the hostile where it is (the hostile is {stagedDrift} deg off its staged line at the lock)"); - Assert.True(strengths.First() < strengths.Last(), $"the lock tightens: Strength went {strengths.First()} to {strengths.Last()}"); } }