Faction play 1: typed doctrine on Faction and one flight per faction per zone

FactionDoctrine/RoleDoctrine (Faction key 17), Flight as the only writer of
members' Target and Task, hail/grace/compliance, FollowTask/FollowState,
agent removal on Entities removal, doctrine-catalog command, FlightTests.
Not yet built or run; verification follows in later commits.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-08 14:18:41 +02:00
commit 8f42265de3
24 changed files with 812 additions and 42 deletions

View file

@ -23,11 +23,14 @@ public class Agent
public Ship Ship { get; }
public ItemManager ItemManager { get; }
public GameplaySettings Settings { get; }
// How this pilot fights: its faction's doctrine for its role (Zone.CreateAgent resolves it).
public RoleDoctrine Doctrine { get; }
public float TopSpeed => _velocityLimit?.Limit ?? 100;
public Agent(Ship ship)
public Agent(Ship ship, RoleDoctrine doctrine)
{
Ship = ship;
Doctrine = doctrine;
ItemManager = Ship.ItemManager;
Settings = Ship.ItemManager.GameplaySettings;

View file

@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using static CultMath.math;
// One flight per (zone, faction) of piloted ships; unaffiliated ships form one flight of their own. The flight alone
// decides what its members do: which hostile each engages (Ship.Target) and what each does otherwise (Agent.Task).
// The agent graph executes those two and decides nothing. The faction's doctrine (FactionDoctrine) holds the numbers.
//
// The picture is the union of the members' VisibleEnemies. A track passes through phases the flight owns, runtime only:
// None, Hailing (grace running, one hail spoken), Complied (slow enough at the grace's end: followed, not engaged) and
// Engaged. A member still fires only at what its own VisibleEntities hold (FireControl), so the shared picture never
// lets it fire at what it cannot see.
public class Flight
{
public enum Phase { None, Hailing, Complied, Engaged }
private class Track
{
public Phase Phase;
public double Start;
}
private class Member
{
public Agent Agent;
public PatrolOrbitsTask Patrol;
}
private readonly FactionDoctrine _doctrine;
private readonly GameplaySettings _settings;
private readonly List<Member> _members = new List<Member>();
private readonly Dictionary<Entity, Track> _tracks = new Dictionary<Entity, Track>();
private double _time;
// No doctrine fights as FactionDoctrine.Default; a doctrine that leaves a role unauthored gets that role's default.
public Flight(FactionDoctrine doctrine, GameplaySettings settings)
{
var fallback = FactionDoctrine.Default(settings);
_doctrine = doctrine ?? fallback;
_settings = settings;
Combatant = _doctrine.Combatant ?? fallback.Combatant;
}
public RoleDoctrine Combatant { get; }
public int Count => _members.Count;
public bool Contains(Agent agent) => _members.Any(m => m.Agent == agent);
public Phase PhaseOf(Entity track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None;
public void Join(Agent agent, PatrolOrbitsTask circuit)
{
_members.Add(new Member { Agent = agent, Patrol = circuit });
agent.Task = circuit;
}
public void Leave(Agent agent) => _members.RemoveAll(m => m.Agent == agent);
public void Update(float dt)
{
_time += dt;
var picture = _members.SelectMany(m => m.Agent.Ship.VisibleEnemies).Distinct().ToList();
foreach (var gone in _tracks.Keys.Where(t => !picture.Contains(t)).ToList()) _tracks.Remove(gone);
foreach (var entity in picture)
{
if (!_tracks.TryGetValue(entity, out var track)) _tracks[entity] = track = new Track();
Advance(entity, track);
}
foreach (var member in _members) Order(member, picture);
}
// A grudge is a hostile IFF override that any member holds on the track.
private bool Grudged(Entity entity) => _members.Any(m => m.Agent.Ship.IffOverride(entity) == true);
private bool Engageable(Entity entity, bool grudge)
{
switch (_doctrine.EngageOn)
{
case EngageOn.Never: return false;
case EngageOn.Detection: return true;
case EngageOn.Identified:
return grudge || _members.Any(m => m.Agent.Ship.EntityInfoGathered.TryGetValue(entity, out var info) &&
info >= _settings.TargetGearInfoThreshold);
case EngageOn.Provoked: return grudge;
case EngageOn.Trespass: return grudge || !(entity.PresencePermitted?.Value ?? true);
default: return false;
}
}
private void Advance(Entity entity, Track track)
{
if (track.Phase == Phase.Engaged) return;
var grudge = Grudged(entity);
if (!Engageable(entity, grudge))
{
track.Phase = Phase.None;
return;
}
// A grudge is already a quarrel: no grace to give.
if (grudge)
{
track.Phase = Phase.Engaged;
return;
}
switch (track.Phase)
{
case Phase.None:
if (_doctrine.Grace > 0)
{
track.Phase = Phase.Hailing;
track.Start = _time;
if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.SetMessage(_doctrine.Hail);
}
else track.Phase = Phase.Engaged;
break;
case Phase.Hailing:
if (_time >= track.Start + _doctrine.Grace)
track.Phase = _doctrine.ComplySpeed > 0 && length(entity.Velocity) < _doctrine.ComplySpeed
? Phase.Complied
: Phase.Engaged;
break;
case Phase.Complied:
if (length(entity.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged;
break;
}
}
private Member Nearest(Entity entity) =>
_members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - entity.Position.xz)).FirstOrDefault();
private void Order(Member member, List<Entity> picture)
{
var ship = member.Agent.Ship;
var seen = ship.VisibleEnemies;
// Engage: keep the current target while it stays engaged, else the nearest engaged track this member can see.
var engaged = picture.Where(e => _tracks[e].Phase == Phase.Engaged && seen.Contains(e)).ToList();
if (engaged.Count > 0)
{
var current = ship.Target.Value.Entity;
var pick = current != null && engaged.Contains(current)
? current
: engaged.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First();
Aim(ship, pick);
return;
}
Aim(ship, null);
var held = picture.Where(e => _tracks[e].Phase == Phase.Hailing || _tracks[e].Phase == Phase.Complied).ToList();
if (held.Count > 0)
{
var anchor = member.Agent.Task is FollowTask follow && held.Contains(follow.Anchor)
? follow.Anchor
: held.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First();
if (!(member.Agent.Task is FollowTask current) || current.Anchor != anchor)
member.Agent.Task = new FollowTask { Anchor = anchor, Standoff = Standoff(ship) };
}
else if (member.Agent.Task != member.Patrol)
member.Agent.Task = member.Patrol;
}
private static void Aim(Ship ship, Entity target)
{
var wanted = target == null ? TargetRef.None : new TargetRef(target);
if (!ship.Target.Value.Equals(wanted)) ship.SetTarget(wanted);
}
private float Standoff(Ship ship)
{
if (Combatant.HoldStandoff > 0) return Combatant.HoldStandoff;
return 1.1f * ship.Weapons.Select(w => w.Range).DefaultIfEmpty(0f).Max();
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5f6c82a74e5b4bc1b8c79e0d242e3ec2

View file

@ -1,23 +1,30 @@
using System.Linq;
using UniRx;
// The agent graph for a piloted ship. It executes the Task and Target its flight writes (Flight.cs) and decides
// neither: patrol and follow follow the Task kind, combat follows the Target.
public class Minion : Agent
{
public Minion(Ship ship) : base(ship)
public Minion(Ship ship, RoleDoctrine doctrine) : base(ship, doctrine)
{
var patrolState = new PatrolOrbitsState(this);
_rootState.AddTransition(patrolState,
() => Task is PatrolOrbitsTask,
() => patrolState.Task = Task as PatrolOrbitsTask);
var followState = new FollowState(this);
void Patrol() => patrolState.Task = Task as PatrolOrbitsTask;
_rootState.AddTransition(patrolState, () => Task is PatrolOrbitsTask, Patrol);
_rootState.AddTransition(followState, () => Task is FollowTask);
Ship.VisibleEnemies.ObserveAdd().Where(_ => Ship.Target.Value.IsNone).Subscribe(add => Ship.SetTarget(add.Value));
// A Task change switches the substate in one update, from wherever the pilot is in the old task. includeChildren
// reaches the sub-states (MoveToOrbit) that Update actually sits in.
patrolState.AddTransition(followState, () => Task is FollowTask, null, true, _rootState);
followState.AddTransition(patrolState, () => Task is PatrolOrbitsTask, Patrol, true, _rootState);
patrolState.AddTransition(_rootState, () => !(Task is PatrolOrbitsTask), null, true, _rootState);
followState.AddTransition(_rootState, () => !(Task is FollowTask), null, true, _rootState);
var combatState = new CombatState(this);
_rootState.AddTransition(combatState,
() => Ship.Target.Value.Entity != null, null, true, _rootState);
combatState.AddTransition(_rootState,
() => Ship.Target.Value.Entity == null, null, true, _rootState);
}
}
}

View file

@ -11,6 +11,7 @@ public class CombatState : BaseState
{
private const int DPS_SAMPLE_COUNT = 32;
private float _optimumRange;
public float OptimumRange => _optimumRange;
private readonly List<(int index, float dps)> _availableGroups = new List<(int index, float dps)>();
private readonly List<LockWeapon> _availableLockingWeapons = new List<LockWeapon>();
@ -118,7 +119,7 @@ public class CombatState : BaseState
// 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))
if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability))
weapon.Activate();
else if (weapon.Firing)
weapon.Deactivate();
@ -156,7 +157,7 @@ public class CombatState : BaseState
}
// Multiply by range raised to a (sublinear) exponent to bias DPS preference towards longer ranges
dps *= pow(range, _agent.Settings.AgentRangeExponent);
dps *= pow(range, _agent.Doctrine.RangeExponent);
if (dps > optimumDPS)
{

View file

@ -0,0 +1,26 @@
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
// Hold a standoff from the anchor: match its velocity, and close or open the gap in proportion to the error.
public class FollowState : BaseState
{
public FollowState(Agent agent) : base(agent) { }
public override void Update(float delta)
{
var task = _agent.Task as FollowTask;
if (task?.Anchor == null) return;
var toAnchor = task.Anchor.Position.xz - _agent.Ship.Position.xz;
var distance = length(toAnchor);
var direction = distance > 0 ? toAnchor / distance : _agent.Ship.Direction;
// Positive error: too far, close. Negative: too near, open.
var error = distance - task.Standoff;
var pull = direction * (float) sign(error) * saturate(abs(error) / max(task.Standoff, 1f));
var desired = task.Anchor.Velocity + pull * _agent.TopSpeed;
_agent.Ship.Aim = float3(direction.x, 0, direction.y);
_agent.Accelerate(desired);
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5cf86244ca6648a99f304b061b66348a

View file

@ -29,6 +29,7 @@ public enum TaskType
Tow,
Defend,
Attack,
Explore
Explore,
Follow
}

View file

@ -0,0 +1,11 @@
using System;
using MessagePack;
// Keep station on an entity at a standoff: the flight hailing a track holds its members here (Flight.cs).
// Runtime only, like every agent task.
public class FollowTask : AgentTask
{
[IgnoreMember] public Entity Anchor;
[IgnoreMember] public float Standoff;
[IgnoreMember] public override TaskType Type => TaskType.Follow;
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6bab02326ff64e29ad2b81f0fc1483b5

View file

@ -80,7 +80,7 @@ public class TurretController : Behavior, IInitializableBehavior
{
// The same per-weapon decision Combat.cs makes; the range test is part of it (a shot out of range
// is not designated and prices at zero).
if (FireControl.AgentFires(x, Entity, target))
if (FireControl.AgentFires(x, Entity, target, Entity.ItemManager.GameplaySettings.AgentMinHitProbability))
{
x.Activate();
}

View file

@ -57,6 +57,12 @@ public class Faction
[Inspectable, JsonProperty("bossMusic"), Key(15)]
public uint BossMusic;
// Key 16 is reserved for Relations (operator ruling faction-relations-field).
// The faction's rules of engagement; null fights as FactionDoctrine.Default.
[Inspectable, JsonProperty("doctrine"), Key(17)]
public FactionDoctrine Doctrine;
}
[CultDocument("aetheria.namefile", "1"), Inspectable, MessagePackObject]

View file

@ -0,0 +1,74 @@
/* 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 GameCult.Caching;
using MessagePack;
using Newtonsoft.Json;
// What lets a flight open fire. The flight reads it per track; nothing else does.
public enum EngageOn
{
// Any hostile the flight's members can see.
Detection,
// A hostile whose gear one member has resolved (EntityInfoGathered at or past TargetGearInfoThreshold).
Identified,
// Only a hostile that a member holds a hostile IFF override on (a grudge).
Provoked,
// A hostile whose presence this faction's zone does not permit.
Trespass,
// Never, not even for a grudge.
Never
}
// A faction's rules of engagement (operator ruling faction-doctrine-typed): flight-level fields plus one RoleDoctrine
// per role. Keys 5-8 are reserved for faction-play-2, keys 9-10 for faction-play-3. A faction with no doctrine gets
// Default, which fights as the agents did before doctrine existed.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class FactionDoctrine
{
[Inspectable, JsonProperty("engageOn"), Key(0)]
public EngageOn EngageOn;
// Seconds between a flight hailing a track and opening fire on it. Zero fires at once, with no hail.
[Inspectable, JsonProperty("grace"), Key(1)]
public float Grace;
[Inspectable, JsonProperty("hail"), Key(2)]
public string Hail;
// A hailed track slower than this at the end of the grace counts as complying and is followed, not engaged.
// Zero means nothing complies.
[Inspectable, JsonProperty("complySpeed"), Key(3)]
public float ComplySpeed;
[Inspectable, JsonProperty("combatant"), Key(4)]
public RoleDoctrine Combatant;
public static FactionDoctrine Default(GameplaySettings settings) => new FactionDoctrine
{
EngageOn = EngageOn.Detection,
Combatant = new RoleDoctrine
{
RangeExponent = settings.AgentRangeExponent,
MinHitProbability = settings.AgentMinHitProbability
}
};
}
// How one role fights. Combatant is the only role so far.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class RoleDoctrine
{
// Bias of the preferred engagement range towards longer ranges (CombatState's optimum range).
[Inspectable, JsonProperty("rangeExponent"), Key(0)]
public float RangeExponent;
// A weapon fires only when its hit probability reaches this.
[Inspectable, JsonProperty("minHitProbability"), Key(1)]
public float MinHitProbability;
// Distance a flight holds from a track it is hailing or following. Zero means 1.1 x the member's longest weapon range.
[Inspectable, JsonProperty("holdStandoff"), Key(2)]
public float HoldStandoff;
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3f9a1c5e7b2d4e68a0c4d1b9e7f25a31

View file

@ -814,6 +814,9 @@ public abstract class Entity
else _iffOverrides.Remove(other);
}
// The override this entity holds on another, or null when it holds none (the derived rule decides).
public bool? IffOverride(Entity other) => _iffOverrides.TryGetValue(other, out var hostile) ? hostile : (bool?) null;
public bool IsHostileTo(Entity other, bool recursive = false)
{
// An override decides only the stance of the entity that holds it. The recursive=true calls

View file

@ -101,12 +101,13 @@ public static class FireControl
// target is designated, whatever the arc: the round is sent out of arc on purpose (Solve). An agent never
// fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and
// never one Solve refuses: a refused weapon is not a weapon it can use.
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target)
// The hit-probability threshold is the shooter's doctrine (RoleDoctrine.MinHitProbability), or the turret's global.
public static bool AgentFires(Weapon weapon, Entity shooter, Entity target, float minHitProbability)
{
// A mine layer lays only at a designated target in range, never at nothing and never on a hit probability.
if (IsMineLayer(weapon)) return Solve(weapon, weapon.Item, shooter, target).Designated;
if (FuseOf(weapon.Item, out _) == null)
return HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability;
return HitProbability(weapon, shooter, target) >= minHitProbability;
var solution = Solve(weapon, weapon.Item, shooter, target);
return solution.Designated && solution.Outcome != FireOutcome.Refused;
}

View file

@ -38,6 +38,11 @@ public class Zone
private ItemManager _itemManager;
private double _time;
public List<Agent> Agents = new List<Agent>();
// One flight per faction, created on its first piloted join; unaffiliated ships share Unaffiliated. A flight owns
// what its members target and do (Agents/Flight.cs).
private static readonly Faction Unaffiliated = new Faction();
private readonly Dictionary<Faction, Flight> _flights = new Dictionary<Faction, Flight>();
public IEnumerable<Flight> Flights => _flights.Values;
// Cut 2 (docs/mining-cut.md): chunk wear, keyed by chunk. Zone.Wear is the only writer; ChunkExists and
// ChunkRadius are the only readers besides the pack/unpack round trip below.
@ -106,6 +111,16 @@ public class Zone
// ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone.
Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); }));
// Leaving Entities, by death, docking or a transfer, is leaving the zone's pilots: the agent and its flight
// membership go with the ship, so nothing keeps flying a hull that is not here.
Entities.ObserveRemove().Subscribe(remove =>
{
var agent = Agents.FirstOrDefault(a => a.Ship == remove.Value);
if (agent == null) return;
Agents.Remove(agent);
foreach (var flight in _flights.Values) flight.Leave(agent);
});
Targets.Add(new EntityTargets());
foreach (var orbit in pack.Orbits)
@ -173,10 +188,13 @@ public class Zone
private Agent CreateAgent(Ship ship)
{
var agent = new Minion(ship);
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 task = new PatrolOrbitsTask();
task.Circuit = Orbits.OrderBy(_ => _itemManager.Random.NextFloat()).Take(4).Select(x => x.Key).ToArray();
agent.Task = task;
flight.Join(agent, task);
return agent;
}
@ -218,7 +236,8 @@ public class Zone
orbit.Value.Velocity = (orbit.Value.Position - orbit.Value.PreviousPosition) / deltaTime;
}
foreach(var agent in Agents)
foreach (var flight in _flights.Values) flight.Update(deltaTime);
foreach(var agent in Agents.ToArray())
agent.Update(deltaTime);
foreach (var entity in Entities.ToArray()) entity.Update(deltaTime);

View file

@ -273,7 +273,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
var r = Build(SideThenForward);
var group = r.Shooter.WeaponGroups.Single(g => g.weapons.Count == 2).weapons;
Assert.True(ReferenceEquals(group[0], r.Guns[0].Weapon), "fixture: the side mount leads the group");
r.Zone.Agents.Add(new Minion(r.Shooter));
r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
r.Zone.Update(1f);
r.Zone.Update(1f);
@ -288,7 +288,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
public void CombatFiresAFusedWeaponAtADesignatedTargetOutOfArc()
{
var r = Build(SideThenForward, fuse: WeaponFuse.Proximity, blast: 4f);
r.Zone.Agents.Add(new Minion(r.Shooter));
r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
r.Zone.Update(1f);
r.Zone.Update(1f);
@ -328,7 +328,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
if (reason == Withheld.NoTarget) target = null;
if (reason == Withheld.NotVisible) r.Shooter.VisibleEntities.Remove(r.Target);
Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, target));
Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, target, r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability));
}
[Fact]
@ -337,7 +337,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
// Accuracy 0 prices every direct hit at zero; the fused weapon is unmoved.
var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, accuracy: 0f);
Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target));
Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability));
}
// A weapon that fires at what it hits keeps the AgentMinHitProbability threshold, per weapon.
@ -351,7 +351,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
var p = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target);
Assert.True(p > .3f && p < .7f, $"fixture: the shot must price near the accuracy, priced {p}");
Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target));
Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability));
}
// ==== Ruling 2: the HUD forecast is Fire's own decision ====
@ -566,7 +566,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
{
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: 20f);
Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target));
Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability));
}
// Combat's group pick counts only weapons that can fire: the refused gun's group out-damages the decoy's, so a
@ -577,7 +577,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
{
var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, targetRange: 20f, decoy: true);
Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the refused gun is the stronger group");
r.Zone.Agents.Add(new Minion(r.Shooter));
r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
r.Zone.Update(1f);
r.Zone.Update(1f);
@ -594,7 +594,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
{
var r = Build(range: 10f, targetRange: 20f, decoy: true);
Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the short gun is the stronger group");
r.Zone.Agents.Add(new Minion(r.Shooter));
r.Zone.Agents.Add(new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant));
r.Zone.Update(1f);
r.Zone.Update(1f);
@ -611,7 +611,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
var r = Build(range: 60f, targetRange: 60f);
// The agent is driven directly, not through the zone, so the shooter stays exactly at max range: the first
// update leaves the root state for combat, the second is combat deciding.
var agent = new Minion(r.Shooter);
var agent = new Minion(r.Shooter, FactionDoctrine.Default(r.Shooter.ItemManager.GameplaySettings).Combatant);
agent.Update(.01f);
agent.Update(.01f);
@ -630,8 +630,33 @@ public sealed class FireControlPerWeaponTests : IDisposable
var atThreshold = Build(accuracy: .5f, minHit: price);
var overThreshold = Build(accuracy: .5f, minHit: MathF.BitIncrement(price));
Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target));
Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target));
Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target, atThreshold.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability));
Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target, overThreshold.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability));
}
// The threshold is the doctrine's, passed in: the settings global (zero here) decides nothing for a pilot. Kills: a
// reintroduced read of AgentMinHitProbability inside AgentFires.
[Fact]
public void TheDoctrinesThresholdDecidesFire()
{
var r = Build(accuracy: .5f, minHit: 0f);
var price = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target);
Assert.True(price > 0f);
Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, price));
Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target, MathF.BitIncrement(price)));
}
// A long and a short weapon: the exponent biases the preferred range outward. Kills: the optimum read from the
// settings global instead of the member's RoleDoctrine.
[Fact]
public void TheDoctrinesExponentMovesTheOptimumRange()
{
var r = Build(range: 400f, decoy: true);
float Optimum(float exponent) =>
new CombatState(new Minion(r.Shooter, new RoleDoctrine { RangeExponent = exponent, MinHitProbability = .2f })).OptimumRange;
Assert.True(Optimum(0f) < Optimum(1f));
}
// A round refused on a Range that fell between the trigger and the round is still free: the burst is paid

View file

@ -211,7 +211,7 @@ public sealed class FireControlTests : IDisposable
// decision at all (Cut 1's regression), not AgentMinHitProbability tuning -- zero the threshold so an
// unaided shot still counts as "worth it."
items.GameplaySettings.AgentMinHitProbability = 0f;
zone.Agents.Add(new Minion(shooter));
zone.Agents.Add(new Minion(shooter, FactionDoctrine.Default(shooter.ItemManager.GameplaySettings).Combatant));
return (shooter, target, zone);
}

View file

@ -0,0 +1,332 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using CultMath;
using GameCult.Caching;
using UniRx;
using Xunit;
// The flight (Agents/Flight.cs) is the only writer of its members' Target and Task. These tests drive it on the
// IffAndCombatTests world. Perception is the sensors' output (VisibleEnemies, EntityInfoGathered), which
// IffAndCombatTests covers separately, so the tests set it directly; a grudge is a real IFF override.
public sealed class FlightTests : IDisposable
{
private readonly IffAndCombatTests _world = new IffAndCombatTests();
private readonly ItemManager _items;
private readonly Zone _zone;
public FlightTests() => (_items, _zone) = _world.BuildWorld();
public void Dispose() => _world.Dispose();
private static readonly Faction Hostile = new Faction { Name = "Hostile" };
private static Faction WithDoctrine(EngageOn on, float grace = 0, string hail = null, float comply = 0) => new Faction
{
Name = "Escort",
Doctrine = new FactionDoctrine
{
EngageOn = on, Grace = grace, Hail = hail, ComplySpeed = comply,
Combatant = new RoleDoctrine { RangeExponent = .25f, MinHitProbability = .2f }
}
};
private Ship Pilot(Faction faction, float x = 0)
{
var ship = _world.NewShip(_items, _zone, faction, piloted: true);
ship.Position = new float3(x, 0, 0);
return ship;
}
private Ship Track(float x = 100)
{
var track = _world.NewShip(_items, _zone, Hostile);
track.Position = new float3(x, 0, 0);
return track;
}
private static void See(Ship member, Entity track)
{
member.EntityInfoGathered[track] = 1;
member.VisibleEnemies.Add(track);
}
private void Tick(float dt)
{
foreach (var flight in _zone.Flights.ToArray()) flight.Update(dt);
}
[Fact]
public void ADefaultDoctrineEngagesTheVisibleEnemyAsMinionDid()
{
var member = Pilot(new Faction { Name = "Plain" });
var track = Track();
See(member, track);
Tick(.1f);
Assert.Same(track, member.Target.Value.Entity);
member.VisibleEnemies.Remove(track);
Tick(.1f);
Assert.True(member.Target.Value.IsNone);
Assert.IsType<PatrolOrbitsTask>(_zone.Agents.Single().Task);
}
[Fact]
public void OnlyTheFlightWritesTheTarget()
{
var member = Pilot(new Faction { Name = "Plain" });
See(member, Track());
Assert.True(member.Target.Value.IsNone);
Tick(.1f);
Assert.False(member.Target.Value.IsNone);
}
[Fact]
public void IdentifiedWaitsForTheGearTier()
{
_items.GameplaySettings.TargetGearInfoThreshold = .8f;
var faction = WithDoctrine(EngageOn.Identified);
var a = Pilot(faction);
var b = Pilot(faction, 10);
var track = Track();
See(a, track);
See(b, track);
a.EntityInfoGathered[track] = .7f;
b.EntityInfoGathered[track] = .7f;
Tick(.1f);
Assert.True(a.Target.Value.IsNone);
Assert.True(b.Target.Value.IsNone);
b.EntityInfoGathered[track] = .9f;
Tick(.1f);
Assert.Same(track, a.Target.Value.Entity);
Assert.Same(track, b.Target.Value.Entity);
}
[Fact]
public void ProvokedEngagesOnlyTheGrudgedTrack()
{
var member = Pilot(WithDoctrine(EngageOn.Provoked));
var calm = Track(80);
var grudged = Track(120);
See(member, calm);
See(member, grudged);
member.SetIff(grudged, true);
Tick(.1f);
Assert.Same(grudged, member.Target.Value.Entity);
}
[Fact]
public void NeverIgnoresEvenAGrudge()
{
var member = Pilot(WithDoctrine(EngageOn.Never));
var track = Track();
See(member, track);
member.SetIff(track, true);
Tick(.1f);
Assert.True(member.Target.Value.IsNone);
}
[Fact]
public void GraceHailsOnceFromTheNearestThenEngages()
{
var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.");
var near = Pilot(faction, 90);
var far = Pilot(faction, 0);
var track = Track(100);
See(near, track);
See(far, track);
Tick(.1f);
Assert.True(near.Messages.ContainsKey("Hold."));
Assert.False(far.Messages.ContainsKey("Hold."));
Assert.True(near.Target.Value.IsNone && far.Target.Value.IsNone);
Assert.All(_zone.Agents, a => Assert.IsType<FollowTask>(a.Task));
Tick(4.8f);
Assert.True(near.Target.Value.IsNone && far.Target.Value.IsNone);
Tick(.4f);
Assert.Same(track, near.Target.Value.Entity);
Assert.Same(track, far.Target.Value.Entity);
}
[Fact]
public void ASlowTrackComplies()
{
var faction = WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold.", comply: 10);
var member = Pilot(faction);
var track = Track();
See(member, track);
Tick(.1f);
Tick(5.1f);
Assert.True(member.Target.Value.IsNone);
Assert.IsType<FollowTask>(_zone.Agents.Single().Task);
member.Messages.Clear();
track.Velocity = new float2(20, 0);
Tick(.1f);
Assert.Same(track, member.Target.Value.Entity);
Assert.Empty(member.Messages);
}
[Fact]
public void AGrudgeSkipsTheGrace()
{
var member = Pilot(WithDoctrine(EngageOn.Detection, grace: 5, hail: "Hold."));
var track = Track();
See(member, track);
Tick(.1f);
Assert.True(member.Target.Value.IsNone);
member.SetIff(track, true);
Tick(.1f);
Assert.Same(track, member.Target.Value.Entity);
}
[Fact]
public void FollowClosesWhenFarAndOpensWhenNear()
{
var far = Pilot(null);
var near = Pilot(null);
var anchor = Track();
float TurnAfterFollowing(Ship ship, float3 anchorAt)
{
anchor.Position = anchorAt;
var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant)
{ Task = new FollowTask { Anchor = anchor, Standoff = 100 } };
minion.Update(.1f);
minion.Update(.1f);
return ship.Turn;
}
// The hull faces +z. Anchor 300 ahead: the desired velocity is dead ahead, no turn. Anchor 20 ahead: inside the
// standoff, the desired velocity is astern, and the pilot turns to it.
Assert.Equal(0, TurnAfterFollowing(far, new float3(0, 0, 300)), 3);
Assert.NotEqual(0, TurnAfterFollowing(near, new float3(0, 0, 20)), 3);
}
[Fact]
public void ATaskChangeLeavesThePatrolSubstate()
{
var ship = Pilot(null);
var anchor = Track();
var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant)
{ Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } };
for (var i = 0; i < 4; i++) minion.Update(.1f);
Assert.IsType<MoveToOrbitState>(CurrentState(minion));
minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 };
minion.Update(.1f);
Assert.IsType<FollowState>(CurrentState(minion));
}
private static BaseState CurrentState(Agent agent) =>
(BaseState) typeof(Agent).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(agent);
[Fact]
public void RemovalTakesTheAgentAndTheFlightMember()
{
var faction = new Faction { Name = "Plain" };
var dies = Pilot(faction);
var leaves = Pilot(faction, 10);
var stays = Pilot(faction, 20);
var player = _world.NewShip(_items, _zone, faction, isPlayerShip: true);
var flight = _zone.Flights.Single();
Assert.Equal(3, flight.Count);
dies.Hull.Durability = 0;
dies.HullDamage.OnNext(1f);
_zone.Entities.Remove(leaves);
Assert.Equal(new[] { stays }, _zone.Agents.Select(a => a.Ship));
Assert.Equal(1, flight.Count);
Assert.DoesNotContain(_zone.Agents, a => a.Ship == player);
}
private static string TempCatalog() => Path.Combine(Path.GetTempPath(), "aetheria-doctrine-" + Guid.NewGuid().ToString("N") + ".cc");
[Fact]
public void TheDoctrineRoundTrips()
{
var path = TempCatalog();
try
{
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
cache.Upsert(new Faction
{
Name = "Armed",
Doctrine = new FactionDoctrine
{
EngageOn = EngageOn.Trespass, Grace = 6, Hail = "Hold.", ComplySpeed = 10,
Combatant = new RoleDoctrine { RangeExponent = .05f, MinHitProbability = .15f, HoldStandoff = 40 }
}
});
cache.Upsert(new Faction { Name = "Bare" });
cache.FlushAsync().Wait();
}
using (var cache = AetheriaStores.Open(path))
{
var armed = cache.GetAll<Faction>().Single(f => f.Name == "Armed").Doctrine;
Assert.Equal(EngageOn.Trespass, armed.EngageOn);
Assert.Equal(6f, armed.Grace);
Assert.Equal("Hold.", armed.Hail);
Assert.Equal(10f, armed.ComplySpeed);
Assert.Equal(.05f, armed.Combatant.RangeExponent);
Assert.Equal(.15f, armed.Combatant.MinHitProbability);
Assert.Equal(40f, armed.Combatant.HoldStandoff);
Assert.Null(cache.GetAll<Faction>().Single(f => f.Name == "Bare").Doctrine);
}
}
finally { File.Delete(path); }
}
[Fact]
public void TheCatalogCommandNamesAMissingFaction()
{
var path = TempCatalog();
try
{
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
foreach (var name in new[] { "Zhestokost", "Lucent Media", "Aeronautics Unlimited" }) cache.Upsert(new Faction { Name = name });
cache.FlushAsync().Wait();
}
var output = new StringWriter();
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
DoctrineCatalog.Run(cache, apply: true, output);
cache.FlushAsync().Wait();
}
Assert.Contains("Pirates: no Faction record, doctrine not written", output.ToString());
using (var cache = AetheriaStores.Open(path, catalogWritable: true))
{
Assert.All(cache.GetAll<Faction>(), f => Assert.NotNull(f.Doctrine));
Assert.Equal(EngageOn.Trespass, cache.GetAll<Faction>().Single(f => f.Name == "Zhestokost").Doctrine.EngageOn);
cache.Upsert(new Faction { Name = "Pirates" });
DoctrineCatalog.Run(cache, apply: true, new StringWriter());
Assert.Equal(8f, cache.GetAll<Faction>().Single(f => f.Name == "Pirates").Doctrine.Grace);
}
}
finally { File.Delete(path); }
}
}

View file

@ -48,7 +48,7 @@ public sealed class IffAndCombatTests : IDisposable
// A world with one empty zone (no faction/security machinery engaged: GalaxyZone.Owner is null and
// every test ship is non-player, so PresencePermitted stays at its default true and derived hostility
// between non-owner factions is always false). Individual tests layer overrides/factions on top.
private (ItemManager items, Zone zone) BuildWorld()
internal (ItemManager items, Zone zone) BuildWorld()
{
_openCache = AetheriaStores.Open(Catalog, catalogWritable: true);
var ledger = new ProvenanceLedger();
@ -69,7 +69,7 @@ public sealed class IffAndCombatTests : IDisposable
TargetDetectionInfoThreshold = .5f
};
private Ship NewShip(ItemManager items, Zone zone, Faction faction = null, bool isPlayerShip = false, bool equipGun = false)
internal Ship NewShip(ItemManager items, Zone zone, Faction faction = null, bool isPlayerShip = false, bool equipGun = false, bool piloted = false)
{
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Skiff"));
var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 };
@ -82,14 +82,19 @@ public sealed class IffAndCombatTests : IDisposable
Assert.True(ship.TryEquip(gun, HardpointCell));
}
zone.Entities.Add(ship);
ship.Activate();
// A piloted ship joins as every piloted admission does (Zone.Admit), and so gets its agent and its flight.
if (piloted) zone.Admit(ship, true);
else
{
zone.Entities.Add(ship);
ship.Activate();
}
return ship;
}
// Marks entity `observer` as having detected `target`, the same way Sensor ramps EntityInfoGathered
// past TargetDetectionInfoThreshold in the real game.
private static void Detect(Entity observer, Entity target) => observer.EntityInfoGathered[target] = 1;
internal static void Detect(Entity observer, Entity target) => observer.EntityInfoGathered[target] = 1;
[Fact]
public void OverrideDecidesOutrightOverDerivedRule()

View file

@ -623,9 +623,9 @@ public sealed class MineTests : IDisposable
}
lab.Zone.Update(1f);
Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, null));
Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, far));
Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near));
Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, null, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability));
Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, far, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability));
Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability));
Assert.False(FireControl.Refuses(lab.Weapon, lab.Layer));
}
@ -667,7 +667,7 @@ public sealed class MineTests : IDisposable
lab.Zone.Update(1f);
Assert.False(FireControl.Refuses(lab.Weapon, lab.Layer));
Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near));
Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability));
var mine = LayOne(lab);
Assert.Equal(150f, mine.BlastRadius);
}

View file

@ -0,0 +1,69 @@
using System;
using System.IO;
using System.Linq;
using GameCult.Caching;
// Authors the starting FactionDoctrine of the four factions the demo cast uses (aetheria-release, faction-play-1).
// Tuning is a starting point; the operator adjusts it after play. A faction with no Faction record is named, not
// created: the Pirates' record is owed by follow-up pirates-catalog-record, and re-running apply writes them once it lands.
public static class DoctrineCatalog
{
private static readonly (string faction, FactionDoctrine doctrine)[] Table =
{
("Zhestokost", new FactionDoctrine
{
EngageOn = EngageOn.Trespass, Grace = 6f, Hail = "Cut thrust and hold for inspection.", ComplySpeed = 10f,
Combatant = new RoleDoctrine { RangeExponent = .05f, MinHitProbability = .05f }
}),
("Lucent Media", new FactionDoctrine
{
EngageOn = EngageOn.Detection, Grace = 2f, Hail = "You're live.",
Combatant = new RoleDoctrine { RangeExponent = .25f, MinHitProbability = .3f }
}),
("Aeronautics Unlimited", new FactionDoctrine
{
EngageOn = EngageOn.Detection,
Combatant = new RoleDoctrine { RangeExponent = .25f, MinHitProbability = .05f }
}),
("Pirates", new FactionDoctrine
{
EngageOn = EngageOn.Detection, Grace = 8f, Hail = "Drop your cargo and you can go.",
Combatant = new RoleDoctrine { RangeExponent = .5f, MinHitProbability = .15f }
}),
};
// Dry run unless apply, as the other catalog commands. The cache must be writable for apply.
public static int Run(CultCache cache, bool apply, TextWriter output)
{
var factions = cache.GetAll<Faction>().ToArray();
var changed = new System.Collections.Generic.List<Faction>();
foreach (var (name, doctrine) in Table)
{
var faction = factions.FirstOrDefault(f => string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase));
if (faction == null)
{
output.WriteLine($"{name}: no Faction record, doctrine not written");
continue;
}
output.WriteLine($"{name}: engage on {doctrine.EngageOn}, grace {doctrine.Grace:0.##}s, hail \"{doctrine.Hail}\", " +
$"comply {doctrine.ComplySpeed:0.##}, range exponent {doctrine.Combatant.RangeExponent:0.##}, " +
$"min hit {doctrine.Combatant.MinHitProbability:0.##}");
faction.Doctrine = doctrine;
changed.Add(faction);
}
if (!apply)
{
output.WriteLine("\nDry run. Pass \"apply\" to author them.");
return 0;
}
cache.Commit(batch =>
{
foreach (var faction in changed) batch.Upsert(typeof(Faction), faction, cache.RefOf(faction).Key);
});
output.WriteLine($"\nAuthored {changed.Count} doctrines in Aetheria.cc");
return 0;
}
}

View file

@ -38,8 +38,9 @@ public static class Program
case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply"));
case "ship-authoring": return ShipAuthoringCommands.Run(args.Skip(1).ToArray());
case "field-kinds": return FieldKindsCatalog(args.Contains("apply"));
case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out);
default:
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply]");
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], doctrine-catalog [apply]");
return 1;
}
}
@ -659,7 +660,7 @@ public static class Program
{
var db = AetherDb.Open();
var products = db.Cache.GetAll<FactionProductData>().ToArray();
Console.WriteLine($"{"faction",-26} {"short",-13} {"geonames",-22} {"boss hull",-14} {"influence",-9} products");
Console.WriteLine($"{"faction",-26} {"short",-13} {"geonames",-22} {"boss hull",-14} {"influence",-9} {"doctrine",-10} products");
var broken = 0;
foreach (var faction in db.Cache.GetAll<Faction>().OrderBy(f => f.Name))
{
@ -669,7 +670,7 @@ public static class Program
? "none"
: db.Cache.Get(faction.BossHull)?.Name ?? "DANGLING";
if (geonames == "UNSET" || boss == "DANGLING") broken++;
Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {geonames,-22} {boss,-14} {faction.InfluenceDistance,-9} " +
Console.WriteLine($"{faction.Name,-26} {faction.ShortName,-13} {geonames,-22} {boss,-14} {faction.InfluenceDistance,-9} {(faction.Doctrine == null ? "none" : faction.Doctrine.EngageOn.ToString()),-10} " +
products.Count(p => p.Manufacturer.Key.Equals(key)));
}
Console.WriteLine($"\n{broken} factions carry a link that would break generation");