From 76143e50d19b5e3fa13e2525e02a0f67b8a3837b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 02:27:28 +0200 Subject: [PATCH] Command seam, player: PilotCommand gains Tractor, the target Verb, CycleAim and Iff; HelmInput.Demand returns a TurnCommand resolved each step Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/HelmInput.cs | 5 +- Assets/Scripts/ServerShared/Pilot.cs | 35 +++- .../PilotSeamPlayerTests.cs | 168 ++++++++++++++++++ tests/Aetheria.Shared.Tests/SteeringTests.cs | 10 +- 4 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs diff --git a/Assets/Scripts/ServerShared/HelmInput.cs b/Assets/Scripts/ServerShared/HelmInput.cs index fe1316aff..7894f33a3 100644 --- a/Assets/Scripts/ServerShared/HelmInput.cs +++ b/Assets/Scripts/ServerShared/HelmInput.cs @@ -13,6 +13,7 @@ public class HelmInput public void Toggle() => Latched = !Latched; - public float Demand(Ship ship, float2 aim, float turnAxis) => - FaceAim ? Steering.Toward(ship, aim) : clamp(turnAxis, -1f, 1f); + // The turn the player commands: the aim heading, resolved against the hull at each sim step, or the turn axis. + public TurnCommand Demand(float2 aim, float turnAxis) => + FaceAim ? TurnCommand.Toward(aim) : TurnCommand.OfRate(clamp(turnAxis, -1f, 1f)); } diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index ba42a12f1..be1e9cd7e 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -26,6 +26,16 @@ public readonly struct TurnCommand public float Resolve(Ship ship) => _toward ? Steering.Toward(ship, _heading) : _rate; } +// The player's target presses, each answered by the entity's own chooser (Entity.TargetNext and the rest) when applied. +public enum TargetVerb +{ + None, + UnderReticle, + NearestEnemy, + Next, + Previous +} + // The one input an entity's pilot gives it (its agent, the player, a scripted pilot): only own-ship vectors and handles the // pilot was given (ruling observation-sensors-only). Commands are per entity, never per Flight or side: a side may be one // ship (ruling brain-one-per-side-r2). PilotApplier.Apply is the only writer of the entity's fields from it; the pilot @@ -37,12 +47,16 @@ public sealed class PilotCommand public TurnCommand? Turn; public float2? Movement; public float3? Aim; + public float? Tractor; internal readonly List Designations = new List(); internal readonly List Selections = new List(); internal readonly List Hails = new List(); internal readonly List Uses = new List(); internal readonly List<(IActivatedBehavior Behavior, bool Pull)> Triggers = new List<(IActivatedBehavior, bool)>(); + internal readonly List<(Entity Other, bool? Hostile)> Iffs = new List<(Entity, bool?)>(); + internal TargetVerb TargetVerb; + internal int AimCycles; public void Designate(TargetRef target) => Designations.Add(target); public void SelectItem(EquippedItem item) => Selections.Add(item); @@ -50,6 +64,10 @@ public sealed class PilotCommand public void Use(ConsumableItemData consumable) => Uses.Add(consumable); public void Pull(IActivatedBehavior behavior) => Triggers.Add((behavior, true)); public void Release(IActivatedBehavior behavior) => Triggers.Add((behavior, false)); + // The last verb in a step wins. + public void Verb(TargetVerb verb) => TargetVerb = verb; + public void CycleAim() => AimCycles++; + public void Iff(Entity other, bool? hostile) => Iffs.Add((other, hostile)); // The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state). public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null; @@ -69,12 +87,15 @@ public sealed class PilotCommand Hails.Clear(); Uses.Clear(); Triggers.Clear(); + Iffs.Clear(); + TargetVerb = TargetVerb.None; + AimCycles = 0; } } // The one writer of an entity's pilot-controlled fields, called only from Zone.Update. The order is a rule: the target -// lands before its item is selected, and the aim before the triggers, so a pilot fires along the aim it commands this -// step, as the player does. +// lands (a designation, then a chooser verb) before its item is selected or the aim cycled, and the aim before the +// triggers, so a pilot fires along the aim it commands this step. public static class PilotApplier { public static void Apply(Entity entity) @@ -82,7 +103,16 @@ public static class PilotApplier var command = entity.Command; foreach (var target in command.Designations) if (!entity.Target.Value.Equals(target)) entity.SetTarget(target); + switch (command.TargetVerb) + { + case TargetVerb.UnderReticle: entity.TargetUnderReticle(); break; + case TargetVerb.NearestEnemy: entity.TargetNearestEnemy(); break; + case TargetVerb.Next: entity.TargetNext(); break; + case TargetVerb.Previous: entity.TargetPrevious(); break; + } foreach (var item in command.Selections) entity.TrySelectTargetItem(item); + for (var i = 0; i < command.AimCycles; i++) entity.CycleAim(); + foreach (var (other, hostile) in command.Iffs) entity.SetIff(other, hostile); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); if (command.Aim is float3 aim) entity.Aim = aim; @@ -94,6 +124,7 @@ public static class PilotApplier if (command.Turn is TurnCommand turn) ship.Turn = turn.Resolve(ship); if (command.Movement is float2 movement) ship.MovementDirection = movement; } + if (command.Tractor is float tractor) entity.TractorCommand = tractor; command.ClearOneShots(); } } diff --git a/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs new file mode 100644 index 000000000..7f20dfcf2 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PilotSeamPlayerTests.cs @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Collections.Generic; +using System.Linq; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// The command seam, player half (aetheria-arena, cut command-seam-player): the player's input writes only its entity's +// PilotCommand, as the agents do, and PilotApplier lands it at the next zone step. Its target presses are verbs the entity's +// chooser answers, its stance and aim-cycle presses are one-shots, its helm and tractor are held, and its triggers are +// edges: a press is one pull and a let-go one release. +public sealed partial class RunStartTests +{ + // East, west 200 away and a third 250 away, all seen by east once the zone has stepped. + private (Zone arena, Ship east, Ship west, Ship third) PlayerSeamTrio() + { + Ship third = null; + var (arena, east, west) = SeamPair(false, (stage, e, w) => + third = stage.Place(EvLonginus(stage), float2(350, 0), facing: float2(-1, 0)) as Ship); + for (var step = 0; step < 60; step++) arena.Update(SeamDt); + Assert.Contains(west, east.VisibleEntities); + Assert.Contains(third, east.VisibleEntities); + return (arena, east, west, third); + } + + [Fact] + public void HelmDemandIsAnIntent() + { + var (arena, east, _) = SeamPair(false); + var held = new HelmInput { Held = true }; + var heading = float2(0, 1); // a quarter turn from its facing + Assert.Equal(Steering.Toward(east, heading), held.Demand(heading, .5f).Resolve(east)); + Assert.Equal(.5f, new HelmInput().Demand(heading, .5f).Resolve(east)); + Assert.Equal(-1f, new HelmInput().Demand(heading, -4f).Resolve(east)); + + // One face-aim demand, held: it re-resolves against the hull at every step as the hull comes round. + east.Command.Turn = held.Demand(heading, 0); + var turns = new List(); + for (var step = 0; step < 600; step++) + { + var expected = Steering.Toward(east, heading); + arena.Update(SeamDt); + Assert.Equal(expected, east.Turn); + turns.Add(east.Turn); + } + Assert.True(turns.Distinct().Count() > 2, "the resolved turn changes as the hull turns"); + } + + [Fact] + public void TargetVerbsApplyOnceAtTheStep() + { + var (arena, east, west, third) = PlayerSeamTrio(); + Assert.True(east.Target.Value.IsNone); + + // The last verb in a step wins: Previous would pick the farthest (third), Next picks the nearest. + east.Command.Verb(TargetVerb.Previous); + east.Command.Verb(TargetVerb.Next); + Assert.True(east.Target.Value.IsNone, "nothing lands before the step"); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + + east.Command.Verb(TargetVerb.Next); + arena.Update(SeamDt); + Assert.Same(third, east.Target.Value.Entity); + + east.Command.Verb(TargetVerb.Previous); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + + // Enemies only: the farther hostile, not the nearer neutral. + east.SetIff(third, true); + east.Command.Verb(TargetVerb.NearestEnemy); + arena.Update(SeamDt); + Assert.Same(third, east.Target.Value.Entity); + + // The reticle picks along the aim; picking the held target again would clear it. + east.Command.Aim = float3(-1, 0, 0); + east.Command.Verb(TargetVerb.UnderReticle); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + } + + [Fact] + public void IffAndCycleAimAreOneShots() + { + var (arena, east, west, _) = PlayerSeamTrio(); + Assert.False(east.IsHostileTo(west)); + east.Command.Iff(west, true); + Assert.False(east.IsHostileTo(west), "nothing lands before the step"); + arena.Update(SeamDt); + Assert.True(east.IsHostileTo(west)); + // Not replayed: a stance set after the step stays. + east.SetIff(west, false); + arena.Update(SeamDt); + Assert.False(east.IsHostileTo(west)); + east.Command.Iff(west, true); + east.Command.Iff(west, null); + arena.Update(SeamDt); + Assert.False(east.IsHostileTo(west), "the later stance in a step lands last"); + + var first = SeamGun(west).Item; + var second = west.Weapons.Select(w => w.Item).First(i => i != first && i.Data.Name == first.Data.Name); + east.RecordReveal(west, first, SignatureChannel.Emission, west.ClaimOf(first)); + east.RecordReveal(west, second, SignatureChannel.Emission, west.ClaimOf(second)); + east.Command.Verb(TargetVerb.Next); + arena.Update(SeamDt); + Assert.Same(west, east.Target.Value.Entity); + var before = east.ResolvedAim; + + east.Command.CycleAim(); + Assert.Equal(before.Item, east.ResolvedAim.Item); + arena.Update(SeamDt); + var once = east.ResolvedAim; + Assert.True(once.Item != before.Item || once.Mount != before.Mount, "the cycle advanced the aim"); + arena.Update(SeamDt); + Assert.Equal(once.Item, east.ResolvedAim.Item); + Assert.Equal(once.Mount, east.ResolvedAim.Mount); + } + + // A trigger that counts its edges: a semi-auto fires once per Activate. + private sealed class SeamTrigger : IActivatedBehavior + { + public int Pulled, Released; + public void Activate() => Pulled++; + public void Deactivate() => Released++; + } + + // A press is one pull at the next step and the button held after it pulls nothing more, so a semi-auto fires once; + // the let-go is one release. + [Fact] + public void PlayerTriggerIsAnEdge() + { + var (arena, east, _) = SeamPair(false); + var trigger = new SeamTrigger(); + east.Command.Pull(trigger); + Assert.Equal(0, trigger.Pulled); + for (var step = 0; step < 10; step++) arena.Update(SeamDt); + Assert.Equal((1, 0), (trigger.Pulled, trigger.Released)); + + east.Command.Release(trigger); + for (var step = 0; step < 10; step++) arena.Update(SeamDt); + Assert.Equal((1, 1), (trigger.Pulled, trigger.Released)); + } + + [Fact] + public void TractorIsHeld() + { + var (arena, east, _) = SeamPair(false); + east.Command.Tractor = 1; + for (var step = 0; step < 5; step++) + { + east.TractorCommand = 0; // a disturbance between steps: the held command lands again at every step + arena.Update(SeamDt); + Assert.Equal(1f, east.TractorCommand); + } + east.Command.Tractor = .25f; + arena.Update(SeamDt); + Assert.Equal(.25f, east.TractorCommand); + } +} diff --git a/tests/Aetheria.Shared.Tests/SteeringTests.cs b/tests/Aetheria.Shared.Tests/SteeringTests.cs index 71dbfff75..08c0a8958 100644 --- a/tests/Aetheria.Shared.Tests/SteeringTests.cs +++ b/tests/Aetheria.Shared.Tests/SteeringTests.cs @@ -88,8 +88,8 @@ public sealed class SteeringTests { ship.Direction = direction; var expected = Steering.Toward(ship, aim); - Assert.Equal(expected, held.Demand(ship, aim, axis)); - Assert.Equal(expected, latched.Demand(ship, aim, axis)); + Assert.Equal(expected, held.Demand(aim, axis).Resolve(ship)); + Assert.Equal(expected, latched.Demand(aim, axis).Resolve(ship)); } } @@ -102,9 +102,9 @@ public sealed class SteeringTests foreach (var aim in new[] { float2(0, 1), float2(1, 0), float2(-1, -1) }) { - Assert.Equal(.25f, helm.Demand(ship, aim, .25f)); - Assert.Equal(1f, helm.Demand(ship, aim, 3f)); - Assert.Equal(-1f, helm.Demand(ship, aim, -3f)); + Assert.Equal(.25f, helm.Demand(aim, .25f).Resolve(ship)); + Assert.Equal(1f, helm.Demand(aim, 3f).Resolve(ship)); + Assert.Equal(-1f, helm.Demand(aim, -3f).Resolve(ship)); } helm.Toggle();