Merge eureka/aetheria-arena-sim-rate h6: the AI releases a held automatic gun it no longer commands
Soul pass 4 (aetheria-arena:verdict:cut-sim-rate.s4) holds all three promises; suite 2822/2822 on Yggdrasil. Unity compile and feel unproven. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
8ca5425fcc
6 changed files with 187 additions and 4 deletions
|
|
@ -47,9 +47,9 @@ public class CombatState : BaseState
|
|||
// only a contact a member tracks has a track to fly on, so an untracked target is no target here.
|
||||
var view = _agent.View;
|
||||
var target = view.TargetOf(_agent.Ship);
|
||||
if (target == null) return;
|
||||
if (target == null) { ReleaseUnwanted(); return; }
|
||||
var track = view.TrackOf(_agent.Ship, target.Value);
|
||||
if (track == null) return;
|
||||
if (track == null) { ReleaseUnwanted(); return; }
|
||||
|
||||
_availableGroups.Clear();
|
||||
_availableLockingWeapons.Clear();
|
||||
|
|
@ -88,7 +88,9 @@ public class CombatState : BaseState
|
|||
view.Designated(_agent.Ship, weapon, target.Value) &&
|
||||
!view.Refuses(_agent.Ship, weapon, target.Value) &&
|
||||
(weapon is ConstantWeapon ||
|
||||
weapon is InstantWeapon instantWeapon && instantWeapon.CanFire))
|
||||
// A held AutoWeapon stays a candidate between its rounds (it is cooling because it is firing), so the
|
||||
// group it belongs to is judged by the same Fires verdict below, not dropped for cooling down.
|
||||
weapon is InstantWeapon instantWeapon && (instantWeapon.CanFire || weapon is AutoWeapon && weapon.Firing)))
|
||||
{
|
||||
if(weapon is LockWeapon lockWeapon)
|
||||
_availableLockingWeapons.Add(lockWeapon);
|
||||
|
|
@ -151,6 +153,7 @@ public class CombatState : BaseState
|
|||
_agent.Ship.Command.Release(weapon);
|
||||
}
|
||||
}
|
||||
ReleaseUnwanted();
|
||||
_agent.Ship.Command.Aim = toTarget;
|
||||
_agent.Ship.Command.Turn = TurnCommand.Toward(toTarget.xz);
|
||||
_agent.Accelerate(movementDirection * _agent.TopSpeed, true);//selectedGroup >= 0);
|
||||
|
|
@ -158,6 +161,18 @@ public class CombatState : BaseState
|
|||
// Fire charged guns!
|
||||
}
|
||||
|
||||
public override void OnExitState() => ReleaseUnwanted();
|
||||
|
||||
// The AI's trigger level for an AutoWeapon is what it commands this step: the weapon holds a down trigger until it is
|
||||
// released, so every held one this step has no command for is let go, whether the target is gone, untracked, out of reach,
|
||||
// its group is not the pick, or Combat has ended. A plain gun is tapped and lets go of itself.
|
||||
private void ReleaseUnwanted()
|
||||
{
|
||||
var command = _agent.Ship.Command;
|
||||
foreach (var weapon in _agent.Ship.Weapons)
|
||||
if (weapon is AutoWeapon && weapon.Firing && !command.Commands(weapon)) command.Release(weapon);
|
||||
}
|
||||
|
||||
// Overdrive to close a distant target ahead, through the same consumable gate as the vent (Agent.VentOnTrack).
|
||||
private void UseConsumables(float3 toTarget, float targetDistance)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -94,6 +94,14 @@ public sealed class PilotCommand
|
|||
// 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;
|
||||
|
||||
// Whether this pilot has commanded a trigger, pull or release, for `behavior` this step.
|
||||
public bool Commands(ActivatedBehavior behavior)
|
||||
{
|
||||
foreach (var trigger in Triggers)
|
||||
if (ReferenceEquals(trigger.Behavior, behavior)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether the last trigger this pilot commanded for `behavior` this step is a pull.
|
||||
public bool Pulls(ActivatedBehavior behavior)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -43,7 +43,10 @@ public sealed partial class RunStartTests
|
|||
// order, ties by shot id, so every commit of a step precedes its applies (it was commit then apply, shot by shot, in fire
|
||||
// order). The two parts measured: commit-then-apply split alone a1318ee0, with the arrival sort 55ecd858; the old walk at the
|
||||
// h5 head, rebased onto master, still measures 108c93dc, so the rebase and the fire-mode property moved nothing.
|
||||
private const string TwoAiPinnedDigest = "55ecd858875ca6a5418d6badd40e71d9bbd363ca782843005483a308bcf07d74";
|
||||
// Repinned a seventh time, once (h6): Combat is the one owner of the AI's trigger level for an AutoWeapon, and lets go of a held one it
|
||||
// no longer commands (target gone, untracked, another group the pick, Combat ended), where a held gun fired on until a later
|
||||
// step selected its group (55ecd858 before).
|
||||
private const string TwoAiPinnedDigest = "85e85c46cfc66693c819dd294ad02f3cd0eae5486c8f152a97e2e2787f854274";
|
||||
private const float TwoAiSeconds = 60f;
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -1041,6 +1041,124 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
if (shots == 0) Assert.Equal(60, readyTicks);
|
||||
}
|
||||
|
||||
// ==== The AI's trigger level for an AutoWeapon ====
|
||||
|
||||
public enum Loss { NoTarget, Untracked }
|
||||
|
||||
private static void LoseTheTarget(Rig r, Loss loss)
|
||||
{
|
||||
if (loss == Loss.NoTarget) r.Shooter.SetTarget(TargetRef.None);
|
||||
else r.Shooter.VisibleEntities.Remove(r.Target);
|
||||
}
|
||||
|
||||
// A pilot that pulled an AutoWeapon and then loses its target, or its track of it, lets the trigger go: the weapon holds
|
||||
// a down trigger until told otherwise, and an early return that issued no release left it firing on at nothing. Through
|
||||
// the whole agent (Combat, then the transition out of it), and through Combat's Update alone. Kills: Combat never
|
||||
// releasing, releasing only when the target is gone (not when it is untracked), releasing a step late.
|
||||
[Theory]
|
||||
[InlineData(Loss.NoTarget)]
|
||||
[InlineData(Loss.Untracked)]
|
||||
public void ACombatPilotThatLosesItsTargetReleasesItsHeldAutoWeaponAndItFiresNoMore(Loss loss)
|
||||
{
|
||||
var r = Build(auto: true);
|
||||
var gun = (InstantWeapon) r.Gun.Weapon;
|
||||
var rounds = 0;
|
||||
gun.OnFire += _ => rounds++;
|
||||
r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
|
||||
const float dt = 1f / 30f;
|
||||
for (var i = 0; i < 90; i++) r.Zone.Update(dt);
|
||||
Assert.True(gun.Firing, "control: the pilot holds the gun while it has a target");
|
||||
Assert.True(rounds >= 2, $"control: {rounds} rounds in 3 s at a 1 s cooldown");
|
||||
|
||||
LoseTheTarget(r, loss);
|
||||
r.Zone.Update(dt);
|
||||
var after = rounds;
|
||||
Assert.False(gun.Firing, "the trigger is let go the step the target is lost");
|
||||
for (var i = 0; i < 90; i++) r.Zone.Update(dt);
|
||||
Assert.Equal(after, rounds);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Loss.NoTarget)]
|
||||
[InlineData(Loss.Untracked)]
|
||||
public void CombatUpdateReleasesAHeldAutoWeaponWhenItHasNoTargetOrNoTrack(Loss loss)
|
||||
{
|
||||
var r = Build(auto: true);
|
||||
var gun = (InstantWeapon) r.Gun.Weapon;
|
||||
var combat = new CombatState(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
|
||||
gun.Activate();
|
||||
Assert.True(gun.Firing, "control: the trigger is down");
|
||||
LoseTheTarget(r, loss);
|
||||
|
||||
combat.Update(.1f);
|
||||
r.Zone.Update(.1f);
|
||||
|
||||
Assert.False(gun.Firing);
|
||||
}
|
||||
|
||||
// Combat's end lets go of what it held, wherever the end comes from.
|
||||
[Fact]
|
||||
public void CombatExitReleasesAHeldAutoWeapon()
|
||||
{
|
||||
var r = Build(auto: true);
|
||||
var gun = (InstantWeapon) r.Gun.Weapon;
|
||||
var combat = new CombatState(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
|
||||
gun.Activate();
|
||||
Assert.True(gun.Firing, "control: the trigger is down");
|
||||
|
||||
combat.OnExitState();
|
||||
r.Zone.Update(.1f);
|
||||
|
||||
Assert.False(gun.Firing);
|
||||
}
|
||||
|
||||
// The target stands but its group is no longer a candidate (it moved out of the gun's reach): the held gun is let go
|
||||
// the same step, not left to fire at the last aim. Kills: Combat's release sweep after its group pick being lost.
|
||||
[Fact]
|
||||
public void CombatReleasesAHeldAutoWeaponWhoseTargetMovesOutOfReach()
|
||||
{
|
||||
var r = Build(auto: true);
|
||||
var gun = (InstantWeapon) r.Gun.Weapon;
|
||||
var rounds = 0;
|
||||
gun.OnFire += _ => rounds++;
|
||||
r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
|
||||
const float dt = 1f / 30f;
|
||||
for (var i = 0; i < 90; i++) r.Zone.Update(dt);
|
||||
Assert.True(gun.Firing, "control: the pilot holds the gun while the target is in reach");
|
||||
|
||||
r.Target.Position = r.Shooter.Position + float3(0, 0, 500);
|
||||
r.Zone.Update(dt);
|
||||
var after = rounds;
|
||||
|
||||
Assert.False(gun.Firing);
|
||||
for (var i = 0; i < 30; i++) r.Zone.Update(dt);
|
||||
Assert.Equal(after, rounds);
|
||||
}
|
||||
|
||||
// A held AutoWeapon is cooling between its rounds because it is firing: its group is not dropped for cooling down, or
|
||||
// the trigger would be let go and pressed again at step granularity. Kills: releasing a held gun whose cooldown is
|
||||
// running while its target stands.
|
||||
[Fact]
|
||||
public void CombatKeepsAnAutoWeaponHeldThroughItsCooldownWhileTheTargetStands()
|
||||
{
|
||||
var r = Build(auto: true);
|
||||
var gun = (InstantWeapon) r.Gun.Weapon;
|
||||
var rounds = 0;
|
||||
gun.OnFire += _ => rounds++;
|
||||
r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
|
||||
const float dt = 1f / 30f;
|
||||
for (var i = 0; i < 60; i++) r.Zone.Update(dt);
|
||||
var cooling = 0;
|
||||
for (var i = 0; i < 240; i++)
|
||||
{
|
||||
r.Zone.Update(dt);
|
||||
Assert.True(gun.Firing, $"step {i}: the held gun was let go with its target standing");
|
||||
if (!gun.CanFire) cooling++;
|
||||
}
|
||||
Assert.True(cooling > 100, $"{cooling} of 240 steps spent cooling");
|
||||
Assert.InRange(rounds, 8, 10);
|
||||
}
|
||||
|
||||
// Kills: Trigger that leaves _burstStarted true from the previous burst, so the next burst reads as started
|
||||
// (and ready) before its first round has flown.
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -967,6 +967,32 @@ public sealed class LoadoutTests : IDisposable
|
|||
Assert.Equal(1f, effect.Evaluate(undeclared), 3);
|
||||
}
|
||||
|
||||
// Creating a behaviour reads the fire mode from the data wherever the weapon is carried: a consumable's automatic gun is an
|
||||
// AutoWeapon that repeats, every other kind a plain semi-automatic InstantWeapon. Kills: the consumable CreateInstance
|
||||
// ignoring Automatic.
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void CreatingABehaviourFromAConsumableHonoursAutomatic(bool automatic)
|
||||
{
|
||||
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
||||
var maker = cache.RefOf(cache.GetByName<Faction>("Maker"));
|
||||
var hullData = cache.GetByName<HullData>("Skiff");
|
||||
InstantWeaponData gun = automatic ? new AutoWeaponData() : new InstantWeaponData();
|
||||
var consumableRef = cache.Upsert(new ConsumableItemData { Name = "Dose", Duration = 10, Behaviors = { gun } });
|
||||
cache.FlushAsync().Wait();
|
||||
|
||||
var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
||||
var hullItem = (EquippableItem) items.CreateInstance(items.CreateLot(hullData, maker, .5f));
|
||||
var ship = new Ship(items, null, hullItem, new EntitySettings());
|
||||
var consumableItem = (ConsumableItem) items.CreateInstance(items.CreateLot(cache.Get(consumableRef), maker, .5f));
|
||||
|
||||
var behavior = new ConsumableItemEffect(consumableItem, ship).Behaviors.Single();
|
||||
|
||||
Assert.True(automatic == (behavior is AutoWeapon), $"automatic {automatic}: created a {behavior.GetType().Name}");
|
||||
Assert.IsAssignableFrom<InstantWeapon>(behavior);
|
||||
}
|
||||
|
||||
// F4: CreateLot(product) fills each of the design's roles from the product's own per-role spread
|
||||
// (FactionProductData.Roles), not the design-wide default.
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -83,4 +83,17 @@ public sealed class SemiAutoCatalogTests
|
|||
Assert.True(auto.Automatic);
|
||||
Assert.Empty(InstantWeaponData.SemiAutoFaults(new ItemData[] { Gun("Auto", auto), new ConsumableItemData { Name = "Auto dose", Behaviors = { auto } } }));
|
||||
}
|
||||
|
||||
// The floor is the 0.1 s constant itself, not a function of the tick: a 0.08 s semi-auto gun is refused at a step length
|
||||
// whose own multiple (2 x GameStep at 30 Hz is 0.067 s) would let it through, and refused at the constant's edge from
|
||||
// below however the step moves. Kills: SemiAutoFaults comparing against a step-derived value.
|
||||
[Fact]
|
||||
public void TheFloorIsTheConstantNotAMultipleOfTheStep()
|
||||
{
|
||||
Assert.NotEqual(InstantWeaponData.ShortestSemiAutoCooldown, SimClock.GameStep * 2f);
|
||||
Assert.True(SimClock.GameStep * 2f < .08f, "fixture: a step-derived floor would let 0.08 s through");
|
||||
Assert.Single(InstantWeaponData.SemiAutoFaults(new[] { Gun("Eighty", new InstantWeaponData { Cooldown = Const(.08f) }) }));
|
||||
Assert.Single(InstantWeaponData.SemiAutoFaults(new[] { Gun("Just under", new InstantWeaponData { Cooldown = Const(.0999f) }) }));
|
||||
Assert.Empty(InstantWeaponData.SemiAutoFaults(new[] { Gun("Just over", new InstantWeaponData { Cooldown = Const(.1001f) }) }));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue