From 14d3327c1927a9d463fc9d75b654bde63ec32ec7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 18 Oct 2026 00:51:33 +0200 Subject: [PATCH] LockOn: the hostile is ordered to close on the player; the warning is measured against the poses it was stamped from ScenarioStage.Approach(entity, target) states a piloted entity's task as a follow task on the target, passed through Zone.Admit to the flight as the member's baseline task; it replaces Patrol(entity, orbit) and the orbit index, and RunStart's post-admit circuit overwrite. LockOn stages at .6, the quality Crossing and the other RunStart scenarios use (its brief states none), not .8 picked by outcome. The bearing check keeps well-law r4's intent (measure against the hostile's actual pose, not the staged line) but reads the poses of the tick before the paint was first seen, which is the tick it was stamped in, with a .05 deg limit for float error; the live-pose check at .5 deg spent its budget on the paint inbox's deliberate one-tick delay. LockOnHoldsUnderOnePercentOfEachStagedInput moves hostile x, y and quality by 1% each way. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Agents/Flight.cs | 13 +++-- Assets/Scripts/ServerShared/RunStart.cs | 5 +- Assets/Scripts/ServerShared/ScenarioStage.cs | 17 +++--- .../Scripts/ServerShared/Scenarios/LockOn.cs | 39 +++++++------ Assets/Scripts/ServerShared/Zone.cs | 18 +++--- .../LockOnScenarioTests.cs | 57 ++++++++++++++++--- 6 files changed, 100 insertions(+), 49 deletions(-) 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()}"); } }