Merge eureka/aetheria-release-faction-play-1 (cut-faction-play-1 r3, h6) into master
Catalog GameData/Aetheria.cc regenerated by replaying doctrine-catalog apply on the merged catalog, not taken from either branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
d7f30ed165
33 changed files with 1792 additions and 82 deletions
|
|
@ -15,6 +15,9 @@ public class Agent
|
|||
{
|
||||
private const float FORWARD_DELTA_THRESHOLD = 20;
|
||||
private const float THRUST_DELTA_THRESHOLD = 1;
|
||||
// The Strength of one Track paint in this ship's own IncomingPaints: a fire-control emitter tracking this
|
||||
// that hard (rulings lock-warning-strength, sensor-stat-set item 4, launcher-lock-solution-quality).
|
||||
private const float LockAlarm = .5f;
|
||||
protected BaseState _rootState;
|
||||
private BaseState _currentState;
|
||||
private VelocityLimit _velocityLimit;
|
||||
|
|
@ -23,11 +26,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;
|
||||
|
||||
|
|
@ -49,6 +55,7 @@ public class Agent
|
|||
// The pilot tick writes Turn every tick, in every state: a state that pilots overwrites this zero with its
|
||||
// demand, and one that does not leaves the hull holding course instead of inheriting the last state's rate.
|
||||
Ship.Turn = 0;
|
||||
VentOnTrack();
|
||||
_currentState.Update(delta);
|
||||
foreach (var transition in _currentState.Transitions)
|
||||
if (transition.Condition())
|
||||
|
|
@ -58,6 +65,25 @@ public class Agent
|
|||
}
|
||||
}
|
||||
|
||||
// The AI plays the player's rule on the player's information: vent when one Track paint on this ship passes half. It
|
||||
// reads only this ship's own IncomingPaints, so it runs in every state (ruling vent-in-any-state), and a ship
|
||||
// carrying no vent does nothing.
|
||||
private void VentOnTrack()
|
||||
{
|
||||
if (TrackAlarm) Use(b => b is VapourDumpData);
|
||||
}
|
||||
|
||||
// One Track paint on this ship passes the alarm: the ship vents, and does not overdrive on the same tick.
|
||||
public bool TrackAlarm => Ship.IncomingPaints.Any(p => p.Mode == PaintMode.Track && p.Strength > LockAlarm);
|
||||
|
||||
// Through TryActivateConsumable, one of a kind at a time: a Stackable design would otherwise be drained once per tick.
|
||||
public void Use(Predicate<BehaviorData> kind)
|
||||
{
|
||||
if (Ship.HasActiveConsumable(kind)) return;
|
||||
var consumable = Ship.FirstCarriedConsumable(kind);
|
||||
if (consumable != null) Ship.TryActivateConsumable(consumable);
|
||||
}
|
||||
|
||||
public void Accelerate(float2 targetVelocity, bool noTurn = false)
|
||||
{
|
||||
var deltaV = targetVelocity - Ship.Velocity;
|
||||
|
|
|
|||
185
Assets/Scripts/ServerShared/Agents/Flight.cs
Normal file
185
Assets/Scripts/ServerShared/Agents/Flight.cs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
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;
|
||||
}
|
||||
|
||||
// No engaged track in this member's own sensors: it steers on the flight's picture, a bearing and never targeting
|
||||
// data (ruling shared-track-bearing-only). That is the hailed and the complied, and an engaged track only a flightmate sees.
|
||||
Aim(ship, null);
|
||||
// Overrides are per member: a flightmate's hostile track is not one this member follows after it has made it friendly.
|
||||
var held = picture.Where(e => _tracks[e].Phase != Phase.None && ship.IffOverride(e) != false).ToList();
|
||||
if (held.Count > 0)
|
||||
{
|
||||
// An engaged track outranks a hailed one; the held anchor is kept only while nothing outranks it.
|
||||
var anyEngaged = held.Any(e => _tracks[e].Phase == Phase.Engaged);
|
||||
var anchor = member.Agent.Task is FollowTask follow && held.Contains(follow.Anchor)
|
||||
&& (!anyEngaged || _tracks[follow.Anchor].Phase == Phase.Engaged)
|
||||
? follow.Anchor
|
||||
: held.OrderBy(e => _tracks[e].Phase == Phase.Engaged ? 0 : 1)
|
||||
.ThenBy(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();
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/ServerShared/Agents/Flight.cs.meta
Normal file
2
Assets/Scripts/ServerShared/Agents/Flight.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5f6c82a74e5b4bc1b8c79e0d242e3ec2
|
||||
|
|
@ -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 patrol sub-states (MoveToOrbit) that Update actually sits in; the walk never enters the other task's state.
|
||||
patrolState.AddTransition(followState, () => Task is FollowTask, null, true, _rootState);
|
||||
patrolState.AddTransition(_rootState, () => !(Task is PatrolOrbitsTask), null, true, _rootState, followState);
|
||||
followState.AddTransition(patrolState, () => Task is PatrolOrbitsTask, Patrol);
|
||||
followState.AddTransition(_rootState, () => !(Task is FollowTask));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,11 @@ 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;
|
||||
public float OptimumRange => _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>();
|
||||
|
|
@ -141,7 +139,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();
|
||||
|
|
@ -154,25 +152,13 @@ public class CombatState : BaseState
|
|||
// 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.
|
||||
// Overdrive to close a distant target ahead, through the same consumable gate as the vent (Agent.VentOnTrack).
|
||||
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);
|
||||
if (!_agent.TrackAlarm && targetDistance > OverdriveRangeFactor * _optimumRange &&
|
||||
dot(normalize(ship.Direction), normalize(toTarget.xz)) > cos(radians(OverdriveArcDegrees)))
|
||||
_agent.Use(b => b is ThrottleLockData);
|
||||
}
|
||||
|
||||
private void SampleDps()
|
||||
|
|
@ -200,7 +186,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)
|
||||
{
|
||||
|
|
|
|||
26
Assets/Scripts/ServerShared/Agents/States/Follow.cs
Normal file
26
Assets/Scripts/ServerShared/Agents/States/Follow.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/ServerShared/Agents/States/Follow.cs.meta
Normal file
2
Assets/Scripts/ServerShared/Agents/States/Follow.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5cf86244ca6648a99f304b061b66348a
|
||||
|
|
@ -29,6 +29,7 @@ public enum TaskType
|
|||
Tow,
|
||||
Defend,
|
||||
Attack,
|
||||
Explore
|
||||
Explore,
|
||||
Follow
|
||||
}
|
||||
|
||||
|
|
|
|||
11
Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs
Normal file
11
Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6bab02326ff64e29ad2b81f0fc1483b5
|
||||
|
|
@ -27,6 +27,7 @@ public class PatrolOrbitsState : BaseState
|
|||
Transitions.Add(new StateTransition(patrolMoveState,
|
||||
() => true,
|
||||
() => patrolMoveState.Orbit = CurrentTarget));
|
||||
patrolMoveState.Transitions.Add(new StateTransition(this, () => patrolMoveState.Distance < 10, NextTarget));
|
||||
// Arrival advances the circuit only while the pilot is still on this task: a task change leaves the patrol first.
|
||||
patrolMoveState.Transitions.Add(new StateTransition(this, () => patrolMoveState.Distance < 10 && ReferenceEquals(agent.Task, Task), NextTarget));
|
||||
}
|
||||
}
|
||||
|
|
@ -79,7 +79,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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
74
Assets/Scripts/ServerShared/Doctrine.cs
Normal file
74
Assets/Scripts/ServerShared/Doctrine.cs
Normal 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 the zone's owner does not permit (Entity.PresencePermitted, the security of the zone it is in), whichever flight sees it.
|
||||
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;
|
||||
}
|
||||
2
Assets/Scripts/ServerShared/Doctrine.cs.meta
Normal file
2
Assets/Scripts/ServerShared/Doctrine.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3f9a1c5e7b2d4e68a0c4d1b9e7f25a31
|
||||
|
|
@ -1286,6 +1286,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
|
||||
|
|
|
|||
|
|
@ -95,12 +95,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,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.
|
||||
|
|
@ -155,6 +160,16 @@ public class Zone
|
|||
_entities.ObserveRemove().Subscribe(remove => _departures.Add(new Departure(remove.Value, remove.Value.Position.xz,
|
||||
remove.Value.Visibility)));
|
||||
|
||||
// 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)
|
||||
|
|
@ -283,10 +298,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;
|
||||
}
|
||||
|
||||
|
|
@ -327,7 +345,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);
|
||||
|
||||
var entities = Entities.ToArray();
|
||||
|
|
|
|||
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
Binary file not shown.
|
|
@ -12,8 +12,8 @@ using float3 = CultMath.float3;
|
|||
using int2 = CultMath.int2;
|
||||
|
||||
// AI pilots use consumables (aetheria-release cut consumable-ai r3; rulings sensor-stat-set item 4,
|
||||
// lock-warning-strength, launcher-lock-solution-quality): CombatState vents when ONE Track paint in the ship's own
|
||||
// IncomingPaints passes .5 and otherwise overdrives to close a target farther than twice its optimum range while
|
||||
// lock-warning-strength, launcher-lock-solution-quality, vent-in-any-state): the agent vents, in every state, when ONE
|
||||
// Track paint in the ship's own IncomingPaints passes .5; CombatState otherwise overdrives to close a target farther than twice its optimum range while
|
||||
// heading within 15 degrees of it. It reads only its own ship's senses. A consumable is chosen by what its behaviours
|
||||
// are, never by name, so the vent design is named "Overdrive" and the overdrive design "Coolant".
|
||||
//
|
||||
|
|
@ -173,18 +173,22 @@ public sealed class ConsumableAiTests : IDisposable
|
|||
scene.Hold = scene.Agent.CargoBays.Single();
|
||||
// The agent joins as the zone admits a piloted ship (its Minion and CombatState come from production); the
|
||||
// locker is a gun ship nobody pilots.
|
||||
// One fixed orbit far from the fight, before the agent joins: a pilot that is not engaged patrols it, as production's would.
|
||||
zone.Orbits.Add(new CultRecordKey("test-orbit"), new Orbit(new OrbitData { FixedPosition = float2(0, 5000) }));
|
||||
zone.Admit(scene.Agent, piloted: true);
|
||||
zone.Agents.Single().Task = null; // the test zone has no orbits to patrol; the Minion and its CombatState are production's
|
||||
zone.Admit(scene.Prey, piloted: false);
|
||||
zone.Admit(scene.Locker, piloted: false);
|
||||
zone.Update(0f); // brings the gear online
|
||||
// The flight is the only writer of the agent's Target: the agent senses the prey and holds a grudge on it, so the
|
||||
// flight engages it.
|
||||
scene.Agent.SetIff(scene.Prey, true);
|
||||
scene.Agent.EntityInfoGathered[scene.Prey] = 1f;
|
||||
scene.Agent.SetTarget(scene.Prey);
|
||||
scene.Agent.VisibleEnemies.Add(scene.Prey);
|
||||
zone.Update(0f); // brings the gear online
|
||||
scene.Locker.Position = float3(0, 0, -40);
|
||||
scene.Place(100f, 0f);
|
||||
zone.Update(.05f);
|
||||
zone.Update(.05f);
|
||||
zone.Update(.05f); // patrol to combat takes a tick the unengaged-root start did not
|
||||
scene.AssertInCombat();
|
||||
|
||||
// Read, never re-sampled: the optimum is whatever production derived for the spawned ship.
|
||||
|
|
@ -343,9 +347,14 @@ public sealed class ConsumableAiTests : IDisposable
|
|||
Assert.True(s.Zone.Remove(s.Agent));
|
||||
s.Agent.Deactivate();
|
||||
fit(s.Agent);
|
||||
s.Zone.Admit(s.Agent, piloted: false); // back in, the agent's Minion keeps its CombatState
|
||||
s.Zone.Admit(s.Agent, piloted: true); // back in as a pilot: the zone gives it a new Minion and CombatState
|
||||
// Deactivation cleared its senses: it senses the prey again, as the sensors would after the jump in.
|
||||
s.Agent.SetIff(s.Prey, true);
|
||||
s.Agent.EntityInfoGathered[s.Prey] = 1f;
|
||||
if (!s.Agent.VisibleEnemies.Contains(s.Prey)) s.Agent.VisibleEnemies.Add(s.Prey);
|
||||
s.Place(30f, 0f);
|
||||
s.Zone.Update(.05f);
|
||||
// The new Minion walks root, patrol, orbit, combat; the tick after it enters combat samples the optimum.
|
||||
for (var i = 0; i < 8 && !(s.State is CombatState); i++) s.Zone.Update(.05f);
|
||||
s.Zone.Update(.05f);
|
||||
}
|
||||
float Optimum() => (float) typeof(CombatState).GetField("_optimumRange", Private).GetValue(s.State);
|
||||
|
|
@ -490,4 +499,54 @@ public sealed class ConsumableAiTests : IDisposable
|
|||
Assert.Empty(s.Agent.VisibleEnemies);
|
||||
Assert.True(s.Active(s.Vent));
|
||||
}
|
||||
|
||||
// A patrolling agent has no target and senses no enemy, and it vents all the same (ruling vent-in-any-state).
|
||||
[Fact]
|
||||
public void APatrollingAgentVentsToo()
|
||||
{
|
||||
var s = Build(carryVent: true);
|
||||
s.Agent.VisibleEnemies.Clear();
|
||||
s.Zone.Update(.05f);
|
||||
s.Zone.Update(.05f);
|
||||
Assert.False(s.State is CombatState, $"the agent is in {s.State?.GetType().Name}");
|
||||
Assert.True(s.Agent.Target.Value.IsNone);
|
||||
Assert.IsType<PatrolOrbitsTask>(s.Zone.Agents.Single().Task);
|
||||
|
||||
s.Tick((PaintMode.Track, .3f));
|
||||
Assert.False(s.Active(s.Vent));
|
||||
s.Tick((PaintMode.Track, .9f));
|
||||
|
||||
Assert.True(s.Active(s.Vent));
|
||||
Assert.Equal(0, s.Carried);
|
||||
}
|
||||
|
||||
// A following agent has a task to follow and no target, and it vents all the same (ruling vent-in-any-state names
|
||||
// patrol, follow and combat). The agent is driven directly: the zone's flight is the sole writer of its Task.
|
||||
[Fact]
|
||||
public void AFollowingAgentVentsToo()
|
||||
{
|
||||
var s = Build(carryVent: true);
|
||||
var agent = s.Zone.Agents.Single();
|
||||
s.Agent.VisibleEnemies.Clear();
|
||||
s.Zone.Update(.05f);
|
||||
s.Zone.Update(.05f);
|
||||
Assert.True(s.Agent.Target.Value.IsNone);
|
||||
agent.Task = new FollowTask { Anchor = s.Prey, Standoff = 100 };
|
||||
for (var i = 0; i < 6 && !(s.State is FollowState); i++) agent.Update(.05f);
|
||||
Assert.True(s.State is FollowState, $"the agent is in {s.State?.GetType().Name}");
|
||||
|
||||
void Paint(float strength)
|
||||
{
|
||||
s.Agent.ReceivePaint(s.Locker, s.Locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)), PaintMode.Track, strength);
|
||||
s.Agent.PublishInbox();
|
||||
agent.Update(.05f);
|
||||
}
|
||||
Paint(.3f);
|
||||
Assert.False(s.Active(s.Vent));
|
||||
Paint(.9f);
|
||||
|
||||
Assert.True(s.State is FollowState);
|
||||
Assert.True(s.Active(s.Vent));
|
||||
Assert.Equal(0, s.Carried);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -277,7 +277,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);
|
||||
|
|
@ -292,7 +292,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);
|
||||
|
|
@ -314,6 +314,67 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
Assert.True(r.Gun.Weapon.Firing, "the turret must fire a fused weapon at a designated target out of arc");
|
||||
}
|
||||
|
||||
// The turret keeps the AgentMinHitProbability threshold for a direct-hit weapon: a shot priced near .5 is held at a
|
||||
// threshold of .6 and taken at .4. Kills: the turret's fire threshold dropped to zero or fixed to a constant.
|
||||
[Theory]
|
||||
[InlineData(.6f, false)]
|
||||
[InlineData(.4f, true)]
|
||||
public void ATurretKeepsTheGlobalHitProbabilityThresholdForADirectHitWeapon(float minHit, bool fires)
|
||||
{
|
||||
var r = Build(accuracy: .5f, minHit: minHit, turret: true);
|
||||
var p = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target);
|
||||
Assert.True(p > .4f && p < .6f, $"fixture: the shot must price near .5, priced {p}");
|
||||
|
||||
r.Zone.Update(1f);
|
||||
r.Zone.Update(1f);
|
||||
|
||||
Assert.Equal(fires, r.Gun.Weapon.Firing);
|
||||
}
|
||||
|
||||
// The same threshold half a hundredth either side of the shot's price. Kills: the turret's threshold loosened or
|
||||
// tightened by a tenth or a hundredth.
|
||||
[Theory]
|
||||
[InlineData(.005f, false)]
|
||||
[InlineData(-.005f, true)]
|
||||
public void ATurretHoldsAShotJustUnderTheThresholdAndTakesOneJustOver(float thresholdAbovePrice, bool fires)
|
||||
{
|
||||
var probe = Build(accuracy: .5f, minHit: 0f, turret: true);
|
||||
var price = FireControl.HitProbability(probe.Gun.Weapon, probe.Shooter, probe.Target);
|
||||
var r = Build(accuracy: .5f, minHit: price + thresholdAbovePrice, turret: true);
|
||||
Assert.Equal(price, FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target), 5);
|
||||
|
||||
r.Zone.Update(1f);
|
||||
r.Zone.Update(1f);
|
||||
|
||||
Assert.Equal(fires, r.Gun.Weapon.Firing);
|
||||
}
|
||||
|
||||
// A flight's members hold off at 1.1 x their longest gun when the doctrine authors no standoff (every authored
|
||||
// doctrine leaves it 0), and at the authored distance otherwise. Guns of Range r and 2r on one ship, at two r and two authored values. Kills: the factor replaced by an offset or an affine rule, the
|
||||
// default zeroed, inverted, taken from the shortest gun, or the authored value ignored.
|
||||
[Theory]
|
||||
[InlineData(50f, 0f, 110f)]
|
||||
[InlineData(200f, 0f, 220f)]
|
||||
[InlineData(50f, 40f, 40f)]
|
||||
[InlineData(50f, 250f, 250f)]
|
||||
public void AFlightHoldsOffAtTheDefaultOrAuthoredStandoff(float range, float holdStandoff, float expected)
|
||||
{
|
||||
var r = Build(range: range, decoy: true);
|
||||
r.Shooter.SetIff(r.Target, null);
|
||||
if (!r.Shooter.VisibleEnemies.Contains(r.Target)) r.Shooter.VisibleEnemies.Add(r.Target);
|
||||
var settings = r.Shooter.ItemManager.GameplaySettings;
|
||||
var flight = new Flight(new FactionDoctrine
|
||||
{
|
||||
EngageOn = EngageOn.Detection, Grace = 5, Combatant = new RoleDoctrine { MinHitProbability = .2f, HoldStandoff = holdStandoff }
|
||||
}, settings);
|
||||
var agent = new Minion(r.Shooter, flight.Combatant);
|
||||
flight.Join(agent, new PatrolOrbitsTask());
|
||||
|
||||
flight.Update(.1f);
|
||||
|
||||
Assert.Equal(expected, ((FollowTask) agent.Task).Standoff, 2);
|
||||
}
|
||||
|
||||
// The AI aims at the height a target rides at (Entity.RideHeight: field plus the layer the target's kind rides on),
|
||||
// not at the plane and not at the shooter's own layer. A target on a layer 40 u up against a shooter on the ship
|
||||
// layer, a shot fast enough to be predicted: the turret's and Combat's aim point at the target's stepped pose.
|
||||
|
|
@ -331,7 +392,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
public void CombatAimsAtTheHeightATargetRidesAt()
|
||||
{
|
||||
var r = Build(velocity: 500f, targetLayerHeight: 40f);
|
||||
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);
|
||||
|
||||
|
|
@ -366,7 +427,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]
|
||||
|
|
@ -375,7 +436,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.
|
||||
|
|
@ -389,7 +450,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 ====
|
||||
|
|
@ -599,7 +660,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
|
||||
|
|
@ -610,7 +671,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);
|
||||
|
|
@ -628,7 +689,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
var r = Build(range: 10f, targetRange: 20f, decoy: true);
|
||||
Assert.True(r.Gun.Weapon.RangeDamagePerSecond(5f) > r.Decoy.Weapon.RangeDamagePerSecond(5f), "fixture: the short gun is the stronger group");
|
||||
Assert.Equal(0f, r.Gun.Weapon.RangeDamagePerSecond(20f)); // the damage function already reads 0 beyond Range
|
||||
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);
|
||||
|
|
@ -650,7 +711,7 @@ public sealed class FireControlPerWeaponTests : IDisposable
|
|||
var decoy = r.Decoy.Weapon.RangeDamagePerSecond(60f);
|
||||
Assert.Equal(gunPicked, r.Gun.Weapon.RangeDamagePerSecond(60f) > decoy);
|
||||
Assert.True(r.Gun.Weapon.RangeDamagePerSecond(effective == 10f ? 30f : 120f) > decoy == !gunPicked, "fixture: the scaled distance reads the other way");
|
||||
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);
|
||||
|
|
@ -667,7 +728,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);
|
||||
|
||||
|
|
@ -686,8 +747,37 @@ 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)
|
||||
{
|
||||
var combat = new CombatState(new Minion(r.Shooter, new RoleDoctrine { RangeExponent = exponent, MinHitProbability = .2f }));
|
||||
combat.Update(0f); // the optimum is derived at the first Update
|
||||
return combat.OptimumRange;
|
||||
}
|
||||
|
||||
Assert.True(Optimum(1f) - Optimum(0f) > 100f);
|
||||
}
|
||||
|
||||
// A round refused on a Range that fell between the trigger and the round is still free: the burst is paid
|
||||
|
|
|
|||
|
|
@ -237,7 +237,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);
|
||||
}
|
||||
|
||||
|
|
|
|||
1121
tests/Aetheria.Shared.Tests/FlightTests.cs
Normal file
1121
tests/Aetheria.Shared.Tests/FlightTests.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,13 +82,13 @@ public sealed class IffAndCombatTests : IDisposable
|
|||
Assert.True(ship.TryEquip(gun, HardpointCell));
|
||||
}
|
||||
|
||||
zone.Admit(ship, piloted: false);
|
||||
zone.Admit(ship, piloted);
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@ public sealed partial class ContactRevealTests
|
|||
var bay = AddBay(float3(0, 0, 40), gun: true);
|
||||
var gun = Guns(bay).Single();
|
||||
var ai = Add(float3(0, 0, 0), new[] { "Eye" });
|
||||
_zone.Agents.Add(new Minion(ai));
|
||||
_zone.Agents.Add(new Minion(ai, FactionDoctrine.Default(ai.ItemManager.GameplaySettings).Combatant));
|
||||
ai.SetTarget(bay);
|
||||
Hold(ai, 12, 1f, bay);
|
||||
Assert.True(ReferenceEquals(bay, ai.Target.Value.Entity), "fixture: the AI keeps the ship as its target");
|
||||
|
|
|
|||
|
|
@ -677,9 +677,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));
|
||||
}
|
||||
|
||||
|
|
@ -721,7 +721,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -379,7 +379,7 @@ public sealed partial class ContactRevealTests
|
|||
target.Claim(reactor, DataOf("Radiator"));
|
||||
var player = Add(float3(-20, 0, 0), tools: new[] { "Scanner" });
|
||||
var ai = Add(float3(20, 0, 0), tools: new[] { "Scanner" });
|
||||
var agent = new Minion(ai); // the AI pilot, ticked in Zone.Update beside the scan: it takes the combat state once it has a target
|
||||
var agent = new Minion(ai, FactionDoctrine.Default(ai.ItemManager.GameplaySettings).Combatant); // the AI pilot, ticked in Zone.Update beside the scan: it takes the combat state once it has a target
|
||||
_zone.Agents.Add(agent);
|
||||
Assert.Single(_zone.Agents); // fixture: only the second ship has an agent
|
||||
foreach (var scanner in new[] { player, ai })
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ public sealed partial class FireControlCut124Tests
|
|||
|
||||
Assert.True(solution.Bears && solution.Free);
|
||||
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
|
||||
Assert.True(FireControl.AgentFires(e.Weapon, e.Shooter, e.Target));
|
||||
Assert.True(FireControl.AgentFires(e.Weapon, e.Shooter, e.Target, e.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability));
|
||||
Near(intercept, solution.Direction);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ public sealed class SteeringTests
|
|||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
|
||||
var agent = new Agent(ship);
|
||||
var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant);
|
||||
var aim = float3(0, 0, 1);
|
||||
ship.Aim = aim;
|
||||
|
||||
|
|
@ -182,7 +182,7 @@ public sealed class SteeringTests
|
|||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
|
||||
var agent = new Agent(ship);
|
||||
var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant);
|
||||
Assert.Equal(float2(0, 1), ship.Direction); // starboard is +x
|
||||
|
||||
ship.Turn = .37f;
|
||||
|
|
@ -210,7 +210,7 @@ public sealed class SteeringTests
|
|||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
|
||||
var agent = new Agent(ship);
|
||||
var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant);
|
||||
ship.Velocity = float2(100, 0);
|
||||
ship.Turn = .37f;
|
||||
|
||||
|
|
@ -326,7 +326,7 @@ public sealed class SteeringTests
|
|||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
|
||||
var agent = new Agent(ship);
|
||||
var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant);
|
||||
var start = ship.Direction;
|
||||
|
||||
agent.Transition(new SpinState(agent));
|
||||
|
|
@ -369,7 +369,7 @@ public sealed class SteeringTests
|
|||
{
|
||||
using var cache = RestoredHullsTests.OpenCatalog();
|
||||
var ship = RestoredHullsTests.BuildThrustedShip(cache, Hull);
|
||||
var agent = new Agent(ship);
|
||||
var agent = new Agent(ship, FactionDoctrine.Default(ship.ItemManager.GameplaySettings).Combatant);
|
||||
var state = new MoveToPointState(agent);
|
||||
var dir = Heading(ship, degrees);
|
||||
state.Point = dir * 500f;
|
||||
|
|
|
|||
|
|
@ -301,12 +301,12 @@ public sealed class VerseGrammarTests : IDisposable
|
|||
|
||||
// The shipped catalog carries the command's output.
|
||||
[Fact]
|
||||
public void The_shipped_catalog_holds_revision_one_and_the_seven_starter_verbs()
|
||||
public void The_shipped_catalog_holds_revision_one_and_the_eight_starter_verbs()
|
||||
{
|
||||
using var cache = OpenScoped(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), true);
|
||||
Assert.Equal(1, cache.GetGlobal<VerseGrammar>().Revision);
|
||||
var starter = VerseGrammarCommand.StarterVerbs();
|
||||
Assert.Equal(7, starter.Length);
|
||||
Assert.Equal(8, starter.Length);
|
||||
var shipped = cache.GetAll<VerseVerb>().ToArray();
|
||||
Assert.Equal(starter.Length, shipped.Length);
|
||||
foreach (var expected in starter)
|
||||
|
|
|
|||
83
tools/AetherDb/DoctrineCatalog.cs
Normal file
83
tools/AetherDb/DoctrineCatalog.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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. The Pirates record is on master (cut pirates-record);
|
||||
// a faction with no Faction record is named and skipped, and apply is re-run after a cast faction's record lands.
|
||||
// A faction that already has a doctrine is kept as it is (the operator tunes after play), so a rerun that finds
|
||||
// nothing to author writes nothing.
|
||||
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 => f.Name == name);
|
||||
if (faction == null)
|
||||
{
|
||||
output.WriteLine($"{name}: no Faction record, doctrine not written");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (faction.Doctrine != null)
|
||||
{
|
||||
output.WriteLine($"{name}: already has a doctrine, kept");
|
||||
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;
|
||||
}
|
||||
|
||||
if (changed.Count == 0)
|
||||
{
|
||||
output.WriteLine("Nothing to author.");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -56,8 +56,9 @@ public static class Program
|
|||
case "generic-designs": return GenericDesigns(args.Contains("apply"));
|
||||
case "inspection-scanner": return InspectionScannerCatalog(args.Contains("apply"));
|
||||
case "product-lines": return ProductLinesCommand.Run(args.Skip(1).ToArray());
|
||||
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], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], product-lines weapons [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], range-falloff [apply] [--effective <fraction>] [--end <multiplier>], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], product-lines weapons [apply], doctrine-catalog [apply]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -697,7 +698,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))
|
||||
{
|
||||
|
|
@ -707,7 +708,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");
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ public static class VerseGrammarCommand
|
|||
Roles = new[] { Role("target", VerseReferentKind.Faction) } },
|
||||
new VerseVerb { Name = "explore", RenderPath = VerseRenderPath.ShipAction, Description = "The actor surveys a region and charts what is there.",
|
||||
Roles = new[] { Role("region", VerseReferentKind.Place) } },
|
||||
new VerseVerb { Name = "follow", RenderPath = VerseRenderPath.ShipAction, Description = "The actor shadows a ship of the target faction at a standoff, as a flight does while it hails.",
|
||||
Roles = new[] { Role("target", VerseReferentKind.Faction) } },
|
||||
new VerseVerb { Name = "speak", RenderPath = VerseRenderPath.Conversation, Description = "The actor says something to whoever is in earshot. Shown as Aetheria dialogue: face to face when docked, by radio with the speaker's portrait over their ship when undocked.",
|
||||
Roles = new VerseRole[0] },
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue