Aetheria/Assets/Scripts/ServerShared/Agents/States/Combat.cs
Meta Vens bf32a82e32 CombatState derives its optimum range once the weapons' stats are known, and re-derives on loadout change
A zone-spawned ship's CombatState sampled in its constructor, before any weapon had evaluated its stats,
so the optimum range read 0 and never moved. It now marks the optimum stale on construction, on any
equipment add/remove and on a weapon's online change, and samples at the next Update once every active
weapon reports StatsKnown. ConsumableAiTests build the agent through Zone.Admit(piloted: true) and read
the optimum without re-sampling; new rows pin the hold scope, hull-heading source, mixed paints, sight
independence and the optimum across two guns and a refit.

Not yet verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:03:50 +02:00

211 lines
No EOL
9.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using UniRx;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using float2x2 = CultMath.float2x2;
public class CombatState : BaseState
{
private const int DPS_SAMPLE_COUNT = 32;
// The Strength of one Track paint in this ship's own IncomingPaints: a fire-control emitter tracking this ship
// that hard (rulings lock-warning-strength, sensor-stat-set item 4, launcher-lock-solution-quality).
private const float LockAlarm = .5f;
// Overdrive only to close a target farther than this many optimum ranges, while heading within this many degrees of it.
private const float OverdriveRangeFactor = 2f;
private const float OverdriveArcDegrees = 15f;
private float _optimumRange;
private bool _optimumStale = true;
private readonly List<(int index, float dps)> _availableGroups = new List<(int index, float dps)>();
private readonly List<LockWeapon> _availableLockingWeapons = new List<LockWeapon>();
public CombatState(Agent agent) : base(agent)
{
foreach (var item in _agent.Ship.Equipment) Watch(item);
_agent.Ship.Equipment.ObserveAdd().Subscribe(add => { Watch(add.Value); _optimumStale = true; });
_agent.Ship.Equipment.ObserveRemove().Subscribe(_ => _optimumStale = true);
}
// The optimum is a function of the loadout's evaluated stats, so it is re-derived whenever a weapon comes or goes
// or changes online state, and only once every weapon that can run has evaluated its stats (a weapon evaluates on
// its first active tick, after it comes online): a ship the zone just spawned samples over live stats, not zeros.
private void Watch(EquippedItem item)
{
if (item.Behaviors.OfType<Weapon>().Any()) item.Online.Subscribe(_ => _optimumStale = true);
}
public override void Update(float delta)
{
if (_optimumStale && _agent.Ship.Weapons.All(w => w.StatsKnown || !w.Item.Active.Value))
{
SampleDps();
_optimumStale = false;
}
var target = _agent.Ship.Target.Value.Entity;
if (target == null) return;
_availableGroups.Clear();
_availableLockingWeapons.Clear();
var toTarget = target.Position - _agent.Ship.Position;
var targetDistance = length(toTarget.xz);
// Cut 2 (docs/fire-control-cut.md): aim at whichever revealed weapon on the target hits hardest at
// this range, or clear the aim point when nothing qualifies -- the same predicate
// (FireControl.IsRevealed) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it
// believes the target has, and how hard each hits, come from its book's claims, not the target's live gear
// (aetheria-ew, classify-from-belief; ew-claim-weapon-measure-ruled: the claimed design's rated figures); the
// reveal's Item is the aim and stays the truth.
EquippedItem bestTargetItem = null;
var bestTargetDps = float.MinValue;
foreach (var reveal in _agent.Ship.RevealedWeapons(target))
{
if (!FireControl.IsRevealed(_agent.Ship, reveal.Item)) continue;
var dps = reveal.Claimed.Behaviors.OfType<WeaponData>().First().RatedRangeDamagePerSecond(targetDistance);
if (dps > bestTargetDps)
{
bestTargetDps = dps;
bestTargetItem = reveal.Item;
}
}
_agent.Ship.TrySelectTargetItem(bestTargetItem);
for (var i = 0; i < _agent.Ship.WeaponGroups.Length; i++)
{
var group = _agent.Ship.WeaponGroups[i];
var dps = 0f;
foreach (var weapon in group.weapons)
// The range test is FireControl's (Designated), and a weapon Solve refuses adds nothing to a group's
// damage.
if (weapon.Item.Online.Value &&
FireControl.Designated(weapon, _agent.Ship, target, out _) &&
!FireControl.Refuses(weapon, _agent.Ship) &&
(weapon is ConstantWeapon ||
weapon is InstantWeapon instantWeapon && instantWeapon.CanFire))
{
if(weapon is LockWeapon lockWeapon)
_availableLockingWeapons.Add(lockWeapon);
else
dps += weapon.RangeDamagePerSecond(targetDistance);
}
if (dps > .1) _availableGroups.Add((i, dps));
}
foreach(var w in _availableLockingWeapons) w.Activate();
UseConsumables(toTarget, targetDistance);
var selectedGroup = -1;
var maxDps = Single.MinValue;
foreach (var group in _availableGroups)
{
if (group.dps > maxDps)
{
maxDps = group.dps;
selectedGroup = group.index;
}
}
var targetRight = target.Direction.Rotate(ItemRotation.Clockwise);
var optimumRangeDelta = abs(_optimumRange - targetDistance);
var directionToTarget = normalize(toTarget.xz);
var targetPortAlignment = dot(targetRight, directionToTarget);
var targetForeAlignment = dot(-target.Direction, directionToTarget);
var forwardness = saturate(optimumRangeDelta / _agent.Settings.AgentMaxForwardDistance) * _agent.Settings.AgentForwardLerp;
if (targetForeAlignment > 0) forwardness = lerp(forwardness, 1, pow(targetPortAlignment, 2));
var movementDirection = normalize(lerp(
directionToTarget.Rotate(targetPortAlignment > 0 ? ItemRotation.Clockwise : ItemRotation.CounterClockwise),
targetDistance > _optimumRange ? directionToTarget : -directionToTarget, forwardness));
if (selectedGroup >= 0)
{
var testWeapon = _agent.Ship.WeaponGroups[selectedGroup].weapons.First();
if(testWeapon.Velocity > 1)
{
// Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with FireControl.Fire's
// own snapshot and the roll's deviation measurement -- two separate predictions here and in the
// roll would let an AI aim at one point and get judged for missing a different one.
var predictedPosition = FireControl.PredictedIntercept(testWeapon, _agent.Ship, target);
predictedPosition.y = target.RideHeight(predictedPosition.xz);
toTarget = normalize(predictedPosition - _agent.Ship.Position);
}
// Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a
// verdict. FireControl.AgentFires is the AI's "worth it" heuristic for a weapon that hits what it
// fires at, and "the target is designated" for a fused one.
foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons)
{
if (FireControl.AgentFires(weapon, _agent.Ship, target))
weapon.Activate();
else if (weapon.Firing)
weapon.Deactivate();
}
}
_agent.Ship.Aim = toTarget;
_agent.Ship.Turn = Steering.Toward(_agent.Ship, toTarget.xz);
_agent.Accelerate(movementDirection * _agent.TopSpeed, true);//selectedGroup >= 0);
// Fire charged guns!
}
// The AI plays the player's rule on the player's information: vent when one Track paint on this ship passes half,
// otherwise overdrive to close a distant target ahead. It reads only this ship's own IncomingPaints. Both go
// through TryActivateConsumable, and a ship carrying neither does nothing.
private void UseConsumables(float3 toTarget, float targetDistance)
{
var ship = _agent.Ship;
if (ship.IncomingPaints.Any(p => p.Mode == PaintMode.Track && p.Strength > LockAlarm))
Use(b => b is VapourDumpData);
else if (targetDistance > OverdriveRangeFactor * _optimumRange &&
dot(normalize(ship.Direction), normalize(toTarget.xz)) > cos(radians(OverdriveArcDegrees)))
Use(b => b is ThrottleLockData);
}
// One of a kind at a time: a Stackable design would otherwise be drained once per tick.
private void Use(Predicate<BehaviorData> kind)
{
if (_agent.Ship.HasActiveConsumable(kind)) return;
var consumable = _agent.Ship.FirstCarriedConsumable(kind);
if (consumable != null) _agent.Ship.TryActivateConsumable(consumable);
}
private void SampleDps()
{
var minRange = Single.MaxValue;
var maxRange = Single.MinValue;
foreach (var weapon in _agent.Ship.Weapons)
{
if (weapon.MinRange < minRange)
minRange = weapon.MinRange;
if (weapon.Range > maxRange)
maxRange = weapon.Range;
}
var offset = _agent.ItemManager.Random.NextFloat((maxRange - minRange) / DPS_SAMPLE_COUNT);
var optimumDPS = 0f;
for (int i = 0; i < DPS_SAMPLE_COUNT; i++)
{
var range = offset + minRange + (maxRange - minRange) * ((float) i / DPS_SAMPLE_COUNT);
float dps = 0;
foreach (var weapon in _agent.Ship.Weapons)
{
if (weapon.Item.Online.Value)
dps += weapon.RangeDamagePerSecond(range);
}
// Multiply by range raised to a (sublinear) exponent to bias DPS preference towards longer ranges
dps *= pow(range, _agent.Settings.AgentRangeExponent);
if (dps > optimumDPS)
{
optimumDPS = dps;
_optimumRange = range;
}
}
}
}