diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index c906ce33b..7aa7cfc54 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -23,6 +23,9 @@ public class Agent private VelocityLimit _velocityLimit; public AgentTask Task { get; set; } + // Zone-scoped, from 1: the ordinal of this pilot's own dice, set by Zone.CreateAgent as it admits the pilot (0 for + // an agent built outside a zone's admission). + public uint Id { get; init; } public Ship Ship { get; } public ItemManager ItemManager { get; } public GameplaySettings Settings { get; } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 8ed210567..499f631b0 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -173,7 +173,10 @@ public class CombatState : BaseState maxRange = weapon.Range; } - var offset = _agent.ItemManager.Random.NextFloat((maxRange - minRange) / DPS_SAMPLE_COUNT); + // The pilot's own dice, a pure function of the zone seed and its id: the same offset at every resample, and + // nothing that mints an item can move it. + var offset = SimulationDice.For(_agent.Ship.Zone.CombatSeed, SimulationDice.CombatSampleStream, _agent.Id) + .NextFloat((maxRange - minRange) / DPS_SAMPLE_COUNT); var optimumDPS = 0f; for (int i = 0; i < DPS_SAMPLE_COUNT; i++) { diff --git a/Assets/Scripts/ServerShared/SimulationDice.cs b/Assets/Scripts/ServerShared/SimulationDice.cs index 94d8f8946..ce646d670 100644 --- a/Assets/Scripts/ServerShared/SimulationDice.cs +++ b/Assets/Scripts/ServerShared/SimulationDice.cs @@ -10,6 +10,10 @@ public static class SimulationDice { public const uint LootStream = 1; public const uint MineStream = 2; + // A pilot's own decisions, ordinal Agent.Id: its preferred engagement range and its patrol circuit. AI behaviour + // never rides ItemManager.Random, so minting items cannot change how a pilot fights. + public const uint CombatSampleStream = 3; + public const uint PatrolStream = 4; // 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply, // xor-shift, multiply, xor-shift. Diffuses a structured seed (a zone seed XORed with a small, diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index bb8e9adfe..4fc40fc53 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -133,6 +133,10 @@ public class Zone // The death ordinal of the loot dice: the nth death in this zone rolls on SimulationDice.For(CombatSeed, LootStream, n). private uint _deaths; + // The zone-scoped agent ordinal, from 1, never reused: the key of a pilot's own dice. + private uint _lastAgentId; + public uint NextAgentId() => ++_lastAgentId; + // One death subscription per member entity: Admit creates it together with the membership, Remove disposes it // together with the membership, and nothing else touches either. private readonly Dictionary _deathWatches = new Dictionary(); @@ -301,9 +305,11 @@ public class Zone var key = ship.Faction ?? Unaffiliated; if (!_flights.TryGetValue(key, out var flight)) _flights[key] = flight = new Flight(ship.Faction?.Doctrine, _itemManager.GameplaySettings); - var agent = new Minion(ship, flight.Combatant); + var id = NextAgentId(); + var agent = new Minion(ship, flight.Combatant) { Id = id }; var task = new PatrolOrbitsTask(); - task.Circuit = Orbits.OrderBy(_ => _itemManager.Random.NextFloat()).Take(4).Select(x => x.Key).ToArray(); + 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); return agent; } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 2eca55442..72cec2284 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -780,6 +780,27 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.True(Optimum(1f) - Optimum(0f) > 100f); } + // The range-sample offset is the pilot's own draw (zone seed, its id), never the item stream's: the same pilot + // samples the same optimum however many items were minted before it spawned, and two pilots sample their own. + // Kills: the offset drawn from ItemManager.Random; the agent id dropped from the draw. + [Fact] + public void AnAgentsSampleOffsetIsItsOwnAndNotTheItemStreams() + { + var r = Build(range: 400f, decoy: true); + float Optimum(uint id, int itemsMintedFirst) + { + for (var i = 0; i < itemsMintedFirst; i++) r.Shooter.ItemManager.Random.NextFloat(); + var combat = new CombatState(new Minion(r.Shooter, new RoleDoctrine { RangeExponent = 1f, MinHitProbability = .2f }) { Id = id }); + combat.Update(0f); + return combat.OptimumRange; + } + + Assert.Equal(Optimum(3, 0), Optimum(3, 0)); + 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"); + } + // A round refused on a Range that fell between the trigger and the round is still free: the burst is paid // (SingleAmmoBurst) at its first round that is not refused, judged with the Range that round flies with. // The trigger saw a stale Range of 100; the round's Range is 29.5, under the arming distance. The refusal