From bbfd816851fab29070f09e63f64fc971ae834a8e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 21:28:35 +0200 Subject: [PATCH] LockOn states its hostile's patrol order and fit quality; dice streams, patrol circuit and projector range pinned ScenarioStage.Patrol(entity, near) states a piloted entity's patrol order (the orbit nearest a point) and RunStart applies it over the drawn circuit. LockOn stages its fit at .6 and the hostile on the orbit by the player. Tests: every SimulationDice stream draws independently of every other (they already did: the stream is mixed into the hash), the pilot's sample offset is on its own stream's grid, a patrol circuit does not move with items minted first, Crossing.ProjectorRange is read against the live range. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/RunStart.cs | 3 +++ Assets/Scripts/ServerShared/ScenarioStage.cs | 14 +++++++++++ .../Scripts/ServerShared/Scenarios/LockOn.cs | 9 +++++-- .../CrossingScenarioTests.cs | 1 + .../FireControlPerWeaponTests.cs | 12 +++++++++ .../FloatingBodyTests.cs | 23 +++++++++++++++++ tests/Aetheria.Shared.Tests/ScenarioTests.cs | 25 +++++++++++++++++++ 7 files changed, 85 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index b330ad944..c9c287a3c 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -54,6 +54,9 @@ public static class RunStart 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; } 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 77e5bf1e3..d89e4c7af 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using CultMath; +using GameCult.Caching; using static CultMath.math; using float2 = CultMath.float2; using float3 = CultMath.float3; @@ -48,6 +49,7 @@ public sealed class ScenarioStage public Entity Entity; public ScenarioStance Stance; public bool Piloted; + public CultRecordKey[] Circuit; } // 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 @@ -134,6 +136,18 @@ public sealed class ScenarioStage return entity; } + // The patrol order a piloted entity starts on: the one orbit that sits nearest `near` when the stage opens. 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 + // piloted or not placed is a failure. + public void Patrol(Entity entity, float2 near) + { + 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; } + var orbit = Arena.Orbits.Keys.OrderBy(key => lengthsq(Arena.GetOrbitPosition(key) - near)).Cast().FirstOrDefault(); + if (orbit == null) { _failures.Add("patrol: the arena has no orbit to patrol"); return; } + placement.Circuit = new[] { orbit.Value }; + } + // Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage // chooses its product) or a product of one (pinned); a name that is neither is a failure. public void Cargo(Entity entity, params string[] names) diff --git a/Assets/Scripts/ServerShared/Scenarios/LockOn.cs b/Assets/Scripts/ServerShared/Scenarios/LockOn.cs index 87fc14103..370c2cd80 100644 --- a/Assets/Scripts/ServerShared/Scenarios/LockOn.cs +++ b/Assets/Scripts/ServerShared/Scenarios/LockOn.cs @@ -18,11 +18,15 @@ public sealed class LockOn : Scenario // 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). + const float StagedQuality = .6f; + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(DemoTerminus.Cast); public override void Stage(ScenarioStage stage) { - var fighter = stage.Fit("Longinus", + var fighter = stage.Fit("Longinus", StagedQuality, ("Cockpit 2x2", int2(2, 6), None), ("Large Drive", int2(1, 0), Reversed), ("Large Drive", int2(3, 0), Reversed), @@ -41,6 +45,7 @@ public sealed class LockOn : Scenario ("Store-All Plus", int2(2, 8), None)); // Cargo Bay stage.Player(fighter, float2(0, 0), facing: float2(0, 1)); - stage.Place(fighter, HostileAt, facing: normalize(-HostileAt), stance: ScenarioStance.Hostile, piloted: true); + var hostile = stage.Place(fighter, HostileAt, facing: normalize(-HostileAt), stance: ScenarioStance.Hostile, piloted: true); + stage.Patrol(hostile, float2(0, 0)); // it patrols the orbit by the player, which brings it into lock range } } diff --git a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs index a55cf3e93..a21aa7f19 100644 --- a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs @@ -55,6 +55,7 @@ public sealed partial class RunStartTests var toWaiting = waiting.Position - player.Position; EvSettle(player); // a weapon's Range is read once the ship's stats have settled; the geometry above is as staged var range = projector.Behaviors.OfType().Single().Range; + Assert.InRange(range, Crossing.ProjectorRange - 1f, Crossing.ProjectorRange + 1f); // the constant the waiting place is 80% of Assert.True(FireControl.InArc(projector, toWaiting), "the waiting Djinni is inside the Lightning Projector's arc"); Assert.True(length(toWaiting) <= range, "the waiting Djinni is inside the Lightning Projector's Range"); Assert.True(length(toWaiting) > .75f * range, "the waiting Djinni is beyond three quarters of the Lightning Projector's Range"); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 72cec2284..90e142b3c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -799,6 +799,18 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(Optimum(3, 0), Optimum(3, 17)); Assert.True(Enumerable.Range(1, 8).Select(i => Optimum((uint) i, 0)).Distinct().Count() > 1, "pilots with different ids sample different offsets"); + + // Whose dice: each sampled range is the pilot's offset plus a whole step of the sample grid, so the optimum + // minus the offset from CombatSampleStream lands on the grid for every id; an offset from another stream + // (the loot dice, say) leaves a remainder. Kills: the offset drawn from another stream. + var min = r.Shooter.Weapons.Min(w => w.MinRange); + var step = (r.Shooter.Weapons.Max(w => w.Range) - min) / 32f; // Combat.DPS_SAMPLE_COUNT + foreach (var id in Enumerable.Range(1, 16).Select(i => (uint) i)) + { + var offset = SimulationDice.For(r.Zone.CombatSeed, SimulationDice.CombatSampleStream, id).NextFloat(step); + var cells = (Optimum(id, 0) - offset - min) / step; + Assert.True(MathF.Abs(cells - MathF.Round(cells)) < .01f, $"id {id}: optimum is {cells} steps from the pilot's own offset"); + } } // A round refused on a Range that fell between the trigger and the round is still free: the burst is paid diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs index d2ee30870..ac4f763c1 100644 --- a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using CultMath; using GameCult.Caching; using Xunit; @@ -138,6 +139,28 @@ public sealed class FloatingBodyTests : IDisposable Assert.Equal(1u, zero.state); } + // The stream is mixed into the seed, not added beside it: over many ordinals no two streams give the same first + // draw, and none of them tracks another's. + [Fact] + public void EveryStreamDrawsIndependentlyOfEveryOther() + { + uint[] streams = { SimulationDice.LootStream, SimulationDice.MineStream, SimulationDice.CombatSampleStream, SimulationDice.PatrolStream }; + const int count = 512; + var draws = streams.Select(s => Enumerable.Range(1, count).Select(n => SimulationDice.For(1234u, s, (uint) n).NextFloat()).ToArray()).ToArray(); + for (var a = 0; a < streams.Length; a++) + for (var b = a + 1; b < streams.Length; b++) + { + Assert.Equal(0, Enumerable.Range(0, count).Count(i => draws[a][i] == draws[b][i])); + var meanA = draws[a].Average(); + var meanB = draws[b].Average(); + var covariance = Enumerable.Range(0, count).Sum(i => (draws[a][i] - meanA) * (draws[b][i] - meanB)) / count; + var correlation = covariance / (StandardDeviation(draws[a], meanA) * StandardDeviation(draws[b], meanB)); + Assert.InRange(correlation, -.15f, .15f); // 512 independent draws: sigma ~ .044 + } + } + + private static float StandardDeviation(float[] xs, float mean) => MathF.Sqrt(xs.Sum(x => (x - mean) * (x - mean)) / xs.Length); + [Fact] public void NeighbouringZonesAndOrdinalsNeverShareADraw() { diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index 9e5903eff..60b205765 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -291,6 +291,31 @@ public sealed partial class RunStartTests Assert.Contains("No Such Gun", Assert.Single(failures)); } + // A pilot's patrol circuit is its own draw (zone seed, agent id), not the item stream's: two arenas that differ only + // in items minted before the pilot is admitted give it the same circuit. Kills: the circuit drawn from ItemManager.Random. + [Fact] + public void APilotsPatrolCircuitDoesNotRideTheItemStream() + { + Zone Arena(int itemsMintedFirst) + { + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(0, 0)); + for (var i = 0; i < itemsMintedFirst; i++) stage.Place(stage.Generated("Longinus"), float2(900 + 40 * i, 0)); + stage.Place(stage.Generated("Longinus"), float2(300, 0), stance: ScenarioStance.Hostile, piloted: true); + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + return arena; + } + CultRecordKey[] Circuit(Zone arena) => ((PatrolOrbitsTask) Assert.Single(arena.Agents).Task).Circuit; + + var plain = Arena(0); + var disturbed = Arena(3); + Assert.True(plain.Orbits.Count > 4, "the zone has orbits to choose a circuit among"); + Assert.Equal(4, Circuit(plain).Length); + Assert.Equal(Circuit(plain), Circuit(disturbed)); + } + // A fit that states its quality mints every lot it builds at it, assembly and role fills alike; the same fit without one // draws them from the item stream, so some lot comes out otherwise. A scenario's checks rest on the first. [Fact]