Merge origin/master (594d3477) into eureka/aetheria-release-gear-without-presence

Brings gear-without-presence up to master ahead of landing it, with
eureka/aetheria-release-retire-longinusx following.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-08 18:33:40 +02:00
commit 2403a5f393
46 changed files with 2609 additions and 103 deletions

View file

@ -0,0 +1,151 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &3437702659452284539
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3879893384158149147}
- component: {fileID: 8643339631069775210}
- component: {fileID: 7703217095432408775}
- component: {fileID: 7283754118921506074}
m_Layer: 0
m_Name: Mine
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &3879893384158149147
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3437702659452284539}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &8643339631069775210
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3437702659452284539}
m_Mesh: {fileID: -5495902117074765545, guid: dab8e1a18a1b825458e8e6356cf00cd2, type: 3}
--- !u!23 &7703217095432408775
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3437702659452284539}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 38f8ba6b169127249b6532be2b3926ee, type: 2}
- {fileID: 2100000, guid: 71e1f3613633dfa488fff097aaf97660, type: 2}
- {fileID: 2100000, guid: e879c37f3520bf14a8a6075581faf7b2, type: 2}
- {fileID: 2100000, guid: 33a2546b27ff92d4fb43d5a76a9693c0, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!114 &7283754118921506074
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3437702659452284539}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 35c62f5e26769f645b1a1614a70422e1, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::MineInstance
MeshRenderer: {fileID: 7703217095432408775}
EmissionSubmesh: 2
EmissionCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 1
inSlope: 0.051179443
outSlope: 0.051179443
tangentMode: 0
weightedMode: 3
inWeight: 0
outWeight: 0.24423963
- serializedVersion: 3
time: 0.5
value: 0.1
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 3
inWeight: 0.33333334
outWeight: 0.33333334
- serializedVersion: 3
time: 1
value: 1
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 3
inWeight: 0.22580647
outWeight: 0
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
ActiveCycleDuration: 1
ArmedCycleDuration: 0.25
ActiveEmission: 100
ArmedEmission: 1000
RotationSpeed: 1
GridOffset: 0
HitEffect: {fileID: 1121192042590650, guid: e1b08032bec16ba4eb1ae23cbd572664, type: 3}
HitEffectScale: 10

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b04728b2b65e057429eb6e8529299c8a
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -16855,6 +16855,8 @@ MonoBehaviour:
type: 3}
WeaponPickup: {fileID: 8842807112802394818, guid: d2cec1d8c3d94074e9d2e6088e1c02d7,
type: 3}
MinePrefab: {fileID: 7283754118921506074, guid: b04728b2b65e057429eb6e8529299c8a,
type: 3}
MapGravityMaterials:
- {fileID: 2100000, guid: 841e4091d19548347bd59115047b4e49, type: 2}
- {fileID: 2100000, guid: a92fc6d958edb9f46b6e58134f609cc0, type: 2}

View file

@ -140,12 +140,10 @@ public static class ShipPrefabAuthoring
if (values.Length != 7) throw new InvalidOperationException($"Invalid pivot name {name}");
var pivot = AddOrGet<ArticulationPoint>(node.gameObject);
pivot.Group = ParseInt(values[1], name);
pivot.YawMin = ParseInt(values[2], name);
pivot.YawMax = ParseInt(values[3], name);
pivot.PitchMin = ParseInt(values[4], name);
pivot.PitchMax = ParseInt(values[5], name);
pivot.Speed = ParseInt(values[6], name);
if (pivot.YawMin > pivot.YawMax || pivot.PitchMin > pivot.PitchMax || pivot.Speed <= 0)
if (pivot.PitchMin > pivot.PitchMax || pivot.Speed <= 0)
throw new InvalidOperationException($"Invalid articulation range or speed in {name}");
pivots.Add(pivot);
}

View file

@ -341,6 +341,19 @@ public class ActionGameManager : MonoBehaviour
foreach (var wormhole in ZoneRenderer.WormholeInstances.Keys)
{
if (!(length(wormhole.Position - CurrentEntity.Position.xz) < Settings.GameplaySettings.WormholeExitRadius)) continue;
if (wormhole.Exit)
{
// The exit gate leads nowhere: RunGoal decides whether it is open, and an open one ends the run.
if (RunGoal.ExitOpen(Zone)) Win();
else
{
Dialog.Clear();
Dialog.Title.text = $"The exit gate is sealed. {RunGoal.Boss(Zone)?.Name ?? "Its guardian"} holds it shut.";
Dialog.Show();
Dialog.MoveToCursor();
}
return;
}
EnterWormhole(wormhole);
}
Dock();
@ -1185,9 +1198,9 @@ public class ActionGameManager : MonoBehaviour
return float3(pose.x, Settings.PlanetSettings.AsteroidVerticalOffset, pose.y);
}
private void Die(CauseOfDeath cause)
// The one way a run ends, won or lost: the run is cleared and the main menu returns.
private void EndRun()
{
var deathTime = Time.time;
UnbindEntity();
CurrentEntity = null;
MainMenu.gameObject.SetActive(true);
@ -1195,6 +1208,21 @@ public class ActionGameManager : MonoBehaviour
CurrentGalaxy = null;
RunSave.Clear(CultCache);
SavePlayerSettings();
}
private void Win()
{
EndRun();
Dialog.Clear();
Dialog.Title.text = "You have escaped the Terminus. This was the demo; the whole of it is yet to come.";
Dialog.Show();
Dialog.MoveToCursor();
}
private void Die(CauseOfDeath cause)
{
var deathTime = Time.time;
EndRun();
Observable.EveryUpdate()
.Where(_ => Time.time - deathTime < DeathPostTransitionTime)
.Subscribe(_ =>
@ -1395,7 +1423,7 @@ public class ActionGameManager : MonoBehaviour
: $"base {d.PBase:P1}";
DebugInfoText.text =
$"FIRE CONTROL - {selectedItem.Data.Name}\n" +
$"FIRE CONTROL - {ItemManager.Title(selectedItem.EquippableItem)}\n" +
$"{gates}\n" +
$"range {d.Range:F0} [{d.MinRange:F0}..{d.MaxRange:F0}]\n" +
$"info {d.Info:F3}/{d.InfoDemandCeiling:F3} sensor {d.PSensor:F3}\n" +

View file

@ -4,15 +4,14 @@ using UnityEngine;
using CultMath;
using static CultMath.math;
// Presentation only: the barrel follows the point its group's solution places (EntityInstance), which FireControl has
// already clamped to the arc. This decides no arc; Speed and the pitch clamp are how the picture slews.
public class ArticulationPoint : MonoBehaviour
{
public Transform Target;
public int Group;
public float YawMin;
public float YawMax;
public float PitchMin;
public float PitchMax;
@ -30,7 +29,7 @@ public class ArticulationPoint : MonoBehaviour
var yaw = Vector2.SignedAngle(new Vector2(0, 1), new Vector2(targetLocal.x, targetLocal.z));
var pitch = Vector2.SignedAngle(new Vector2(1, 0), new Vector2(targetLocal.z, targetLocal.y));
var targetYaw = clamp(_yaw - yaw, YawMin, YawMax);
var targetYaw = _yaw - yaw;
var targetPitch = clamp(_pitch - pitch, PitchMin, PitchMax);
if (abs(targetYaw - _yaw) < Speed * Time.deltaTime)

View file

@ -64,6 +64,7 @@ public class EntityInstance : MonoBehaviour
public Dictionary<HardpointData, int> BarrelIndices { get; private set; }
public Dictionary<Radiator, MeshRenderer> RadiatorMeshes { get; private set; }
public Transform LookAtPoint { get; private set; }
private Dictionary<int, Transform> _groupAims;
public Entity Entity { get; private set; }
public ZoneRenderer ZoneRenderer { get; private set; }
public Transform LocalSpace { get; private set; }
@ -186,7 +187,8 @@ public class EntityInstance : MonoBehaviour
sensor.OnPingEnd += OnSensorPingEnd;
}
if (behavior is InstantWeapon instantWeapon)
// A mine layer's presentation is the mine it lays (MineInstance, drawn by ZoneRenderer from Zone.Mines).
if (behavior is InstantWeapon instantWeapon && !FireControl.IsMineLayer(instantWeapon))
{
var data = (InstantWeaponData) instantWeapon.Data;
if (!_instantWeaponManagers.ContainsKey(data))
@ -276,10 +278,14 @@ public class EntityInstance : MonoBehaviour
// HullColliders no longer publish Hit or a blast event at all (R8).
LookAtPoint = new GameObject($"{entity.Name} Look Point").transform;
// One aim point per articulation group, placed each LateUpdate where the group's first weapon aims.
_groupAims = new Dictionary<int, Transform>();
foreach (var articulationPoint in ArticulationPoints)
{
articulationPoint.Target = LookAtPoint;
if (!_groupAims.TryGetValue(articulationPoint.Group, out var aim))
_groupAims[articulationPoint.Group] = aim = new GameObject($"{entity.Name} Aim {articulationPoint.Group}").transform;
articulationPoint.Target = aim;
}
_subscriptions.Add(Entity.HullDamage.Subscribe(_ =>
@ -398,8 +404,18 @@ public class EntityInstance : MonoBehaviour
x.Value.material.SetFloat("_Emission", Entity.ItemManager.GameplaySettings.TemperatureEmissionCurve.Evaluate(x.Key.RadiatorTemperature));
}
LookAtPoint.position = transform.position + Entity.Aim.ToUnity() *
(!Entity.Target.Value.IsNone ? max(Entity.TargetRange,Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance) : 10000);
var aimDistance = !Entity.Target.Value.IsNone
? max(Entity.TargetRange, Entity.ItemManager.GameplaySettings.ConvergenceMinimumDistance)
: 10000;
LookAtPoint.position = transform.position + Entity.Aim.ToUnity() * aimDistance;
foreach (var group in _groupAims)
{
var weapon = group.Key >= 0 && group.Key < Entity.WeaponGroups.Length ? Entity.WeaponGroups[group.Key].weapons.FirstOrDefault() : null;
if (weapon == null) { group.Value.position = LookAtPoint.position; continue; }
var barrel = Barrels.TryGetValue(weapon.Item.Hardpoint, out var barrels) ? barrels[0].position : transform.position;
var direction = FireControl.Solution(weapon, Entity, Entity.Target.Value).Direction;
group.Value.position = barrel + new Vector3(direction.x, 0, direction.y) * aimDistance;
}
LocalSpace.localPosition = transform.position = Entity.Position.ToUnity();
if (_influenceInstance)
_influenceInstance.position = new Vector3(Entity.Position.x, 0, Entity.Position.z);

View file

@ -0,0 +1,74 @@
using CultMath;
using CultMath.UnityBridge;
using UnityEngine;
using static CultMath.math;
using Random = UnityEngine.Random;
// How one laid mine looks. Zone owns the mine's every fact (position, arming, trigger, end); this reads them and
// draws, and writes nothing back. The pose is the latest sim body, spun for show; the pulse runs in real time
// (rulings unity-presents-only-r3 and sim-speed-presentation).
public class MineInstance : MonoBehaviour
{
public MeshRenderer MeshRenderer;
public int EmissionSubmesh = 2;
public AnimationCurve EmissionCurve;
public float ActiveCycleDuration = 1f;
public float ArmedCycleDuration = .25f;
public float ActiveEmission = 100f;
public float ArmedEmission = 1000f;
public float RotationSpeed = 1f;
public float GridOffset;
// The blast's particles; the effect prefab destroys itself when its particle system stops.
public GameObject HitEffect;
public float HitEffectScale = 10f;
private Zone _zone;
private Mine _mine;
private Material _material;
private float _timeOffset;
private float _pulse;
private float _emission;
public void Bind(Zone zone, Mine mine)
{
_zone = zone;
_mine = mine;
_material = MeshRenderer.materials[EmissionSubmesh];
_timeOffset = Random.value * 100;
Place();
}
private void Update()
{
Place();
transform.localRotation = Quaternion.Euler(
sin(Time.time - _timeOffset * RotationSpeed) * 90, 0, cos(Time.time - _timeOffset * RotationSpeed) * 90);
// None before the mine arms, then slow while it waits and fast once its fuse is lit.
var armed = _mine.Armed(_zone.Time);
var counting = _mine.TriggeredAt != null;
var target = !armed ? 0f : counting ? ArmedEmission : ActiveEmission;
_emission = lerp(_emission, target, Time.deltaTime * 10);
_pulse = (_pulse + Time.deltaTime / (counting ? ArmedCycleDuration : ActiveCycleDuration)) % 1;
_material.SetFloat("_Emission", _emission * EmissionCurve.Evaluate(_pulse));
}
private void Place()
{
var p = _mine.Body.Position;
transform.position = new Vector3(p.x, _zone.GetHeight(p) + GridOffset, p.y);
}
// The mine left Zone.Mines, which only its blast does: show it where it was drawn last.
public void Blast()
{
var effect = Instantiate(HitEffect, transform.position, Quaternion.identity);
effect.transform.localScale = Vector3.one * HitEffectScale;
Destroy(gameObject);
}
private void OnDestroy()
{
if (_material) Destroy(_material);
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 35c62f5e26769f645b1a1614a70422e1

View file

@ -112,14 +112,10 @@ public abstract class Weapon : Behavior, IActivatedBehavior
// so a weapon is never safed against one.
public bool StanceAllowsFire => Entity.Target.Value.Entity == null || Entity.IsHostileTo(Entity.Target.Value.Entity);
// Cut 3 (docs/fire-control-cut.md, Q2): player fire is arc-gated like everyone else's. This is the same
// predicate (FireControl.InArc) Combat.cs and TurretController.cs gate AI and turret fire with, read at
// the one point every shooter's trigger passes through (InstantWeapon.Trigger) -- not a parallel check, and
// not the AI's own AgentMinHitProbability heuristic, which is a "worth it" decision layered above this
// gate, not the gate itself. With no target set, behaviour is unchanged (nothing to bear on).
// Cut 5, 5.4 (Soul finding 11): the point-blank special case this used to carry moved into InArc itself --
// one bearing test, one owner, so every caller (not just this one) gets a point-blank shot that bears.
// FireControl owns the gate, including its one exemption: a fused weapon fires out of arc.
// The trigger gate for player and AI shooters alike, read at the one point every shooter's trigger passes
// through (InstantWeapon.Trigger). A gun fires on its solution (FireControl.Solution) without the aim, else
// along the aim when the aim is inside its arc, else it holds. An AI shooter's aim is its intercept, so its
// fire is unchanged. FireControl owns the gate, including its exemption: a fused weapon fires out of arc.
public bool ArcAllowsFire => FireControl.ArcPermitsFire(this, Entity);
public Weapon(WeaponData data, EquippedItem item) : base(data, item)

View file

@ -14,8 +14,13 @@ using int2 = CultMath.int2;
public abstract class Entity
{
// Every hull cell's temperature (kelvin) when the entity is built.
public const float StartingCellTemperature = 280;
public Zone Zone;
public Faction Faction;
// The mark of a zone's boss, written only by ZoneGenerator and restored from the pack. RunGoal reads it.
public bool IsBoss;
public EquippableItem Hull;
public EquippedItem EquippedHull;
@ -911,7 +916,7 @@ public abstract class Entity
Armor[v.x, v.y] += Hardpoints[v.x, v.y].Armor;
MaxArmor[v.x, v.y] += Hardpoints[v.x, v.y].Armor;
}
Temperature[v.x, v.y] = 280;
Temperature[v.x, v.y] = StartingCellTemperature;
ThermalMass[v.x, v.y] = hullData.Mass * hullData.SpecificHeat / cellCount;
}
GearOccupancy = new EquippedItem[hullData.Shape.Width, hullData.Shape.Height];

View file

@ -28,6 +28,7 @@ public static class EntitySerializer
else throw new ArgumentException("Attempted to pack an instance of abstract class Entity!");
pack.Settings = entity.Settings;
pack.Boss = entity.IsBoss;
pack.Hull = entity.Hull;
pack.Name = entity.Name;
@ -81,6 +82,7 @@ public static class EntitySerializer
private static void Restore(ItemManager itemManager, Zone zone, EntityPack pack, Entity entity)
{
entity.Name = pack.Name;
entity.IsBoss = pack.Boss;
entity.Faction = itemManager.ItemData.Get(pack.Faction);
entity.Children = pack.Children.Select(c =>
{
@ -187,6 +189,8 @@ public abstract class EntityPack
[Key(14)] public EntitySettings Settings;
[Key(15)] public CultRecordRef<Faction> Faction;
[Key(16)] public int[][] WeaponGroups;
// Keys 17-20 belong to the subclasses; the base's next free key is 21.
[Key(21)] public bool Boss;
private int _price;

View file

@ -67,24 +67,18 @@ public static class FireControl
return blastRadius > 0f ? data.Fuse : null;
}
// The one arc gate on the trigger. A fused weapon fires whatever the arc says: a target it cannot bear on is
// no reason to keep it silent, the round flies along the arc-clamped aim and bursts at the target's range
// (Fire; operator ruling 2026-09-30, "the user is hoping that the arc gets the weapon close enough to still
// splash some damage"). A weapon that only hits what it fires at still refuses. Fire reads InArc itself to
// choose that flight, so the two share the one bearing test and differ only in this exemption.
// Mining Cut 3: a chunk target bears through its own position, on the plane the bearing test reads.
// The one gate on the trigger (operator rulings fire-on-solutions, controls-solution-trigger, 2026-10-06). A gun
// fires on its solution without the aim, else free along the aim if the aim is inside its arc, else it holds.
// A fused weapon fires whatever the arc says: a target it cannot bear on is no reason to keep it silent, the
// round flies along the arc-clamped aim and bursts at the target's range (Fire; operator ruling 2026-09-30,
// "the user is hoping that the arc gets the weapon close enough to still splash some damage"). A mine layer
// lays a body, not a round, and is never arc-gated. The price of a free shot at a target that does not bear is
// HitProbability's: zero.
public static bool ArcPermitsFire(Weapon weapon, Entity shooter)
{
var target = shooter.Target.Value;
if (target.IsNone) return true;
float3 toTarget;
if (target.Chunk is ChunkId chunk)
{
var at = shooter.Zone.ChunkPose(chunk.Field, chunk.Index).xy;
toTarget = float3(at.x, 0, at.y) - shooter.Position;
}
else toTarget = target.Entity.Position - shooter.Position;
return FuseOf(weapon.Item, out _) != null || IsMineLayer(weapon) || InArc(weapon.Item, toTarget);
if (FuseOf(weapon.Item, out _) != null || IsMineLayer(weapon)) return true;
var solution = Solution(weapon, shooter, shooter.Target.Value);
return solution.Bears || solution.Free;
}
// Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so
@ -131,6 +125,33 @@ public static class FireControl
return float2(mount.x * cos(turn) - mount.y * sin(turn), mount.x * sin(turn) + mount.y * cos(turn));
}
// The one owner of a gun's solution against its subject (the designated target; ruling controls-solution-subject).
// Bears: the gun can engage the subject, so the player fires on it without the aim. Free: the aim is inside the
// arc, so the gun can fire along it. Direction: where a round from this gun travels and where its barrel points --
// the subject's intercept when it bears, else the arc-clamped aim. A chunk subject bears through its pose and
// flies the aim, as an untargeted round (Subject is null for it). Derived on every read and never stored: turrets
// traverse instantly in the simulation. Traverse speed belongs to the link (ruling arc-and-traverse-on-the-link),
// which will make this stateful behind the same signature. It never prices; HitProbability is the one price.
public static GunSolution Solution(Weapon weapon, Entity shooter, TargetRef subject)
{
var bears = false;
if (subject.Entity != null) bears = Bears(weapon, shooter, subject.Entity, out _);
else if (subject.Chunk is ChunkId chunk)
{
var at = shooter.Zone.ChunkPose(chunk.Field, chunk.Index).xy;
bears = InArc(weapon.Item, float3(at.x, 0, at.y) - shooter.Position);
}
return new GunSolution
{
Subject = subject.Entity,
Bears = bears,
Free = InArc(weapon.Item, shooter.Aim),
Direction = bears && subject.Entity != null
? TravelDirection(weapon, shooter, subject.Entity)
: AimDirection(weapon.Item, shooter)
};
}
// Cut 2, R5: whether `observer` has resolved `item` (which must belong to some entity in the zone) well
// enough to aim at it. Derived fresh from EntityInfoGathered on every call -- nothing caches a reveal
// result, so decay drops it the moment info falls back below tier, with no clearing loop anywhere.
@ -276,21 +297,26 @@ public static class FireControl
!(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked);
}
// `designated` is the answer above. `inArc` is the bearing test, asked only once a target is designated.
// Whether this gun can engage this target: designated and inside the arc. The one test of it; PFire prices a shot
// at zero unless it holds, and Solution reads it to decide whether the gun fires on the target. `designated` is
// the answer above, kept apart for Solve's burst flight: a designated target out of arc still sets the burst range.
public static bool Bears(Weapon weapon, Entity source, Entity target, out float range) =>
Bears(weapon, source, target, out range, out _);
private static bool Bears(Weapon weapon, Entity source, Entity target, out float range, out bool designated)
{
designated = Designated(weapon, source, target, out range);
return designated && InArc(weapon.Item, target.Position - source.Position);
}
// `designated` is the answer above. `inArc` is Bears, asked only once a target is designated.
// PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at
// (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but
// out of arc it bursts at the target's range, along the aim. The HUD reads Designated and the
// arc from Solve and Inspect and decides nothing.
private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc)
{
range = 0f;
designated = false;
inArc = false;
if (target == null) return 0f;
designated = Designated(weapon, source, target, out range);
if (!designated) return 0f;
inArc = InArc(weapon.Item, target.Position - source.Position);
inArc = Bears(weapon, source, target, out range, out designated);
if (!inArc) return 0f;
var settings = source.ItemManager.GameplaySettings;
@ -615,7 +641,7 @@ public static class FireControl
// burst's later rounds and a power-starved Range included).
var engaged = fuse != null && !(designated && inArc) ? null : target;
var origin = source.Position;
var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source);
var travelDirection = Solution(weapon, source, target).Direction;
var arming = fuse != null ? blastRadius : 0f;
var burstPosition = engaged != null ? engaged.Position : origin;
@ -1989,6 +2015,16 @@ public enum FireOutcome
Refused
}
// A gun's solution against its subject (FireControl.Solution): whether the gun bears on the subject, whether the
// aim is free (inside the arc), and the direction a round from it travels.
public struct GunSolution
{
public Entity Subject;
public bool Bears;
public bool Free;
public float2 Direction;
}
// Solve's answer. Outcome and BurstReach (planar distance from the shooter to the burst point, read for Burst
// only) are what the HUD shows; the rest is what Fire freezes into the PendingShot.
public struct FireSolution

View file

@ -24,7 +24,7 @@ public class Galaxy
public Faction[] Factions { get; }
public GalaxyZone[] Zones { get; }
public GalaxyZone Entrance { get; }
public GalaxyZone Exit { get; }
public GalaxyZone Exit { get; private set; }
public Dictionary<Faction, FactionRelationship> FactionRelationships { get; } = new Dictionary<Faction, FactionRelationship>();
private Action<string> Log { get; }
public bool IsPrelude { get; }
@ -142,6 +142,18 @@ public class Galaxy
return _cache.GetAll<Faction>().FirstOrDefault(f => f.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase));
}
// A cast member of a prelude galaxy, by name. A name the catalog does not hold refuses the galaxy, naming the field.
private Faction ResolveCast(string field, string name) =>
ResolveFaction(name) ?? throw new InvalidOperationException($"cast faction {name} ({field}) is not in the catalog");
// Gives a prelude galaxy its gate: the antagonist's home is the boss zone and the exit. The only writer of Exit
// besides the constructors.
public void PlaceGate(Faction antagonist)
{
BossZones[antagonist] = HomeZones[antagonist];
Exit = HomeZones[antagonist];
}
// How many jumps a faction's influence reaches in this galaxy: its authored distance, halved (rounding up) in a
// prelude. Derived, never written back to the catalog's Faction, so one galaxy's generation cannot change the next.
public int InfluenceOf(Faction faction) =>
@ -168,20 +180,23 @@ public class Galaxy
var factions = new List<Faction>();
var protagonistFaction = ResolveFaction(settings.ProtagonistFaction);
var protagonistFaction = ResolveCast(nameof(settings.ProtagonistFaction), settings.ProtagonistFaction);
factions.Add(protagonistFaction);
var antagonistFaction = ResolveFaction(settings.AntagonistFaction);
var antagonistFaction = ResolveCast(nameof(settings.AntagonistFaction), settings.AntagonistFaction);
factions.Add(antagonistFaction);
var bufferFaction = ResolveFaction(settings.BufferFaction);
var bufferFaction = ResolveCast(nameof(settings.BufferFaction), settings.BufferFaction);
factions.Add(bufferFaction);
var questFaction = ResolveFaction(settings.QuestFaction);
factions.Add(questFaction);
// A cast without a quest faction places no quest zone.
var questFaction = string.IsNullOrEmpty(settings.QuestFaction)
? null
: ResolveCast(nameof(settings.QuestFaction), settings.QuestFaction);
if (questFaction != null) factions.Add(questFaction);
var neutralFactions = settings.NeutralFactions
.Select(ResolveFaction)
.Select(name => ResolveCast(nameof(settings.NeutralFactions), name))
.ToArray();
factions.AddRange(neutralFactions);
@ -222,17 +237,20 @@ public class Galaxy
CalculateFactionInfluence(progressCallback);
var potentialQuestZones = Zones
.Where(z => z.Factions.Contains(antagonistFaction) && z.Factions.Contains(bufferFaction));
if (potentialQuestZones.Any())
HomeZones[questFaction] = potentialQuestZones
.MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, InfluenceOf(questFaction)).Count);
else
HomeZones[questFaction] = Zones
.Where(z => z.Factions.Contains(antagonistFaction))
.MinBy(z=>z.Distance[HomeZones[bufferFaction]]);
CalculateFactionInfluence(progressCallback);
if (questFaction != null)
{
var potentialQuestZones = Zones
.Where(z => z.Factions.Contains(antagonistFaction) && z.Factions.Contains(bufferFaction));
if (potentialQuestZones.Any())
HomeZones[questFaction] = potentialQuestZones
.MaxBy(z => z.Distance[HomeZones[antagonistFaction]] * ConnectedRegion(z, InfluenceOf(questFaction)).Count);
else
HomeZones[questFaction] = Zones
.Where(z => z.Factions.Contains(antagonistFaction))
.MinBy(z=>z.Distance[HomeZones[bufferFaction]]);
CalculateFactionInfluence(progressCallback);
}
Entrance = Zones.Where(z => z.Owner == null).MinBy(z => z.Distance[HomeZones[protagonistFaction]]);

View file

@ -68,7 +68,10 @@ public sealed class GalaxyStage
}
// A prelude galaxy: the tutorial settings and their fixed factions, centred in dense cloud.
public Galaxy Prelude()
public Galaxy Prelude() => Prelude(_prelude);
// A prelude galaxy of the given fixed cast, centred in dense cloud.
public Galaxy Prelude(TutorialGenerationSettings cast)
{
var background = Copy(_preludeBackground);
var random = new Random(Seed);
@ -78,7 +81,7 @@ public sealed class GalaxyStage
background.NoisePosition = random.NextFloat() * 1000;
_progress?.Invoke($"Finding Galaxy Position: iteration {iteration++}");
} while (background.CloudDensity(float2(.5f)) < .5f);
return new Galaxy(_prelude, background, _names, _cache, _playerSettings, _narrativeDirectory, _log, _progress, Seed);
return new Galaxy(cast, background, _names, _cache, _playerSettings, _narrativeDirectory, _log, _progress, Seed);
}
private static SectorBackgroundSettings Copy(SectorBackgroundSettings settings) =>

View file

@ -221,6 +221,11 @@ public class ItemManager
return (ItemData.Get(maker), product);
}
// What the player reads an item as: its product's name when its lot's maker has a product of this design,
// otherwise the design's own name (unbranded, console-given and extracted lots, and commodities).
public string Title(ItemInstance item) =>
(item is CraftedItemInstance crafted ? Brand(crafted).Product?.Name : null) ?? GetData(item).Name;
public (RarityTier tier, int upgrades) GetTier(CraftedItemInstance item)
{
var quality = GetLot(item).Quality;

View file

@ -140,6 +140,18 @@ public class LoadoutGenerator
// required item falls back to any manufacturer when none of those make one, which means the product table
// lacks variety, so it is logged rather than hidden.
public (FactionProductData product, T design)[] RandomProducts<T>(int count, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
{
var (available, preferManufacturers) = AvailableProducts(filter, required);
return available.WeightedRandomElements(ref Random, entry =>
(preferManufacturers ? ManufacturerPreference(entry.product.Manufacturer) : 1) *
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
count);
}
// The products this faction can be offered for a design kind, and whether manufacturer preference applies (it does
// not once a required item has fallen back to any manufacturer). The one availability rule for every selection.
private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts<T>(Predicate<T> filter, bool required) where T : EquippableItemData
{
var hulls = ItemManager.ItemData.GetAll<HullData>().ToArray();
var candidates = ItemManager.ItemData.GetAll<FactionProductData>()
@ -160,11 +172,27 @@ public class LoadoutGenerator
preferManufacturers = false;
}
return available.WeightedRandomElements(ref Random, entry =>
(preferManufacturers ? ManufacturerPreference(entry.product.Manufacturer) : 1) *
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
count);
return (available, preferManufacturers);
}
// A faction's boss: its BossHull when the faction sets one and a product sells it, else the most expensive ship hull
// the faction can be offered, with the gap logged. Null only when no ship hull is on offer at all.
public EntityPack GenerateBossLoadout()
{
var bossHull = Faction != null && Faction.BossHull.IsSet() ? ItemManager.ItemData.Get(Faction.BossHull) : null;
if (bossHull != null)
{
var boss = GenerateShipLoadout(hull => hull == bossHull);
if (boss != null) return boss;
ItemManager.Log($"{Faction.Name}'s BossHull {bossHull.Name} is not sold by any product; its boss flies the most expensive ship hull instead.");
}
else
ItemManager.Log($"{Faction?.Name ?? "This faction"} has no BossHull; its boss flies the most expensive ship hull instead.");
var (available, _) = AvailableProducts<HullData>(hull => hull.HullType == HullType.Ship, required: true);
if (available.Length == 0) return null;
var priciest = available.OrderByDescending(entry => entry.design.Price).ThenBy(entry => entry.design.Name, StringComparer.Ordinal).First().design;
return GenerateShipLoadout(hull => hull == priciest);
}
// Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a

View file

@ -0,0 +1,42 @@
using System.Linq;
using CultMath;
using static CultMath.math;
// The run's goal: whether a zone's boss lives, and so whether the exit gate is open. Engine-free and stateless; the
// answer is derived from Zone.Entities (the death path removes the dead, Zone.cs), so no "boss defeated" flag exists to
// disagree with it, and no presentation decides it.
public static class RunGoal
{
public static Entity Boss(Zone zone) => zone.Entities.FirstOrDefault(entity => entity.IsBoss);
public static bool BossAlive(Zone zone) => Boss(zone) != null;
// The exit gate fails closed: it is open only in the galaxy's exit zone, once a boss was spawned there and no living
// boss remains. An exit zone that never got a boss stays sealed, so a missing boss can never read as a dead one.
public static bool ExitOpen(Zone zone) =>
zone.Galaxy?.Exit != null && zone.GalaxyZone == zone.Galaxy.Exit && zone.Pack.BossSpawned && !BossAlive(zone);
// Where the exit gate sits: at the adjacency wormholes' radius (the zone's radius times the ratio), in the middle of
// the widest angular gap between them, so it shares a point with none. A zone with no neighbours gets the +x axis.
public static float2 ExitGatePosition(Zone zone, float wormholeDistanceRatio)
{
var radius = zone.Pack.Radius * wormholeDistanceRatio;
var angles = zone.GalaxyZone.AdjacentZones
.Select(adjacent => normalize(adjacent.Position - zone.GalaxyZone.Position))
.Select(direction => atan2(direction.y, direction.x))
.OrderBy(angle => angle)
.ToArray();
if (angles.Length == 0) return float2(radius, 0);
var bestAngle = 0f;
var bestGap = -1f;
for (var i = 0; i < angles.Length; i++)
{
var next = i + 1 < angles.Length ? angles[i + 1] : angles[0] + 2 * PI;
if (next - angles[i] <= bestGap) continue;
bestGap = next - angles[i];
bestAngle = angles[i] + bestGap / 2;
}
return float2(cos(bestAngle), sin(bestAngle)) * radius;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: de03c3545cb04ce38bf85b713dc5df74

View file

@ -4,8 +4,9 @@ using static ItemRotation;
public sealed class Arcs : Scenario
{
public override string Name => "Arcs";
public override string Brief => "Forward mounts, a bare hull off the bow, one off the stern, a hostile turret. Verify: the bow " +
"hull can be hit, nothing fires at the stern one through the ship, the turret tracks you all the way round.";
public override string Brief => "Forward mounts, a bare hull off the bow, one off the stern, a hostile turret. Verify: the hull keeps its " +
"heading while you aim at the stern hull, and the forward guns hold. Designate the bow hull and fire with the " +
"aim anywhere: the forward guns fire on it and hit. The turret tracks you all the way round.";
public override uint Seed => 103;
public override bool Ambient => false;

View file

@ -0,0 +1,41 @@
using float2 = CultMath.float2;
// The demo: one gate, one region and one boss, with a fixed cast. The Pirates are the protagonist's ostensible
// employer, Zhestokost the antagonist whose home is the boss zone and the exit, Lucent Media and Aeronautics Unlimited
// the neutrals. A prelude galaxy of that cast; the player, who is not a Pirate, flies the base Longinus.
public sealed class DemoTerminus : Scenario
{
public static readonly TutorialGenerationSettings Cast = new TutorialGenerationSettings
{
ProtagonistFaction = "Pirates",
AntagonistFaction = "Zhe",
BufferFaction = "Luc",
NeutralFactions = new[] { "Aero" },
QuestFaction = null,
ZoneCount = 64,
LinkDensity = .5f
};
public override string Name => "Terminus (Demo)";
public override string Brief => "One gate, one region, one boss. Allied: the Pirates. Opposed: Zhestokost. " +
"Neutral: Lucent Media and Aeronautics Unlimited.";
public override Galaxy Generate(GalaxyStage stage)
{
var galaxy = stage.Prelude(Cast);
galaxy.PlaceGate(galaxy.ResolveFaction(Cast.AntagonistFaction));
return galaxy;
}
public override void Stage(ScenarioStage stage)
{
var galaxy = stage.Galaxy;
var pirates = galaxy.ResolveFaction(Cast.ProtagonistFaction);
galaxy.FactionRelationships[pirates] = FactionRelationship.Friendly;
galaxy.FactionRelationships[galaxy.ResolveFaction(Cast.AntagonistFaction)] = FactionRelationship.Hated;
galaxy.FactionRelationships[galaxy.ResolveFaction(Cast.BufferFaction)] = FactionRelationship.Neutral;
foreach (var neutral in Cast.NeutralFactions)
galaxy.FactionRelationships[galaxy.ResolveFaction(neutral)] = FactionRelationship.Neutral;
stage.Player(stage.Generated("Longinus"), float2.zero);
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5ba7152d3c8b46a98924a4148be550db
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,8 +1,11 @@
// Every way to set up a new run, in the order New Game lists them. Modes are the game; Tests are arenas built to check
// one system each, listed only in editor and development builds.
// Every way to set up a new run, in the order New Game lists them. Modes are what a release build lists; Development
// are the other ways the game sets up a run and Tests are arenas built to check one system each, both listed only in
// editor and development builds.
public static class Scenarios
{
public static readonly Scenario[] Modes = { new TutorialGalaxy(), new MainGalaxy() };
public static readonly Scenario[] Modes = { new DemoTerminus() };
public static readonly Scenario[] Development = { new TutorialGalaxy(), new MainGalaxy() };
public static readonly Scenario[] Tests =
{

View file

@ -4,4 +4,6 @@ public class Wormhole
{
public float2 Position;
public GalaxyZone Target;
// The run's exit gate: it leads nowhere (Target is null) and ends the run when RunGoal.ExitOpen.
public bool Exit;
}

View file

@ -190,6 +190,7 @@ public class Zone
Orbits = Orbits.Keys.Select(key => new CultRecordRef<OrbitData>(key)).ToList(),
Planets = Planets.Keys.Select(key => new CultRecordRef<BodyData>(key)).ToList(),
Time = _time,
BossSpawned = Pack.BossSpawned,
// Cut 2 (docs/mining-cut.md): drop entries that carry no live information -- healed (no damage) and
// not currently broken -- so a save never accumulates wear rows for chunks nobody has touched since
// they last respawned.

View file

@ -43,6 +43,11 @@ public class ZonePack
// default instance.
[JsonProperty("chunkWear"), Key(6)]
public List<ChunkWearPack> ChunkWear;
// True once this zone's boss has been spawned. The dead boss leaves no entity, so this is the one fact that tells a
// slain boss from one that never existed; RunGoal.ExitOpen reads it. Written only by ZoneGenerator, carried by PackZone.
[JsonProperty("bossSpawned"), Key(7)]
public bool BossSpawned;
}
// Cut 2 (docs/mining-cut.md): one packed wear entry for one chunk. "Field" names the body that owns the chunk

View file

@ -337,6 +337,24 @@ public static class ZoneGenerator
if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2);
}
// A boss zone gets its faction's boss whether or not the zone is ambient: the boss is the run's goal, not
// wandering traffic. RunGoal reads the mark; nothing else decides whether a boss lives.
foreach (var bossFaction in galaxy.BossZones.Where(entry => entry.Value == galaxyZone).Select(entry => entry.Key))
{
var boss = GetLoadoutGenerator(bossFaction).GenerateBossLoadout();
if (boss == null)
{
itemManager.Log($"No boss could be generated for {bossFaction.Name}; its boss zone has no boss to kill!");
continue;
}
boss.Boss = true;
pack.Entities.Add(boss);
pack.BossSpawned = true;
}
if (galaxyZone == galaxy.Exit && !pack.BossSpawned)
itemManager.Log("Galaxy.Exit has no boss: Galaxy.BossZones does not map a boss faction to the exit zone, or its boss could not be generated (ZoneGenerator exit-without-boss). The exit gate stays sealed.");
if (!ambient) return pack;
var enemyCount = (int)(random.NextFloat() * factionPresence * 2) + baseStationCount;

View file

@ -127,8 +127,8 @@ public class MainMenu : MonoBehaviour
_nextMenu.panel.AddButton("Quit", Application.Quit);
}
// New Game: every way to set up a run, each with its brief. The test arenas are listed only in editor and
// development builds.
// New Game: every way to set up a run, each with its brief. A release build lists only the modes; the other ways
// to set up a run and the test arenas are listed only in editor and development builds.
private void ShowScenarios()
{
_nextMenu.panel.Clear();
@ -136,6 +136,8 @@ public class MainMenu : MonoBehaviour
AddScenarios(Scenarios.Modes);
if (Debug.isDebugBuild)
{
_nextMenu.panel.AddSection("development");
AddScenarios(Scenarios.Development);
_nextMenu.panel.AddSection("test arenas");
AddScenarios(Scenarios.Tests);
}

View file

@ -192,9 +192,9 @@ public class TradeMenu : MonoBehaviour
columns.Add(("Name", 3,
x => () => x.item is CraftedItemInstance craftedItemInstance ?
$"<color=#{ColorUtility.ToHtmlStringRGB(GameManager.ItemManager.GetTier(craftedItemInstance).tier.Color.ToColor())}>{x.data.Name}" :
x.data.Name,
x => x.data.Name));
$"<color=#{ColorUtility.ToHtmlStringRGB(GameManager.ItemManager.GetTier(craftedItemInstance).tier.Color.ToColor())}>{GameManager.ItemManager.Title(x.item)}" :
GameManager.ItemManager.Title(x.item),
x => GameManager.ItemManager.Title(x.item)));
if(_hardpointFilter.filter==null)
columns.Add(("Type", 2,
x => () =>
@ -352,7 +352,7 @@ public class TradeMenu : MonoBehaviour
{
int quantity = 1;
Dialog.Clear();
Dialog.Title.text = $"Buying {i.data.Name}";
Dialog.Title.text = $"Buying {GameManager.ItemManager.Title(i.item)}";
Dialog.AddField("Quantity",
() => quantity,
q => quantity = min(min(q, GameManager.Credits / StationServices.BuyPrice(GameManager.ItemManager, s)), s.Quantity));

View file

@ -376,7 +376,6 @@ public class PropertiesPanel : MonoBehaviour
sheet.AddStat("Manufacturer", () => maker.Name);
if (product != null)
{
AddProperty(product.Name);
AddProperty(product.Description);
}
}
@ -430,10 +429,9 @@ public class PropertiesPanel : MonoBehaviour
private string GetTitle(EquippableItem item)
{
var data = GameManager.ItemManager.GetData(item);
var (tier, upgrades) = GameManager.ItemManager.GetTier(item);
return
$"<color=#{ColorUtility.ToHtmlStringRGB(tier.Color.ToColor())}>{data.Name}</color><smallcaps><size=60%> ({tier.Name}{new string('+', upgrades)})";
$"<color=#{ColorUtility.ToHtmlStringRGB(tier.Color.ToColor())}>{GameManager.ItemManager.Title(item)}</color><smallcaps><size=60%> ({tier.Name}{new string('+', upgrades)})";
}
public void Inspect(EquippedItem item)

View file

@ -44,6 +44,7 @@ public class ZoneRenderer : MonoBehaviour
public GridObject CompoundCommodityPickup;
public GridObject GearPickup;
public GridObject WeaponPickup;
public MineInstance MinePrefab;
public Material[] MapGravityMaterials;
[Header("Tour")] public bool Tour;
@ -93,6 +94,7 @@ public class ZoneRenderer : MonoBehaviour
public Dictionary<Wormhole, (GameObject gravity, CompassIcon icon)> WormholeInstances = new Dictionary<Wormhole, (GameObject, CompassIcon)>();
private List<ItemPickup> _loot = new List<ItemPickup>();
private Dictionary<Mine, MineInstance> _mines = new Dictionary<Mine, MineInstance>();
public Zone Zone { get; private set; }
public ItemManager ItemManager { get; set; }
@ -222,6 +224,16 @@ public class ZoneRenderer : MonoBehaviour
UnloadEntity(e.Value);
}));
// Every mine, whoever laid it, is drawn through this one subscription. A mine leaves Zone.Mines only by
// its blast, so a removal plays the blast; ClearZone below destroys the rest without one.
foreach (var mine in zone.Mines) LoadMine(zone, mine);
_zoneSubscriptions.Add(zone.Mines.ObserveAdd().Subscribe(e => LoadMine(zone, e.Value)));
_zoneSubscriptions.Add(zone.Mines.ObserveRemove().Subscribe(e =>
{
if (!_mines.Remove(e.Value, out var instance)) return;
instance.Blast();
}));
if (zone.GalaxyZone != null)
{
foreach (var adjacentZone in zone.GalaxyZone.AdjacentZones)
@ -233,9 +245,24 @@ public class ZoneRenderer : MonoBehaviour
Position = dir * zone.Pack.Radius * Settings.WormholeDistanceRatio
});
}
// The run's exit gate is presentation only: whether it opens is RunGoal.ExitOpen, read when it is used.
if (zone.GalaxyZone == zone.Galaxy.Exit)
AddWormhole(new Wormhole
{
Exit = true,
Position = RunGoal.ExitGatePosition(zone, Settings.WormholeDistanceRatio)
});
}
}
private void LoadMine(Zone zone, Mine mine)
{
var instance = Instantiate(MinePrefab);
instance.Bind(zone, mine);
_mines.Add(mine, instance);
}
public void AddWormhole(Wormhole wormhole)
{
var instance = Instantiate(WormholePrefab);
@ -259,6 +286,10 @@ public class ZoneRenderer : MonoBehaviour
subscription.Dispose();
_zoneSubscriptions.Clear();
foreach (var instance in _mines.Values)
if (instance) Destroy(instance.gameObject);
_mines.Clear();
foreach(var gridObject in _loot)
if(gridObject) Destroy(gridObject.gameObject);
_loot.Clear();
@ -574,7 +605,7 @@ public class ZoneRenderer : MonoBehaviour
var itemPickup = gridObject.gameObject.GetComponent<ItemPickup>();
itemPickup.Item = item;
itemPickup.ZoneRenderer = this;
itemPickup.ScanLabel.text = ItemManager.GetData(item).Name;
itemPickup.ScanLabel.text = ItemManager.Title(item);
if (item is CraftedItemInstance craftedItemInstance)
{
var c = ItemManager.GetTier(craftedItemInstance).tier.Color.ToColor();

BIN
GameData/Aetheria.cc (Stored with Git LFS)

Binary file not shown.

View file

@ -377,6 +377,373 @@ Counts: demo 52 (31 specs, 11 of them landed, and 21 follow-ups); full game 34 (
25 follow-ups); obsolete 2 follow-ups. No in-force spec lost its purpose in both scopes,
so none was withdrawn.
### Demo hulls, the Pirates and the boss
Anchors are against `origin/master` `4d664b53`, unless marked `@2a5fc00b`, the tip of the
unmerged `eureka/aetheria-release-boss-gate` branch (which contains demo-galaxy). Read by
`cut-pirates-record`, `cut-package-product`, `cut-boss-hull-derived` and `cut-demo-cast-hulls`;
question `pirates-record-content` ruled the record's content (see "The Pirates record" below).
- **H1. The catalog at `4d664b53` still has no Pirates, and no faction has a boss hull.** Probe:
`git show 4d664b53:GameData/Aetheria.cc | git lfs smudge`, decoded with Python `msgpack`
using the store's own schema table. Result: 12 `aetheria.faction` records, the same twelve
as D6, with no Pirates. `BossHull` (slot 10) is the empty key `''` on all twelve. Each
faction's `Allegiance` holds 11 entries, one for every other faction. Influence runs from
2 to 6. There are 12 `NameFile` records: Australia, Congo, Germany, India, Iran, Japan,
Mexico, Russia, Swahili, Thailand, USA and pleiades. There are 5 hulls: LonginusX, Longinus
and Djinni (`HullType` 0, ship), Zenith (1, station) and Turret (2, turret). Their prices
are 7.5M, 7.5M, 10M, 10M and 0.5M. There are 64 products. The catalog changed once after
`df7c44f2`: `3d77f2e0` (evasion-catalog tracking).
- **H2. "Pirates" appears only on the unmerged demo branches.** `git grep -i pirate` over
`*.cs` at `4d664b53` returns nothing. At `@2a5fc00b`, `DemoTerminus.cs:10` sets
`ProtagonistFaction = "Pirates"`, which `Galaxy.ResolveFaction` resolves by name prefix.
The test partial `RunStartTests` copies `GameData/Aetheria.cc` into a scratch directory
(`RunStartTests.cs:36-46 @2a5fc00b`). `AddPirates()` (`DemoTerminusTests.cs:20-28
@2a5fc00b`) upserts a stand-in Pirates record whose allegiance covers every faction. It is
called 21 times: DemoTerminusTests 9, RunGoalTests 10, ScenarioTests 2. Test
`DemoTerminus_missing_cast_refused` (`DemoTerminusTests.cs:86-95 @2a5fc00b`) asserts that
the shipped catalog has no Pirates. Once the record lands, both the stand-in and that
assertion are wrong: the stand-in duplicates the record and the assertion fails.
- **H3. demo-galaxy and boss-gate are held but unmerged.** `boss-gate` contains
`demo-galaxy`; its tip is `2a5fc00b`, 17 commits ahead of master and 94 behind. Verdicts
`cut-demo-galaxy.s4` and `cut-boss-gate.s2` hold. Finding
`cut-boss-gate.s1.boss-hull-is-a-strangers` records that the demo cannot be generated on
the bare catalog, because the Pirates record is missing.
- **H4. A package holds exactly two records, and no product.** `ShipAuthoringStore.LoadRecords`
(`ShipAuthoring.cs:155-167`) refuses any count other than one hull and one visual.
`ship_cc.py:85-86` refuses any file that does not hold exactly two envelopes.
`ShipModCatalog.Compose` (`ShipModCatalog.cs:48-133`) writes only those two records
(`:113-115`). Generation, station stock and the boss all choose from
`FactionProductData` (M5). A package hull is therefore reachable only through the
console's `give` command. Ruling `package-carries-product` is not implemented on master.
- **H5. Mod keys.** `IsModKey` (`ShipModCatalog.cs:25-26`) recognises `mod-hull:` and
`mod-ship:`. Its comment states the rule that the shipped catalog never references a mod
key, because a mod may be uninstalled. A shipped `Faction.BossHull` therefore cannot name
a package hull.
- **H6. Package hulls and top speed.** `ship-authoring create` without `--like`
(`ShipAuthoringCommands.cs:45-66`) writes a hull with no behaviours and `Price` 0.
`HullLike` copies the reference's members, `VelocityLimit` and `Price` included
(`ShipAuthoring.cs:91-106`). `Validate` (`ShipAuthoring.cs:186-293`) has no rule about top
speed or price. This is R6, still true. Generation silently skips price-0 products (M5).
- **H7. Where boss and hull type are read.** At `@2a5fc00b`:
- `LoadoutGenerator.GenerateBossLoadout` (`:178-196`) uses `Faction.BossHull` when it is
set and sold. Otherwise it falls back to the priciest ship hull on offer.
- `RandomHull` (`:131-136`) is the one hull chooser. `GenerateShipLoadout` reaches it at
`:37`.
- The station stock filter (`:112`) admits ship hulls only.
- `Galaxy.PlaceFactionsMain` picks its boss factions by `BossHull.IsSet()` (`:296-299`).
- AetherDb's `factions` command prints `BossHull` (`Program.cs:662-672`), and `dangling`
mentions it (`:544`).
No other reader exists. `HullType` is `Ship | Station | Turret` (`Enums.cs:140-145`).
Thrusters carry `EntityTypeRestriction(HullType.Ship)`, so a boss must be a `Ship`, not a
new `HullType`.
- **H8. HullData's free key.** Keys in use: ItemData 1, 2, 4-8; CraftedItemData 9;
EquippableItemData 10-16, 18-22, 30, 31; HullData 23-29, 32. Key 33 is free on
`HullData`. evasion-term's key 33 is on `WeaponData`, a sibling type.
- **H9. Emoji in names.** The TextMesh Pro default sprite asset
(`Assets/TextMesh Pro/Sprites/EmojiOne.json`) holds 16 face glyphs (1f600-1f923, 263a,
2639) and no others. The fire emoji in the Pirate Coalition's equipment copy would not
render without a new emoji asset (see "Full emoji").
- **H10. Pirates lore that the record reads from.** Sources:
- The vault note `Worldbuilding/Pre-Elysium/Factions/Powers/Major/Pirate Coalition.md`:
a loose network, no central command.
- `Brainstorming/Faction Flavor and Visual Identity.md:526-557`. Its points: stolen and
adapted equipment gives the faction its range rather than an exclusive technology;
products are named in emoji; hulls show several surviving finishes interrupted by primer,
with one crew accent colour.
- `Game Design/Faction Play.md:395` and `:416`: pirates buy everyone's gear.
- Corporate Roster: the pirate name is rolled per run. The demo's record is fixed.
- **H11. Packages in git and in builds.** At `4d664b53`, `.gitattributes` has no `*.glb`
rule, and `GameData/*.cc` does not match `GameData/Mods/<id>/ship.cc` (M11 still true).
There is no player build entry point; `ships-player` owns staging. `ActionGameManager`
composes from `GameData/Mods` (`:49`, `:63`), so editor play reads packages from the repo.
#### The Pirates record
Draft for question `pirates-record-content`.
| Field | Draft | Why |
|---|---|---|
| Name, ShortName | `Pirates`, `Pirates` | DemoTerminus's cast field and ruling `demo-cast` both say "Pirates" |
| Description | "A loose coalition of crews and hidden docks moving people and goods outside official control. No command, no common law: possession has a maintenance schedule." | Vault note; Faction Play's operating logic |
| PrimaryColor | (0.42, 0.40, 0.37), primer grey | "finishes interrupted by primer" |
| SecondaryColor | (1.00, 0.45, 0.05), the crew's accent colour | "a crew's chosen accent colour" |
| GeonameFile | `pleiades` | Star names suit hidden docks; it is not one of the cast's files |
| InfluenceDistance | 3 | Scattered bases, no continuous territory |
| Allegiance | 1.0 to each of the 12 other factions; no other faction's allegiance changes | Pirates buy everyone's gear. Only the Pirates field Pirates-made products, including their hull |
| Personality, music, BossHull | Empty or unset | Doctrine arrives with faction-play r2; the Pirates are not a boss |
Products: stolen-and-adapted rebrands over other makers' designs. The Pirates own no design;
their range is what they took (Faction Flavor `:532`). Each product's roles: one ProductRole per
role the design declares, mean .45, sd .22 (middling and inconsistent, against makers' .15).
Names are emoji; the text gloss is for the operator's review and is not stored.
| Product name | Gloss | Design (current maker) | Description |
|---|---|---|---|
| 🫳🔫 | finders keepers | Autocannon (Zhestokost) | "Serial number filed off. Still shoots." |
| 🪦🤠 | second owner | Earp (Aeronautics Unlimited) | "The previous owner had no further use for it." |
| 🧾🚫 | no receipt | FastBlast+- (NiteLife Energy) | "Warranty void where prohibited, which is everywhere." |
| 🎨🙈 | overpainted | 6k Shooter (Zhestokost) | "The logo underneath is somebody else's problem." |
| 🔥 | the fire | scorched void policy (Death Monkey Explosives) | "Turns out we did start the fire after all." (the roster's line, `Corporate Roster and Item Wishlist.md:85`) |
| 👀🎯 | borrowed eyes | Targeting Computer (generic) | "Somebody else's eyes. They still work." |
| 🔑🏃💨 | hotwired | Small Drive (Lightsail Express) | "Hotwired. Do not ask about the ignition." |
| 📦🤫 | not as declared | Store-All Plus (Lightsail Express) | "Contents not as declared." |
None is on a Miss Terri's design. Each (Pirates, design) pair is new, so `Brand` stays
unambiguous (CG4). The fire product moves to the flamethrower design if follow-up
`pirates-flamethrower` lands one.
The record's data does not depend on the emoji font: the simulation owns facts and the font is a
presenter (invariant `sim-owns-facts`). So the record waits on boss-gate (H2) and not on the emoji
cuts, and the demo's Pirates (finding `boss-hull-is-a-strangers`) are not held behind a Unity
presentation cut. Until the emoji font lands, editor play shows the names as missing-glyph boxes.
#### The boss hull is derived, not named
Follow-up `zhestokost-boss-hull` left this map to choose between two options. One was an
AetherDb command that names the package hull from `Faction.BossHull`. The other was a package
that declares which faction it is the boss of. The first is ruled out by H5: the shipped
catalog would name a mod key, and uninstalling the package would leave the reference
dangling.
The second option, built from parts that already exist:
- `HullData.Boss` (key 33, bool) marks a hull that only bosses fly.
- The package's own products name the faction as manufacturer (`package-carries-product`).
- A faction's boss hull is then the `Boss` hull it manufactures.
- `Faction.BossHull` has no writer in the catalog (H1), so it is deleted. Key 10 is retired,
as `ShipAuthoring` key 1 was.
Starsector is the prior art: a mod puts its ships into an existing faction by shipping a
faction file that the engine merges. The package therefore carries its own availability (map
"Why first-party hulls through the package path").
The consequence for the product: a `Boss` hull is never station stock, never ordinary traffic,
and never offered by `RandomHull` unless the caller asks for a boss. That keeps the boss
unique. If a faction makes no `Boss` hull, boss-gate's logged fallback stands.
**Why top speed is refused at `Validate`.** `Validate` is the one owner of top speed (follow-up
`package-hull-top-speed`): compose then excludes a speedless package with its name, and the
dock intro never sees one. Handling the absence at the intro was rejected because it would let
a hull fly with no cap.
Ruling `demo-content-bar` also needs NPC role hulls (haulers, tenders, miners) and a dockable
station; follow-up `demo-role-hulls` owns them.
### Full emoji
Anchors are against `origin/master` `4d664b53`. Read by `cut-emoji-font` and `cut-emoji-sequences`;
question `emoji-art-set` asks which pinned font input and fetch step the font build uses.
Rulings this rests on: `pirates-record-content` (emoji-names) and the operator's "I totally want
full emoji support".
- **E1. TextMesh Pro is the one inside `com.unity.ugui` 2.0.0, on Unity 6000.3.24f1.**
`ProjectSettings/ProjectVersion.txt`; `Packages/manifest.json:19` (`"com.unity.ugui": "2.0.0"`);
there is no separate `com.unity.textmeshpro` entry. Source read from
`F:\Projects\Aetheria\Library\PackageCache\com.unity.ugui@b996e7548785\Runtime\TMP` (read only).
- **E2. TMP Settings today** (`Assets/TextMesh Pro/Resources/TMP Settings.asset`, an LFS file):
`m_ActiveFontFeatures: 00000000` (:18), `m_fallbackFontAssets: []` (:36),
`m_defaultSpriteAsset` = EmojiOne (`c41005c1…`, :39-40), `m_enableEmojiSupport: 1` (:42),
`m_EmojiFallbackTextAssets: []` (:44). Default font asset is LiberationSans SDF (:27).
- **E3. EmojiOne is a 16-face sample and nothing uses it.** `Assets/TextMesh Pro/Sprites/EmojiOne.json`
holds 16 faces (H9). `git grep "<sprite"` outside the TMP folder: nothing. EmojiOne's guid is
referenced only by its own meta and TMP Settings; no prefab or scene sets `m_spriteAsset`. Its
attribution file says only "review their licensing terms" (EmojiOne/JoyPixels art is not openly
licensed).
- **E4. TMP's emoji path, by source read.**
- Lookup: `TextMeshProUGUI.cs:1932-1944` (and `TextMeshPro.cs:1619`): when a code point is an
emoji-presentation form not followed by U+FE0E, or an emoji followed by U+FE0F, TMP searches
`TMP_Settings.emojiFallbackTextAssets` first, through
`TMP_FontAssetUtilities.GetTextElementFromTextAssets`. That list takes font assets.
- Sprite assets resolve one code point each (`TMP_SpriteAsset.GetSpriteIndexFromUnicode`,
`:216`, a `Dictionary<uint, TMP_SpriteCharacter>`), so a sprite asset cannot match a ZWJ,
skin-tone, flag or keycap sequence. Sequences resolve only through a font asset's GSUB
ligature records.
- Ligatures: `TextMeshProUGUI.cs:2050-2118`. Gated by `bool ligature =
m_ActiveFontFeatures.Contains(OTL_FeatureTag.liga)` (`:1824`), per component. Matching skips
ZWJ and VS1-VS16 when they are not the expected component
(`TMP_TextParsingUtilities.IsIgnorableForLigature`, `:172-178`), consumes a trailing variation
selector, and collapses the span into one glyph. The ligature table read is the current font
asset's, which is the emoji font when the fallback found the first code point (`:2021-2023`).
- Records come from `FontEngine.GetLigatureSubstitutionRecords` as glyphs are added to a dynamic
font asset (`TMP_FontAsset.cs:3097-3131`).
- Colour glyphs: a font asset in `GlyphRenderMode.COLOR` gets an RGBA32 atlas and the
`TextMeshPro/Sprite` shader (`TMP_FontAsset.cs:606-625`); its vertices are forced white, so a
`<color>` tag around an emoji does not tint it (`TMP_Text.cs:5376-5385`).
- `TMP_Text.OnMissingCharacter` (`TMP_Text.cs:1389`) is a static event raised for every
missing code point: the smoke's probe.
- **E5. No text component has ligatures on.** 154 TMP components in 50 files (48 prefabs, 2
scenes), none legacy `UnityEngine.UI.Text`. Serialized feature lists: 24 x `00000000` (legacy:
converted on load to `[kern]` when `m_enableKerning`, `TMP_Text.cs:6082-6088`) and 38 x
`6e72656b` (`kern`), all in the two scenes (`ARPG.unity` 64 lines with the field,
`FieldShieldTest.unity` 1; `grep -rc "m_ActiveFontFeatures: 6e72656b$"` over prefabs and
scenes = 38). The 48 prefabs' components have no field and take the same legacy path. One component is
created at runtime (`ActionGameManager.cs:301`, the FACE AIM mark).
- **E6. Turning `liga` on changes no Latin text.** All ten project font assets (`Assets/Fonts/*/*.asset`,
LiberationSans SDF) are static (`m_AtlasPopulationMode: 0`) with zero ligature records. Only
the emoji font would supply ligatures.
- **E7. LFS.** `.gitattributes:33` `*.ttf` and `:50` `*.asset` are LFS; TMP Settings.asset,
EmojiOne.asset and EmojiOne.png are LFS objects today.
- **E8. Editor batch pattern.** `Assets/Scripts/Editor/EngineAssetCheck.cs:35-50`: a MenuItem plus
`-executeMethod EngineAssetCheck.Run`, exit 1 on failure in batchmode. The emoji builder and
smoke follow it.
- **E9. Prior art (web, 2026-10-08).**
- Unity's documented path for colour emoji is a colour font asset in TMP Settings' Emoji
Fallback Text Assets (TMP 3.2/4.0 manual "Color emojis"; Unity 6 manual
`UIE-color-emojis`). Unsupported: SVG colour glyphs, AAT fonts, COLRv1 (the manual names
"Noto Color Emoji" as the COLRv1 case), and chain-context and single-substitution features.
Ligature substitution (GSUB type 4), which carries ZWJ, skin-tone and flag sequences, is
supported. COLRv0 is supported (the manual's Android note).
- Unity forum thread 932305: the CBDT `NotoColorEmoji.ttf` from the googlefonts repo renders
through a Color font asset; the Google Fonts download (COLRv1) does not.
- Sets: Twemoji (`jdecked/twemoji`, community fork, Emoji 17.0, graphics CC-BY 4.0; distro
packages build COLRv0/COLRv1/CBDT TTFs from its SVGs with `nanoemoji`, version 17.0.2).
Noto Emoji (`googlefonts/noto-emoji`: fonts OFL 1.1, images Apache 2.0; the prebuilt
`NotoColorEmoji.ttf` is CBDT, bitmaps from `png/128`). OpenMoji (CC-BY-SA 4.0; COLRv0 font
shipped; outlined line-art).
- Not verified here: the exact jdecked release assets (its release page lists two source
archives and no TTF), so a Twemoji TTF is built, not downloaded.
- **E10. Visual identity.** AetheriaLore `Brainstorming/Faction Flavor and Visual Identity.md:532,
541, 548`: Pirates' products are named in emoji, a crew vocabulary; hulls carry "large emoji
marks". `Ship Design Language.md:222-226`: one large emoji crew emblem painted three times.
`Corporate Roster and Item Wishlist.md:85`: "Named entirely in emoji. The flamethrower is" the
fire emoji. `Game Design/Visual and Sensory Direction.md` says nothing about an emoji art set;
its interface register is "should feel like equipment" (:43).
**Why one colour emoji font asset.** One owner: a colour emoji font asset generated from a pinned
font file, set as TMP's emoji fallback and appended to TMP's global fallback list. Every TMP text
renders emoji through it with no per-component wiring, because TMP itself routes emoji code points
there (E4).
Rejected: a generated TMP sprite asset (the brief's first guess). Sprite lookup is one code point
per sprite (E4), so full emoji would also need a text preprocessor that rewrites every sequence to
a private-use code point, installed on every one of 154 components and every runtime text. That is
more code doing what TMP's font path already does.
Rejected: a runtime-built font asset (`TMP_FontAsset.CreateFontAsset` at boot). It needs
`Shader.Find("TextMeshPro/Sprite")` in a player (`TMP_FontAsset.cs:625`), which a build strips
unless the shader is always-included; an editor-built asset references its material and shader.
Sequences need `liga` on the rendering component (E4, E5). There is no global TMP switch, so the
sequences cut sets it on every serialized component through one editor command and on TMP
Settings' defaults for new components. It changes no Latin text (E6).
The atlas is dynamic and multi-atlas: glyphs rasterise on first use, so nothing is hand-made and
nothing is pre-baked. `m_ClearDynamicDataOnBuild: 1` (TMP Settings) already clears dynamic atlases
at build. The pinned font file is the only binary input, and its provenance is a lock file
(source, version, sha256 in and out). `emoji-art-set` decides only which pinned input and which
fetch step; the asset, settings, smoke and deletion are the same for every option. The font build
itself (option twemoji: nanoemoji) runs under `py -3` on Yggdrasil, not Starfire; the Unity batch
run is Starfire's. The sequences command touches only the `m_ActiveFontFeatures` line of each of
48 prefabs and 2 scenes, so it wants the freshest master and a quick merge.
### Retiring the prelude and the tutorial
Body: `origin/master` `4d664b53`; "merge tree M" as in the faction-play re-anchor (BS-probes), unless
noted. Read by `cut-retire-prelude` and `cut-gear-without-presence`; question `dev-scenarios-cast`
asks which galaxy the six test scenarios use once the authored cast goes. D1 and "Why the demo is a
prelude galaxy" describe the code before this retirement.
- **PR1.** `Galaxy.IsPrelude` (`Galaxy.cs:30`) is written by three constructors: the save
constructor from `SavedGame.IsTutorial` (`:53`), the main constructor `false` (`:107`), the
prelude constructor `true` (`:175`). Its readers (grep over Assets, tests, tools): `InfluenceOf`
(`:158-161`, halves influence rounding up), `LoadoutGenerator.IsAvailable` (`:211-214`, every
product on offer), `RunStart.GenerateArena` (`RunStart.cs:29`) and
`ActionGameManager.PopulateLevel` (`ActionGameManager.cs:721-727`) as ZoneGenerator's
`isTutorial`, `RunSave.Capture` (`SavedGame.cs:77`), and tests (`RunGoalTests.cs:22`,
`RunStartTests.cs:146`, `ScenarioTests.cs:137, :146, :554-557`).
- **PR2.** `InfluenceOf` feeds both generation and later zone generation: `Galaxy.cs:214-247`
(cast homes), `:314-324` (main homes), `:342-349` (influence), and `ZoneGenerator.cs:212`
(`factionPresence`, which sets station and enemy counts). A restored galaxy generates unvisited
zones through `PopulateLevel`, so the halving must persist with the run, today through
`SavedGame.IsTutorial` (key 11).
- **PR3.** `ZoneGenerator.GenerateZone(..., bool isTutorial = false, bool ambient = true)`
(`:40-46`): the entrance station floor at `:223-224`, the widened orbit pool at `:238` and
`:246-248`, and the throw at `:327-329` all apply only to `isTutorial && galaxyZone ==
galaxy.Entrance`. Source: operator 2026-09-25 "Tutorial station" (`docs/locomotion-cut.md:375`),
scoped to the tutorial level.
- **PR4.** The cast type is `TutorialGenerationSettings` (`Settings.cs:96-106`): five faction
names, LinkDensity, ZoneCount. Readers: `GameSettings.cs:30` (field, plus
`TutorialBackgroundSettings` `:31`), `GalaxyStage.cs:16, :29, :71-74` (`Prelude()` and
`Prelude(cast)`), `RunStart.cs:36-37`, `ScenarioStage.cs:21-35` (read only by
`TutorialGalaxy.cs:13`), `MainMenu.cs:178-179`, `ActionGameManager.cs:848`,
`DemoTerminus.cs:8` (the demo's cast), `tools/AetherDb/Program.cs:489-533` (`settings` and
`settings-dump`).
- **PR5.** `Assets/Resources/Settings.asset` (LFS, smudged at 4d664b53) `:43-52`: the authored cast
is protagonist `Miss`, antagonist `Zhe`, buffer `Luc`, neutrals `Aero` and `Finch`, quest
`Adras`, 64 zones; `TutorialBackgroundSettings` at `:53`.
- **PR6.** Six test scenarios generate `stage.Prelude()`, the authored cast: Arcs, DjinniShakedown,
Duel, LauncherAngles, LongHaul, StarvedReactor (`Scenarios/*.cs`, one line each).
`Scenarios.Development` is `{ TutorialGalaxy, MainGalaxy }` (`Scenarios.cs:8`). Their galaxy
includes Miss Terri's as protagonist.
- **PR7.** The prelude constructor's `playerSettings` and `narrativeDirectory` parameters feed only
the StoryProcessor lines commented out at `Galaxy.cs:258-260` (dead since ce1a0a46, 2021, per
locomotion-cut.md:375).
- **PR8.** `LoadoutGenerator.IsAvailable` (`:211-214`) and `ManufacturerPreference` (`:217-226`):
availability requires `Galaxy.ContainsFaction(manufacturer)` unless the galaxy is a prelude; an
absent manufacturer has no home zone, so `ManufacturerPreference` gives it distance 0, the
highest weight its allegiance allows. `Galaxy.ContainsFaction` (`:453-457`, cache
`_containedFactions` `:36`) has one game reader (IsAvailable); other readers are
`FireControlCut6cTests.cs:253` and the AetherDb `loadout exclude-sellers` mode
(`Program.cs:745-790`), which builds a galaxy without named factions precisely to exercise the
presence gate. `ScenarioStage.cs:29` builds its availability with Faction null, which today means
every present maker.
- **PR9.** `SavedGame` (`SavedGame.cs:9-52`) is an int-keyed MessagePack document
`aetheria.savedgame` v1, keys 0-13; `IsTutorial` is key 11.
- **PR10.** Tests touching the concept (grep -c "tutorial|prelude", case-insensitive):
RestoredHullsTests 65, ScenarioTests 30, DemoTerminusTests 29, RunStartTests 16, MiningCut3Tests
3, RunGoalTests 1. Most are the `TutorialGalaxySettings()` fixture family in RestoredHullsTests
and the `isTutorial:` argument.
- **PR11.** Open boss-gate finding `test-reads-isprelude` (verdict cut-boss-gate.s1) is
`RunGoalTests.cs:22`; retiring the flag removes it by construction.
**Why two cuts.** The follow-up asks to absorb gear-availability-without-presence and to keep the
subtraction separate from behaviour. Removing the presence gate changes what every galaxy
generates, so it is its own cut (`gear-without-presence`), landed first; `retire-prelude` then
deletes the flag with no behaviour change except where named.
**Why `IsPrelude` is split by rule, not renamed.** It bundles four unrelated rules, and each goes
to its owner:
- influence halving -> the cast's own `InfluenceScale` (the demo's cast sets .5, so its galaxy is
unchanged), carried by the Galaxy and the save (key 14) because later zones read it (PR2);
- the entrance station -> `Scenario.EntranceStation`, a declaration beside `Ambient`, default true,
false for MainGalaxy, so every scenario's arena is unchanged; RunStart.GenerateArena applies it;
- availability -> the general rule of gear-without-presence;
- the save flag -> deleted (key 11 retired; ruling `no-save-compatibility-before-players`).
A renamed flag on Galaxy was rejected: it would keep one switch deciding four things.
**Absent manufacturers.** Preference weights by distance to the maker's headquarters, and an absent
maker has none. The cut weighs it one jump beyond the farthest zone. Prior art: the gravity model
of trade (Tinbergen 1962; Anderson and van Wincoop 2003) weights flows by distance decay, and a
supplier with no local presence carries the largest trade cost, so it is reachable but least
preferred. Zero distance (today's accident) would make absent makers the most preferred, inverting
the rule.
**Old saves.** Key 14 absent decodes as 0 and the save constructor refuses a non-positive scale by
name. Per ruling `no-save-compatibility-before-players` nothing reads key 11.
### Model page rows
| Kind | What names it | Over time | Who decides |
|---|---|---|---|
| Pirates `Faction` | Catalog record, name `Pirates` | Authored once by `AetherDb pirates-faction apply`; doctrine is added by faction-play r2 | Ruling `pirates-record-content`; the operator sees the dry run |
| Package product | `mod-product:<id>:<maker key>` in the package's `ship.cc` | Written by `AetherDb ship-authoring product`; copied verbatim by compose; Blender never edits it | The package author |
| Boss hull | `HullData.Boss` (key 33) | Set by `ship-authoring boss`; a faction's boss hulls are derived per generation from the products it makes | The package author; `BossHullsOf` derives |
| `Faction.BossHull` (key 10) | Retired | Deleted by `boss-hull-derived` | none |
| Pirates products | 8 `FactionProductData`, emoji names, over other makers' designs | Authored with the faction in one commit | Ruling `pirates-record-content` |
| Package hull price | `HullData.Price` | Written by `ship-authoring price` (or `create --like`) | The package author |
| Emoji font file | `Assets/Fonts/Emoji/<set>.ttf` + `emoji-font.lock` | Rebuilt only by `tools/emoji` at a new pinned version | The operator's `emoji-art-set` ruling |
| Emoji font asset | `Assets/Fonts/Emoji/Emoji.asset` (dynamic, COLOR, multi-atlas) | Regenerated by `EmojiFont.Build`; atlas filled at runtime, cleared at build | `EmojiFont.Build` |
| TMP emoji routing | TMP Settings `m_EmojiFallbackTextAssets` and `m_fallbackFontAssets` | Set by `EmojiFont.Build` | `EmojiFont.Build`; no prefab overrides it |
| Ligatures per text | Each TMP component's `fontFeatures` | `liga` added once by `EmojiFont.EnableLigatures`; new components inherit TMP Settings | `EmojiFont.EnableLigatures`, then the component |
| EmojiOne sample | none | Deleted by `emoji-font` | none |
| Galaxy cast (replaces `TutorialGenerationSettings`) | A scenario's code constant (`DemoTerminus.Cast`) | Compiled with the game; no authored settings copy | The scenario |
| `Galaxy.InfluenceScale` | Its galaxy | Set by the constructor (the cast's value, 1 for main), saved as `SavedGame` key 14 | The cast |
| `Scenario.EntranceStation` | Its scenario | Compiled | The scenario; `RunStart` applies it |
| `SavedGame` key 11 (`IsTutorial`) | Retired | Not read, not written | none |
| `Galaxy.IsPrelude`, `TutorialGalaxy`, `TutorialGenerationSettings`, `GameSettings.TutorialGenerationSettings` | Deleted by `retire-prelude` | | none |
## Faction play: the NPC scripting
The operator ruled on 2026-10-03 that the faction dynamics may elaborate the NPC
@ -642,8 +1009,9 @@ and builds on `Zone.Release` from `loot-1`.
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Doctrine | `Faction.Doctrine`, catalog, key 17. | Authored through an `AetherDb` catalog command, as the targeting migrations were. | The catalog author. Read when a ship joins a flight. |
| Flight | Runtime: zone plus faction. | Formed at admission, rebuilt on load, gone with the zone. Never persisted. | Itself alone, for members' `Target`, `Task`, hails and pings. |
| Doctrine | `Faction.Doctrine`, catalog, key 17. | Authored through an `AetherDb` catalog command, as the targeting migrations were. Null reads as the default doctrine built from `GameplaySettings`. | The catalog author. Read when a ship joins a flight. |
| Agent (`Zone.Agents`) | Its ship. | Created by `Zone.Admit`, removed on the `Entities` removal path (it was never removed before). | `Zone`. |
| Flight | Runtime: zone plus faction. | Created on first join, rebuilt on load; members leave on removal or death, and it goes with the zone. Never persisted. | Itself alone, for members' `Target`, `Task`, hails and pings. |
| Track (flight picture) | Runtime: flight plus hostile entity. | Lives while any member sees it, or a sharer lends it. | The flight, from members' perception. Never written into `EntityInfoGathered`. |
| Duel | Runtime: flight plus challenger. | Offered, then held or broken. Ends when the challenger leaves the picture or dies. | The flight, from landed hits with their source. |
| Ammunition baseline | Runtime: member. | Counted at admission. | The flight. Rearm moves real cargo through `TryTransferItems`. |
@ -704,6 +1072,106 @@ four wait for `codex/mining` (it changes `Target`'s type and edits `Minion.cs`)
`codex/scenarios` (it rewrites admission in `Zone.cs`) to merge, because the first
cut rewrites exactly those lines.
### Re-anchor on master 4d664b53 (2026-10-08)
Body: `origin/master` `4d664b537f76029fbaee94392da3d8a122f81339`, read in a detached scratch
worktree. Where a probe says "merge tree M", it read `git merge-tree --write-tree origin/master
origin/eureka/aetheria-release-boss-gate` = tree `8387b47214ef6e44cae28a4e6a2b9b91d51464d9`
(boss-gate `2a5fc00b` contains demo-galaxy `85d0a148`; the merge is clean, no conflicts) with
`git show <tree>:<path>`. No commit was made. Read by the faction-play cuts, r2 and later.
- **BS1. Branch state.** `git merge-base --is-ancestor`: `codex/mining`, `codex/scenarios`,
`eureka/aetheria-release-mining` and `-scenarios` are ancestors of master.
`eureka/aetheria-release-demo-galaxy` (85d0a148) and `-boss-gate` (2a5fc00b) are NOT.
demo-galaxy's merge base with master is 228f241e; master is 94 commits ahead of it.
`-retire-longinusx` on origin equals master (4d664b53): its Hands had not pushed.
- **BS2.** `git merge-tree --write-tree --name-only origin/master <branch>`: demo-galaxy and
boss-gate each merge into master with no conflicting path (trees f3f69e84 and 8387b472).
- **BS3.** feedback-1, ballistic-ammo, controls-mount-aim, controls-ai-bearing, autofire-threshold
and ai-autofire had in-force specs and no report (specs-with-no-report query, as of ordinal 753).
`CapabilityEvents.PublishAbsorb` has no caller in `Assets/Scripts/ServerShared` (grep):
feedback-1's absorb publish sites do not exist yet.
- **FP1.** `Minion.cs:7-22`: constructor `Minion(Ship ship)`; `:14` is the one VisibleEnemies
subscription that writes `Ship.SetTarget(add.Value)` when `Target.Value.IsNone`; combat
transitions read `Ship.Target.Value.Entity` (`:18`, `:21`).
- **FP2.** `Zone.cs`: `Agents` `:40`; `Admit` `:167-172` is the only `Agents.Add` in Assets (grep)
and the comment names it the one admission; `CreateAgent` `:174-181` draws the circuit at `:178`
and assigns `agent.Task` at `:179`; the agent loop `:221-222`; the only Entities subscription is
the death subscription at `:107` (ObserveAdd -> Death -> Remove + Deactivate).
- **FP3. Nothing ever removes an agent.** `Agents.Remove`/`Clear` appear nowhere in Assets or tests
(grep). A dead or departed ship's Minion keeps running every tick. Removal from `Zone.Entities`
happens at `Zone.cs:107` (death), `Entity.cs:1268` (docking) and `ActionGameManager.cs:725` (the
player's zone transfer).
- **FP4.** `Entity.cs`: `_iffOverrides` `:53`; `Target` `:60` (TargetRef, one writer `SetTarget`
`:358`); `SetIff` `:811-815`; `IsHostileTo` `:817-836`; `GetFactionRelationship` `:838-845` reads
`Galaxy.FactionRelationships` only for the player's ship, so NPC-vs-NPC hostility is still only
trespass in an owned zone plus reciprocity ("TODO: Inter-faction hostility"). Consequence for the
demo: the allied Pirates and the antagonist Zhestokost do not fight each other; the Pirates'
doctrine is seen only against trespassers in Pirates zones. Relations (key 16) remain unmapped.
- **FP5.** `FireControl.cs:104-112` `AgentFires(weapon, shooter, target)` reads
`GameplaySettings.AgentMinHitProbability` at `:109`. Callers: `Combat.cs:121` and
`TurretController.cs:83` (grep). `Combat.cs:159` reads `AgentRangeExponent`; `_optimumRange`
`:13`. `Settings.cs:251` and `:274` hold the two globals. Ten FireControl test fixtures set
`AgentMinHitProbability = 0f` in their settings.
- **FP6.** `Agent.cs`: `Task` `:22`, `Settings` `:25`, constructor `:28-36`, `Update` `:47-59` zeroes
`Ship.Turn` each tick (controls-helm), `Accelerate` `:61-84`. `TaskType` `AgentTask.cs:24-33` has
no Follow or Flee. States live in `Agents/States` (BaseState, Combat, MoveTo); `PatrolOrbitsState`
is in `Agents/Tasks/PatrolOrbits.cs:10`.
- **FP7.** `Corporations.cs`: Faction keys run to `BossMusic` Key(15) at `:58-59`; keys 16 and 17
are free.
- **FP8.** `tools/AetherDb/Program.cs` command switch `:23-40`; `TargetingCatalog(bool apply)` at
`:1096` is the dry-run/apply precedent.
- **FP9.** Tests that construct `new Minion(ship)` directly: `FireControlPerWeaponTests.cs:276,
:291, :580, :597` and `FireControlTests.cs:214`; `RunStartTests.cs:100` and `SafeAssert.cs:44`
read `zone.Agents`.
- **FP10.** `LoadoutGenerator.cs`: `GenerateShipLoadout(Predicate<HullData>)` `:35-50`,
`EquipHardpoints` `:220-263`, `FillInterior` `:265`. `ZoneGenerator.cs`: faction ships
`:342-348`, wanderers `:350-364`, `return pack` `:366`. boss-gate adds a boss block to both files
(+38 and +18 lines), so anchors in them move by symbol after it merges.
- **FP11.** `Entity.TryTransferItems(Entity, CultRecordKey, int)` at `Entity.cs:977`. No
`GeneratedMagazines` or `AmmoType` exists in `LoadoutGenerator.cs` or `Settings.cs`:
ballistic-ammo has not landed.
- **FP12.** `Entity.Sensor` `:142`; `Sensor.Ping()` `Sensor.cs:110`.
- **FP13.** Catalog `GameData/Aetheria.cc` at 4d664b53 (LFS, smudged in the worktree): the string
"Pirates" occurs only inside geoname lists, no Faction record (H1). Follow-up
`pirates-catalog-record` is the owner.
- **FP14.** Lore source for the Pirates' doctrine: AetheriaLore `Aetheria/Game Design/Faction
Play.md` section "Pirate Coalition" (`:333-338`) and the summary row `:386`: crews want cargo,
not death; demand a drop, hit drives, leave fights they are losing. Of these, the flight
primitives express the demand (hail and grace) and leaving a losing fight (break-off). Demanding
a drop as compliance and aiming for drives need jettison (map item 10) and subsystem aim, which
no cut builds yet.
**What changed from the first anchor.** The first faction-play pass anchored on `f1dee184` and
waited for the mining and scenarios lanes. Both are merged (BS1), so the later specs branch from
master and anchor on 4d664b53. Target is a TargetRef written only through `SetTarget`, and
`Zone.Admit` is the only creator of agents, as the follow-up says.
Agents are also removed on entity removal (FP3). A flight whose membership follows removal while
the agent list does not would leave two answers to "who is in this zone"; one subscription now
decides both.
The Pirates' doctrine is authored in the doctrine-catalog command's table. While the catalog lacks
the Pirates record (FP13), the command names the missing faction and writes the rest; once the
record lands, re-running apply writes it. No doctrine, barks or levers for Miss Terri's.
The played proof sits in the DemoTerminus scenario with the demo cast, because the third cut
completes the behaviours the demo cast shows (Zhestokost's tender column, Lucent's duel, AU's
haulers); the fourth keeps the tender loop as full-game scope with no proof.
The ping cadence asks `Sensor.Ping()` and nothing more. Follow-up `sensor-stat-set-cut` is a
separate, larger mapping (per-sensor power, beam width, passive gear, shared tracks) and was not
mapped here; whatever Ping becomes, the flight only decides when to ask.
Key layout on Faction: Relations 16 (reserved, ruling `faction-relations-field`), Doctrine 17
(ruling `faction-doctrine-typed`). ships-hull-livery's Livery at 16 must move to 18 (follow-up
`livery-key-collision`, owner ships-hull-livery's next revision).
Collision: `ai-autofire` rewrites Combat's and TurretController's fire decision "at the agent
threshold". After faction-play-1 the agent threshold is the member's
`RoleDoctrine.MinHitProbability` (turrets keep the global). Whichever lands second re-anchors; the
next ai-autofire revision should read `_agent.Doctrine.MinHitProbability`.
### Prior art
- Damián Isla, "Handling Complexity in the Halo 2 AI", GDC 2005
@ -4050,6 +4518,93 @@ pass of 2026-10-07, source marks as in Controls; the EVE formula is the player w
today, and any larger shape fits no hull, like CShot. Nothing in the record settles its intended
shape; question `plight-shape` asks it.
### Body facts: evasion core, mount term and autofire seams (2026-10-08)
Body: `origin/master` `4d664b53`, read in a detached worktree. The in-flight branch
`origin/eureka/aetheria-release-controls-mount-aim` (e103b90a, 538b11b4) was read with `git diff
4d664b53 <branch>`, never checked out. Read by `cut-munition-shots`, `cut-munition-jink`,
`cut-autofire-threshold` and `cut-autofire-controls`.
- **MA1. Master's evasion is two factors in one price** (source read, `FireControl.cs`).
`HitProbability` (:350-357) is `PFire * PMount(AngularVelocity(source, target),
TrackingRate(weapon, source)) * PSpread * POnHull * PDeviation(evasion, Tracking(source))`.
`Forecast` (:326) computes `evasion = Evasion(target, TravelDirection)`. The mount factor reads
the gun's rate times the ship's gear (`TrackingRate`, :221-224); the jink factor reads the ship's
gear alone in metres (`Tracking`, :195-199), as rulings `tracking-per-gun` and `jink-per-ship`
say.
- **MA2. The evasion core is already subject-free** (`FireControl.cs:496-516`).
`Evasion(Entity, los)` returns 0 for a non-Ship and otherwise calls the pure
`Evasion(in ManoeuvreEnvelope, float2 heading, in ManoeuvreTrack, float2 los, float window)`,
whose comment calls it "the one call guns and munitions make". A munition can answer it with its
own envelope, heading and track without a second evasion function.
- **MA3. The envelope and track types** (`FireControl.cs:2049-2115`). `ManoeuvreEnvelope` is six
accelerations (Forward, Reverse, Left, Right in m/s^2 along body axes; Clockwise,
CounterClockwise turn rates). `ManoeuvreTrack.Observe(float2 acceleration, float dt, float
window)` keeps trend, innovation and second moments and forgets a window after the last change
of vector. `Reach` (:521-546) with both turn rates zero reduces to the box's no-turn support
`0.5 * (face along) * T^2` plus faces whose turn is zero (`ReachFace` returns 0 for a turned face
when its rate is below 1e-4, :552). So a point-mass munition with no turn rate is priced by its
box alone, which is what a vector-commanded munition can do.
- **MA4. Who writes a ship's track** (grep over `Assets/Scripts/ServerShared`). One writer:
`Entity.Update` at `Entity.cs:1478` (`Manoeuvre.Observe(Acceleration, delta, SolutionWindow)`),
acceleration being the base update's velocity change with the limit clamp added back (:1477).
Thrusters supply the envelope (`Thruster.Manoeuvre()`, `Thruster.cs:98`); `Ship.Envelope` sums
them (`Ship.cs:153-159`).
- **MA5. Fire freezes the mount for an entity subject only** (`FireControl.cs:688`). `pFire =
solution.PFire * (solution.Engaged != null ? PMount(AngularVelocity(source, solution.Engaged),
TrackingRate(weapon, source)) : 1f)`. `CommitProbability` (:864-878) multiplies the frozen
`PFire` by `DeviationProbability` (:483-489), which adds `Evasion(shot.Target, ...)` to the
realised drift. A munition subject needs both: the mount frozen at fire from its own position and
velocity, and the evasion at commit from its own track.
- **MA6. `AngularVelocity` has a planar overload** (`FireControl.cs:209-213`):
`AngularVelocity(float2 offset, float2 relativeVelocity)`, range floored at 1 m. A munition's
offset is `Flight.Position - source.Position.xz` and its relative velocity `Flight.Velocity -
source.Velocity`; no new geometry is needed.
- **MA7. No munition code is on master** (`ls Assets/Scripts/ServerShared`, grep `MunitionId`).
`Munitions.cs`, `MunitionFlight` and `MunitionId` do not exist; `TargetRef` (`Zone.cs:646-670`)
has only Entity and Chunk. missile-stats, missile-records and missile-odds had no report
(specs-with-no-report query, as of ordinal 750). munition-shots' anchors into that code are by
name.
- **MA8. controls-mount-aim, in execution, changes the seams these cuts touch** (diff of
4d664b53..origin/eureka/aetheria-release-controls-mount-aim, FireControl.cs and Weapon.cs): adds
`Solution(Weapon, Entity shooter, TargetRef subject)` after `AimDirection` returning `GunSolution
{ Entity Subject; bool Bears; bool Free; float2 Direction }`; adds `Bears(weapon, source, Entity
target, out range)` and routes `PFire` through it; rewrites `ArcPermitsFire` to `fused || mine
layer || Solution(weapon, shooter, shooter.Target.Value)` bears or free; Solve's travel direction
becomes `Solution(weapon, source, target).Direction`. The branch dropped the Broadside scenario
(538b11b4: no catalog hull has side-rotated gun hardpoints).
- **MA9. PendingShot construction sites** (grep `new PendingShot` over Assets/Scripts and tests):
two, `FireControl.Fire` and `FireControlCut9Tests.cs`. Readers of `shot.Target` in FireControl:
:487-488, :808, :867-876, :884, :920-950, :1132-1140, :1190, :1483; outside:
`EntityInstance.cs:331-333`.
- **MA10. Autofire seams on master** (source reads): `Entity.WeaponGroups` `Entity.cs:82`, sized
:791-793, removal path :1050; `PowerBus.Step` :1453 then the equipment loop :1455-1458 (the only
place equipped behaviours execute in `Entity.Update`); `Weapon.Activate/Deactivate` set `_firing`
only (`Weapon.cs:156-164`), so a weapon draws no power by being triggered. `StanceAllowsFire`
`Weapon.cs:113`, `ArcAllowsFire` :123. `Refuses` `FireControl.cs:93-95`, `AgentFires` :104-112
(its first branch is the mine layer's: worth it when `Solve` says Designated). `Fire` reads
`source.Target.Value.Entity` at :683. `EntityPack` keys 0-16 on the base class, subclasses use
17-20, key 21 unused (`EntitySerializer.cs`). `ActionBarWeaponGroupBinding`
`ActionBarSlot.cs:177-210`; slot input actions are created in `ActionGameManager.createBinding`
(:434-442, `started` activates, `canceled` deactivates). `ControlsHud.cs` does not exist on master
(controls-hud creates it). `Zone.Targets.Within(in TargetSearch, List<TargetCandidate> into)`
(`TargetingIndex.cs:99`) fills a caller's list. `Scenarios.Tests` (`Scenarios.cs:7-10`) ends with
`LauncherAngles, LongHaul`.
- **MA11. The close-crosser fixture already exists**
(`tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs`). `EvMountRange(gunRate, laserRate)` sets
two guns on one shooter to authored rates through `EvSetRate` (:23-29), and
`LightAndHeavyGunsSplitOnAFastCloseCrosser` (:139-160) with rates 40 and 3 and `EvCross(range,
jinking: false, float2(100, 0))` at 200 m measures PMount >= 0.85 and <= 0.01. The steady
crosser's evasion is zero (EvCross settles the track), so the split is the mount term alone. The
fixture places the target with `ScenarioStance.Neutral` (default of `ScenarioStage.Place`,
`ScenarioStage.cs:109`); `Entity.SetIff(other, true)` (`Entity.cs:810`) or
`ScenarioStance.Hostile` makes it hostile.
- **MA12. No PD-class gun is in the catalog** (tracking table). The highest authored rates are
ClearPath 12 and Spectra 10 deg/s; launchers are +infinity. Against inbound missiles the angular
velocity is small (mostly radial approach), so these suffice to engage salvos in a scenario;
against a fast close crosser ClearPath prices 0.37 / 0.76 (TC / FCA, map table). The catalog
growth section's PD guns (CG-probes) are the content that closes this.
### The design
**The resolution model** is ruled: `missiles-resolution-model`, geometry-sets-odds. The second
@ -4529,12 +5084,12 @@ while one of your autofire guns has it as subject.
| Kind | Identity | Lifecycle | Authority |
|---|---|---|---|
| Munition (light record) | Its `PendingShot`'s `ShotId`, as `MunitionId` in a `TargetRef`. | From fire through its terminal gate (commit) to arrival, or until interception or lifetime; never saved, as pending shots are not. | `FireControl` for transitions and the roll; `Munitions.Advance` for flight. |
| Munition flight | `PendingShot.Flight` (position, velocity, delta-v, command, wear, seeker, lock, faction, inbound rounds). | Stepped every tick; gone with its shot. | `Munitions.Advance` under `FireControl.Step`; wear by `DamageMunition`. |
| Munition flight | `PendingShot.Flight` (position, velocity, delta-v, command, wear, seeker, lock, faction, inbound rounds, `Track`). | Stepped every tick; gone with its shot. `Flight.Track` observes the command Advance applied each step; it is runtime, never saved, and discarded with the shot. | `Munitions.Advance` under `FireControl.Step`, the one writer of the track (as `Entity.Update` is for a ship); `Munitions.Envelope` derives what it can reach from its stats and fuel, and `FireControl` prices it through the same evasion core as a ship; wear by `DamageMunition`. |
| Entity manoeuvre track | `Entity.Manoeuvre` (`ManoeuvreTrack`: trend, filtered innovation, its second moment). | Observed every update from the velocity change across the base update; never saved. | `Entity.Update`. |
| Ship envelope | `Ship.Envelope` (`ManoeuvreEnvelope`: four body-frame accelerations, two turn rates). | Recomputed every update from the live propulsors; never saved. | `Ship`, from each propulsor's report. |
| Missile stats | `MunitionData` on `LauncherData` / `GuidedWeaponData`, six groups. | Catalog data in `Aetheria.cc`. | The catalog now; gear once munitions are entities. |
| Autofire setting | Per weapon group, `Entity.Autofire` (on, threshold). | Set by the player; saved in `EntityPack` key 21. | The player. |
| Autofire subject | Per weapon, runtime only. | Re-picked by `Autofire.Update` when the current one stops being worth firing. | `FireControl.ChooseSubject`. |
| Autofire subject | Per weapon, `Weapon.AutofireSubject`, runtime only. | Re-picked every tick, and when the current one stops being worth firing. | `FireControl.ChooseSubject`. |
### Rationale
@ -4640,6 +5195,379 @@ reading can take), jamming and soft kill; boost and midcourse fuel planning; min
the demo cast fields launchers for the player's PD to meet is content: the cuts add a
`Point Defense` scenario for the operator check.
**Why a munition's evasion goes through the ship's core.** Follow-up
`munition-shots-evasion-interface` (absorbed by `cut-munition-shots`) asked that a munition answer
`FireControl.Evasion` "with its own manoeuvre envelope (from its missile-stats thruster group) and
its own course-change record, like ships do". The earlier price went through a direct agility
(`Stats.Lateral` while fuelled) and the command vector, which is the strafe-only reading ruling
`evasion-is-unpredictability` overturned for ships. The munition now gives the core the two inputs
it reads: `Munitions.Envelope(flight)` (faces equal to Advance's own command clamps; no turn rates,
because a munition commands any planar vector at once, MA3) and `Flight.Track` (observed by
Advance, MA4's pattern). Then `Evasion(flight, los)` is one line over the shared core, a jinking
missile is evasive because its track sees changes of vector, and a dry one is not because its
envelope is zero. A missile pursuing a weaving target also shows innovation from its PN commands;
that is evasion by the ruling's own measure ("how much the ship is actually changing its vector"),
not a defect.
**Why the mount term is the munition's own geometry.** Follow-up `munition-shots-mount-term`:
`PMount(AngularVelocity(Flight.Position - source.Position.xz, Flight.Velocity - source.Velocity),
TrackingRate(weapon, source))` (MA6), frozen at fire like an entity's (MA5), with the jink forgiven
per ship by `Tracking(source)` (ruling `jink-per-ship`). Inbound missiles approach mostly radially,
so their angular velocity is small and PD guns hold them; a missile crossing close is hard to
follow, which is the CIWS geometry and EVE's tracking formula `0.5^((omega/rate)^2)` the mount term
already is.
**Why the box support is enough.** `Reach`'s no-turn support assumes the lateral and along-track
faces fire independently (a box), while Advance clamps the total command to `Stats.Acceleration` (a
disc with a lateral cap). Across the line of sight the binding limit is the lateral face, which the
box and the disc share; the corners overstate reach only on oblique lines of sight, and the
observed track caps the evasion before the envelope does in every case the tests build. A disc
support would be a second reach function for one corner case; not bought.
**Why munition-shots is split.** The earlier estimate was 465 lines and 12 tests across Solve,
Fire, Step, Commit, Apply, Detonate, Solution and Advance, and the envelope, track and mount term
add to it. The evasion-term cut of similar reach took 190 calls and overran (memory
`hands-batch-sizing-and-stryker-offsets`). The jink (inbound-round count, Advance step 4 and the
fuel-spend tests) is a separate behaviour with its own rulings (`missiles-fuel-and-seekers`,
`missile-cognition`) and its own failure modes, and it reaches the PD price only through the track.
So `munition-shots` is the subject, price and resolution (about 380 lines) and `munition-jink` is
the jink (about 170 lines), each falsifiable alone.
**Why the autofire divert test uses a steady close crosser.** Under ruling
`evasion-angular-velocity-r2` a steady fast crosser close in evades through the mount term even
with no jink, and that is exactly where ruling `speed-demon-counterplay` wants light guns to hold a
ship heavy guns cannot. MA11's fixture gives the split (PMount >= 0.85 against <= 0.01) with zero
evasion, so the test proves the chooser reads the per-gun price and nothing else. The threshold is
set inside the interval between the heavy gun's price and the lower of the light gun's price and
the heavy gun's price without the mount, so a Worth that drops the mount, or reads the ship's gear
in place of the gun's rate, lets the heavy gun fire and fails.
**Why autofire-threshold is split too.** The earlier version (about 520 lines) mixed the headless
chooser, Worth, SubjectOf, the saved settings and 13 tests with Unity work (action-bar modifiers,
HUD diamonds, a new scenario) whose every loop needs a Starfire compile. `autofire-threshold` keeps
the sim half (about 360 lines) and gives the settings one writer, `Entity.SetAutofire`, so the
presentation half, `autofire-controls` (about 190 lines), writes nothing else.
**Unowned checks.**
- `missile-odds` was written against evasion-term. It prices a missile's hit on a ship with the
target's evasion; whether a missile as shooter takes any mount term was never ruled (the tracking
table lists launchers at +infinity: "the round steers itself"). Worth a check before missile-odds
is dispatched, if no parallel pass already owns it.
- No PD-class gun existed (MA12). The Point Defense scenario and the speed-demon play check could
run on ClearPath and Spectra, but the ruled outcome "PD-class guns hit a speed demon reliably"
has no catalog gun to show it until one is authored (see "Catalog growth").
## Catalog growth: generic designs, branded products
Ruling `catalog-grows-generic-designs-branded-products` (operator, 2026-10-08): designs are generic
and branding lives in products. A design is expected to carry several products. `Title` is the one
owner of an item's displayed title (`ItemManager.Title`, landed by `product-titles`); there is no
`DisplayName`. Body: `GameCult/Aetheria` `origin/master` `4d664b537f76029fbaee94392da3d8a122f81339`,
unless a fact names another anchor (LP facts read the `product-titles` head `7f700d3c`). Read by
`cut-pd-gear`, `cut-lot-product`, `cut-generic-design-names`, `cut-product-lines-weapons`,
`cut-product-titles` and `cut-pirates-record`; question `minor-power-brands` and follow-up
`gear-product-lines` own the rest of the gear.
### Body facts
Catalog decode method for CG1-CG4: `cultcache_py` `SingleFileMessagePackBackingStore.
pull_all()` over `GameData/Aetheria.cc` at `4d664b53` (LFS pulled into the worktree), each payload
`msgpack.unpackb`ed and read by MessagePack key (the M28 method); the store is `cultcache.store.v1`
with 13 schemas and 217 records. Scratch scripts were not committed.
- **CG1. The catalog's record census at 4d664b53.** 64 FactionProductData, 51 CompoundCommodityData,
32 GearData, 18 WeaponItemData, 13 SimpleCommodityData, 12 Faction, 12 NameFile, 5 HullData,
4 CargoBayData, 1 FieldKindData, 1 DockingBayData, 1 InputLayout, 0 Loadout. Factions (short
names): AU, Adrasteia, Alakrita, DME, Ewan Hart, Finch, Lightsail, Lucent, Miss Terri's,
NiteLife, R&D, Zhestokost. No Pirates record yet (H1).
- **CG2. The data model already separates design from product; the content does not** (source read
plus CG1). `FactionProductData` (`ServerShared/FactionProduct.cs:14-31`: Name 1, Description 2,
Design 3 `CultRecordRef<CraftedItemData>`, Manufacturer 4 `CultRecordRef<Faction>`, Roles 5
`ProductRole{Role, Mean, StandardDeviation}`) is a manufacturer's branded variant of a design; its
own comment says a market segment is "a second product with a role pumped up". Price is on the
design, not the product (`HullData.Price`, H1). Generation and station stock pick products, not
designs (`LoadoutGenerator.RandomProducts`, `:142-171`; `GenerateStationLoadout` stocks 16 random
products, `:107-114`). Presets build a design by its first available product in record-key order
(`Loadout.Resolve`, `Loadout.cs:160-191`). But of the 59 sold designs only Targeting Computer and
Fire Control Array carry more than one product (3 each: Panopticon Prime by Finch, ClapBack Ultra
by Lucent, LockOn Pro by NiteLife on the array, and Panopticon, ClapBack, LockOn on the
computer); every other design has exactly one product, and in 49 of them the design's own name is
the brand (product name equals design name: ClearPath, Spectra, Earp, Manhattan, Store-All Plus
...). Three designs already have generic names under a brand product (Core Power -> Steadfast,
plight -> DragOnBreath, pswarm -> Leonid); seven generic-named designs sell a product of the same
generic name (Autocannon, Cockpit 2x2, Turret Control Module, Industrial Thermostatic Heater,
Medium Drive, Small Drive, Medium Docking Bay). Most designs' `Description` is the brand's voice
("Defend your precious cargo and have fun doing it"); 20 products carry an empty or `[TODO]`
description. 64 products over 60 designs; 0 duplicate (design, maker) pairs.
- **CG3. Where the player sees a name** (source read, master). `PropertiesPanel.GetTitle`
(`UI/Properties Panel/PropertiesPanel.cs:431-437`, `data.Name`) and the trade list
(`UI/Menu/TradeMenu.cs:193-197`, the Name column, tier-coloured) and buy dialog (`:355`) show the
design `Name`. The product name and description appear only as extra property lines after `Brand`
(`PropertiesPanel.cs:370-380`); `Inspect(ItemData)` shows the design as `Type` (`:530`). So today
the brand reaches the headline only because design names are brand names, and a product named in
emoji would be listed under its design's name. `product-titles` moves the headline to the
product (see LP3).
- **CG4. A lot does not record its product** (source read, master). `Lot`
(`ServerShared/Provenance.cs:64-70`) holds Design 0, Origin 1, Quality 2, Roles 3.
`ItemManager.CreateLot(product)` (`ItemManager.cs:134-155`) writes the maker into `Origin` and
drops the product. `ItemManager.Brand` (`:206-222`) re-derives the product as the lowest-key
`FactionProductData` with that maker and design. Two products of one design by one maker (the
market segment `ProductRole`'s comment designs for) are therefore indistinguishable after
creation: the player sees the lower-key product's name and text whatever was bought. Today no
maker sells two products of one design (CG2), so nothing shows it yet.
- **CG5. Weapon fields and the reference small guns** (source read `Behaviors/Weapon.cs:20-72`,
`Behaviors/InstantWeapon.cs:10-20`, `ItemData.cs:477-509`; catalog decode). WeaponData keys:
DamageType 1, Damage 2, Penetration 3, DamageSpread 4, MinRange 5, Range 6, DamageCurve 7,
EffectPrefab 8, Energy 9, Heat 10, Visibility 11, AmmoType 12, MagazineSize 13, ReloadTime 14,
Spread 15, Velocity 16, Tracking 33; InstantWeaponData Count 17, BurstTime 18, Cooldown 19;
WeaponItemData WeaponRange 24, Caliber 25, WeaponType 26, FireTypes 27, Modifiers 28,
BlastRadius 29, Fuse 32; GearData Hardpoint 23 (enum order Hull..AetherDrive: Energy 9,
Ballistic 10, Launcher 11). Shape is `[width, height, cells]`. Evaluated (Min-Max):
| Design | HP | Mass | Price | Dmg | Range | Cooldown | Count | Spread | Velocity | Energy | Ammo/Mag | Tracking |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ClearPath | Ballistic 2 cells | 50 | 50,000 | 2-6 | 250-600 | 0.1-0.033 | 1 | 1.25-0.25 | 700-1250 | 1-0.5 | none / 0 | 12 |
| Spectra | Energy 2 | 25 | 125,000 | 20-85 | 800-1750 | 0.5-0.25 | 1 | 0.1-0 | 0 (beam) | 20-40 | none | 10 |
| FastBlast+- | Energy 2 | 125 | 150,000 | 20-80 | 500-1250 | 0.33-0.1 | 12 | 3-1 | 400-900 | 0 | cells / 12 | 9 |
| Autocannon | Ballistic 4 | 75 | 250,000 | 3 | 1000 | 0.5 | 1 | 0.5 | 500 | 5 | Ammo / 50 | 4 |
| DeathCluster | Ballistic 4 | 250 | 450,000 | 100-250 | 500-1000 | 1-0.25 | 12-15 | 3-0.5 | 750-1500 | 25-5 | Ammo / 8 | 3 |
Damage, Heat and Energy are divided by the burst count per pellet (`InstantWeapon.cs:128-134`).
The ammunition commodity is the SimpleCommodity that Autocannon's AmmoType names (`3085fc8a...`).
- **CG6. Homes for one-cell guns** (source read `LoadoutGenerator.HasHome`, `:173-176`;
`HardpointData.Takes`, M30). A design is offered only if some catalog hull has a hardpoint that
`Takes` it, which is any design of the slot's type whose shape lies within the slot. Longinus has
two Energy 1x2 hardpoints, Djinni two Ballistic 1x2, the station Turret two Ballistic 2x4. So a
1x1 Ballistic or Energy gun and a 1x2 Ballistic gun all have homes on master.
- **CG7. Munitions on master.** `missile-records`, `missile-stats`, `munition-shots`,
`autofire-threshold` and `autofire-controls` had no report: master has no munition record yet
(MA7). missile-stats authors munition `Durability 20`. `Detonate` is the one blast path and
damages entities, not munitions (missile-odds, "Detonate is the one blast path").
- **CG8. A shipped weapon may not carry a fuse today.**
`FireControlCut124Tests.ShippedCatalogIsNeutralOnBlastRadiusAndFuse`
(`tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs:2591-2610`) asserts every shipped weapon's
BlastRadius and Fuse are null. An airburst flak gun would break it; the flak below is a pellet gun.
- **CG9. The catalog tracking test** (`tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs:290-313`,
`EveryCatalogGunTracks`) requires every non-launcher gun's Tracking finite, Min = Max > 0. New
guns must author it.
- **CG10. Design names in code at 4d664b53** (`git grep -F` over `*.cs` for the quoted names of
the generic-names table). 61 hits in 12 files: scenarios Arcs.cs 14, Duel.cs 11,
LauncherAngles.cs 8, StarvedReactor.cs 6; tests EvasionTermTests 10, BrownoutTests,
EvasionTrackingTests, PowerCurveTests, RestoredHullsTests, RunStartTests, ScenarioTests 1 each;
tools/AetherDb/Program.cs 6. Loadout and product references are by record key, unaffected by a
rename.
- **CG11. Behaviour index 31 is CapacitorData** (`ServerShared/Behaviors/Behaviors.cs:178`), so
PotaT+- is NiteLife's capacitor. The wishlist's "capacitors: no products" line is stale.
- **CG12. Vault sources read** (AetheriaLore `33cd06a`): `Aetheria/Game Design/Faction Play.md`
(loadout and quirk lines 118-266, 307-357), `Aetheria/Brainstorming/Faction Flavor and Visual
Identity.md` (Aya point defense and interceptors :458, Megiddo decoys and interception :566),
`Aetheria/Brainstorming/Corporate Roster and Item Wishlist.md` (item ideas :57-87, gaps :97-146;
its "What this is for" names the design/product text split). Zhestokost's loadout names "flak and
shrapnel (MFer, DeathCluster)" against missiles; Lightsail's names ClearPath as its machine gun
and Cetacean Navigators' Waykeeper escorts carry point defense with Lightsail haulers; Aya and
Megiddo are the heavy-PD and decoy factions but are not catalog factions. `Ship Play Concepts.md`
was not found in either repo at these heads.
- **LP1. `Lot` keys** (`Assets/Scripts/ServerShared/Provenance.cs:63-80`, at 7f700d3c). Keys 0-3
(Design, Origin, Quality, Roles); key 4 is free. Lots and origins already carry
`CultRecordRef<ItemData|Faction|SimpleCommodityData>` through MessagePack (:66, :101, :108, :119),
so a fourth `CultRecordRef<T>` needs no formatter work.
- **LP2.** `ItemManager.Brand` (ItemManager.cs:206-222) derives the product at :217-220 with
`GetAll<FactionProductData>().Where(maker and design).OrderBy(key).FirstOrDefault()`. It is the
only such derivation in Assets/ (git grep `GetAll<FactionProductData>`: ItemManager :217, plus
Loadout :194 and LoadoutGenerator :145, which choose a product to mint, not read an item's).
- **LP3.** `Title` (ItemManager.cs:226-227) reads `Brand(crafted).Product?.Name`. Brand's readers:
Title, PropertiesPanel.cs:374 (Manufacturer and description), tests LoadoutTests
:336/:359/:395/:844 and RunStartTests :136/:678. Nothing else reads an item's brand, so fixing
Brand fixes every reader.
- **LP4.** Lot writers in Assets/: only `CreateLot(product)` (:136-158) and `CreateLot(design,
maker, quality)` (:161-170). Production callers of the second: ActionGameManager.cs:544/:548
(console give, no maker) and Loadout.cs:182 (unbranded preset, no maker). No `new Produced` in
Assets/ or tools/; Produced lots exist only in tests.
- **LP5.** Behaviour change: a lot from `CreateLot(design, maker, q)` whose maker sells that design
used to brand as the maker's key-first product; once the lot records its product it brands
(maker, null) and titles by design. No production path mints such a lot (LP4).
- **LP6.** Tests pinning the derivation: LoadoutTests
`BrandPicksFirstProductInKeyOrderForSameMakerAndDesign` (:375-397) pins the tie-break that goes
away; its comment says the census's duplicate detector exists because of it. Stale comment in
LoadoutTests :315-317 ("Brand() requires at most one product per (maker, design)").
- **LP7.** AetherDb census (tools/AetherDb/Program.cs:124-138) prints "(maker, design) pairs with
more than one product" as a fault, justified by Brand's tie-break. Under the ruling a design
carries several products, and a maker may sell two of one design; with `Lot.Product` the pair is
no longer ambiguous.
- **LP8.** Run save: `RunSave.Commit` (SavedGame.cs:138) writes the reachable ledger;
`RunSave.Lots(cache)` (:132) reads it back. RunSaveTests :382-400 shows the fixture shape (a
ledger plus a SavedZone whose pack references the lot).
- **LP9.** ItemTitleTests (tests/Aetheria.Shared.Tests/ItemTitleTests.cs) builds Array with
products Panopticon Prime (Finch, key array-finch) and ClapBack Ultra (Lucent); Soul's probe added
a second Finch product of Array and saw Title return the first (finding `title-key-first-product`).
### Content tables
Drafted content that specs cite. Names are drafts the operator may rename.
**Maker profiles** (one Mean / StandardDeviation applied to every role of the design, from Faction
Play's loadout lines and the existing products' roles):
| Maker | Mean | Dev | Why |
|---|---|---|---|
| Zhestokost | .50 | .08 | armour and consistency, no finesse |
| AU | .50 | .12 | "mid-quality, consistent: the all-rounder" |
| Lightsail | .55 | .10 | freight reliability |
| R&D | .60 | .15 | compute, contractor quality |
| Lucent | .60 | .15 | premium, loud |
| NiteLife | .55 | .12 | energy specialist |
| Finch | .65 | .20 | "high variance in role quality" |
| Alakrita | .60 | .14 | light, fragile, excellent |
| DME | .55 | .20 | runs hot, uneven |
| Adrasteia | .60 | .10 | exacting |
No product is authored for Miss Terri's (ruling `miss-terris-belongs-to-emily-r3`) or the Pirates
(the Pirates record owns their products, ruling `pirates-record-content`).
**PD products** (`pd-gear`; the three new designs are the Point Defense Gun, Point Defense Laser and
Flak Gun, whose stats follow under the rationale):
| Design | Product | Maker | Description |
|---|---|---|---|
| Point Defense Gun | Waykeeper | Lightsail | Freight first, questions later. Keeps a hauler's paint unscratched through a full salvo. |
| Point Defense Gun | Rampart | Zhestokost | A column does not dodge. It does not need to. |
| Point Defense Gun | Swatter | AU | Keeps the rocks and the rockets off your back while you work. |
| Point Defense Gun | Catch-All | R&D | Guaranteed to stop the missile!* *Missile defined as whatever it stops. |
| Point Defense Laser | Glare | Lucent | Every missile gets its moment in the spotlight. A short one. |
| Point Defense Laser | Nightlight | NiteLife | Lights out for anything with a warhead. Shares the bus with your blasters, so mind the meter. |
| Point Defense Laser | Wren | Finch | Small, quiet and quicker than it looks, most days. |
| Point Defense Laser | Gnat | Alakrita | Weighs less than your conscience and keeps the fast pass fast. |
| Flak Gun | MFer | Zhestokost | Our shrapnel turns your enemies into scrapnel. (the operator's own line, Item Wishlist :57) |
| Flak Gun | Hailstorm | AU | A wall of steel for crews who would rather not count missiles. |
| Flak Gun | sky full of teeth | DME | light it up/ shake it loose/ nothing gets through tonight |
**Generic design names** (`generic-design-names`; hulls, Traction and already generic names
excluded: hull names are the model names other passes are revising, retire-longinusx deletes
Traction):
| Old design name | Generic design | Functional description |
|---|---|---|
| ClearPath | Light Machine Gun | Light ballistic hose: many small rounds at short range. |
| 6k Shooter | Slug Rifle | Single heavy slugs, fired as fast as the trigger. |
| Earp | Long Slug Rifle | A lighter slug rifle with the longest ballistic reach. |
| DeathCluster | Cluster Cannon | Medium shotgun: a heavy volley of pellets. |
| pretty pretty bang bang | Charged Scattergun | Ballistic shotgun that charges for a heavier volley. |
| Spectra | Light Laser | The lightest beam: low damage, quick cycle, long reach. |
| FastBlast+- | Cell Blaster | Rapid energy bursts fed by cells that cut its reactor draw. |
| ColdFire | Long Laser | Heavy small beam: slow, hard-hitting, long reach. |
| CShot RainbowLite Lazer | Heavy Beam Laser | The heaviest beam: nine cells, enormous damage and reach. |
| ChargeBlast+- | Charged Blaster | One charged knockout shot. |
| ChargeBlast SG | Charged Scatter Blaster | Charged energy shotgun. |
| plight | Lightning Projector | Charged chain lightning. |
| GT 3K | Guided Missile Launcher | Fires guided missiles. |
| LRMM72 | Long-Range Missile Rack | Large missile rack for long engagements. |
| SRMM72 | Short-Range Missile Rack | Extra-large rack of short-range missiles. |
| pswarm | Swarm Rocket Pod | Dumbfire rockets in a swarm. |
| scorched void policy | Incendiary Rocket Pod | Incendiary dumbfire rockets. |
| Arctica | Adaptive Radiator | Radiator whose output scales widely with conditions. |
| Iapyx | Radiator | Standard two-cell radiator. |
| Skiron | Long Radiator | Four-cell radiator with a large surface. |
| OK Disperser | Heat Pump | Moves heat to where it can be shed. |
| cold like my heart | Cryogenic Heat Pump | Heavy heat pump that drives temperatures far down. |
| Core Power | Compact Reactor | Small reactor. |
| Manhattan | Heavy Reactor | Large three-by-three reactor. |
| Notorious | High-Yield Reactor | Large reactor with a wide output range. |
| Vulcan | Ring Reactor | Ring-shaped reactor, four by four. |
| MoveOnPro | Muon Fusion Reactor | Compact fusion reactor that draws a reservoir. |
| Core Power Station Reactor | Station Reactor | A reactor sized for a station's core. |
| Manhattan Station Reactor | Heavy Station Reactor | A heavy reactor sized for a station's core. |
| Notorious Station Reactor | High-Yield Station Reactor | A high-yield reactor sized for a station's core. |
| Vulcan Station Reactor | Ring Station Reactor | A ring reactor sized for a station's core. |
| MoveOnPro Station Reactor | Muon Fusion Station Reactor | A muon fusion reactor sized for a station's core. |
| RevvITup 2.0 | High-Thrust Drive | Large drive: huge thrust, little torque. |
| deep space burnout | Burn Drive | Large drive that runs very hot. |
| Talaria | Rugged Drive | Two-cell working drive. |
| Victoire | Racing Drive | Feather-light two-cell drive with extreme response. |
| The Bat | Sensor Array | Two-by-two long-range sensor array. |
| not if i see you first | Compact Sensor | One-cell sensor. |
| PotaT+- | Capacitor | Stores charge for burst draw. |
| SafeStuff II | Secure Cargo Bay | Three-by-three secured hold. |
| Store-All Enterprise Edition | Large Cargo Bay | Six-by-six hold. |
| Store-All Plus | Cargo Bay | Two-by-two hold. |
| Store-All Plus Mini | Small Cargo Bay | One-cell hold. |
The product keeps the old name, so the brand survives as the product. Products whose name equals a
generic design get a brand: Autocannon (Zhestokost) -> Hammerfall; Cockpit 2x2 (Lightsail) ->
Wheelhouse; Industrial Thermostatic Heater (Lightsail) -> Hearth; Medium Drive (Lightsail) -> Steady
Haul; Small Drive (Lightsail) -> Short Hop; Turret Control Module (AU) -> Watchtower; Medium Docking
Bay (AU) -> Open Berth. The Bat's product is Miss Terri's: its record is not touched; only the
generic design under it is renamed. Description move: the design's old text goes to its product
when the product's is empty, `[TODO]` or identical; otherwise the product keeps its own and the
dropped design text is printed in the dry run and the commit message.
**Weapon product lines** (`product-lines-weapons`):
| Design | New product | Maker | Description |
|---|---|---|---|
| Light Machine Gun | Rattler | AU | Cheap, loud, and there when the rocks start moving. |
| Slug Rifle | SM-6 Marksman | R&D | Standardised, certified, guaranteed to hit the target.* |
| Long Slug Rifle | Longshot | Finch | Light, quiet and dead accurate, when it feels like it. |
| Autocannon | Workhorse | AU | Feeds from anything, fires at anything. |
| Cluster Cannon | everything at once | DME | why aim/ when you can be everywhere |
| Charged Scattergun | Grapeshot | Zhestokost | The old answer to boarders, sized for the void. |
| Light Laser | Sparrow | Finch | Barely registers on the scale, or on their sensors. |
| Light Laser | Spotlight | Lucent | Make sure they know who got them. |
| Cell Blaster | Strobe | Lucent | Fast, bright, and very hard to ignore. |
| Long Laser | Kestrel | Finch | Hovers at the edge of range, then stoops. |
| Heavy Beam Laser | Afterglow | NiteLife | Keeps glowing long after the fight is over. |
| Charged Blaster | Encore | Lucent | One more, for the fans. |
| Charged Scatter Blaster | nobody leaves early | DME | doors are locked/ lights are low/ stay for the finale |
| Lightning Projector | Static Shock | NiteLife | A jolt for every ship in reach. |
| Guided Missile Launcher | Homer | Finch | Finds its way, mostly. |
| Long-Range Missile Rack | Long Haul | AU | Delivers to the far side of the zone. |
| Short-Range Missile Rack | Short Fuse | AU | For problems already too close. |
| Swarm Rocket Pod | Confetti | Lucent | Every launch is a celebration. |
| Incendiary Rocket Pod | Trebuchet | Zhestokost | Siege fire for a column that does not stop. |
### Model page rows
| Kind | What names it | Over time | Who decides |
|---|---|---|---|
| `FactionProductData` (catalog) | Its record | Every sold design carries at least one product whose name differs from the design's, and is expected to carry several | The catalog author: the brand a player sees |
| Item title | `ItemManager.Title(item)` | Derived on each read from the lot's brand; the design name stays as `Type` | `ItemManager.Title`, the one owner; there is no `DisplayName` |
| `Lot` (run save) | `Lot.Product` (key 4) | Written once by `CreateLot(product)`; a lot made without a product (console give, extraction) has none and shows its maker only. A product that no longer resolves reads as unbranded | `CreateLot`; `Brand` reads it and nothing re-derives a product from maker and design |
| `WeaponItemData` (catalog) | Three new generic designs: Point Defense Gun, Point Defense Laser, Flak Gun | Authored once by `pd-gear` | The catalog author |
### Rationale
**Titles name the product.** One engine-free derivation in ServerShared, `ItemManager.Title(
CraftedItemInstance)`: the brand's product name, else the design name. Every title site reads it.
This is what makes "branding lives in products" visible, and what shows the Pirates' emoji names.
`lot-product` moves the brand's authority from a catalog query (LP2) into the lot, so that two
products of one design by one maker stay distinct (CG4, LP9). A first plan for it added a
`DisplayName` over the same hunks as `Title`; Soul verdict `cut-product-titles.s1` found two title
owners in force (`title-vs-displayname-split`) and Self ruled Title the one owner. A lot whose
product no longer resolves (a removed mod's product) reads as unbranded: `Brand` returns (maker,
null) and `Title` falls back to the design; `RequireDesigns` is not widened (ruling
`no-save-compatibility-before-players`). The census duplicate-brand block (LP7) goes away.
**Why three PD designs and not five.** The missile-defence rulings in force are `pd-who-engages`
(any gun, priced by its own stats), `missiles-sim-side-light` (missiles are lightweight records PD
can engage), `weapon-tracking-authored` (a PD class near 40 deg/s exists to be authored) and
`speed-demon-counterplay`. They need guns with PD stats, which the catalog lacks: the PD Gun
(ballistic hose, the Djinni's slots), the PD Laser (energy, the Longinus's slots, so the demo's
starting hull can fit PD) and the Flak Gun (Zhestokost's flak doctrine as a pellet gun, which needs
no new rule: each pellet rolls). An interceptor launcher needs missiles that take munition subjects
and a decoy launcher needs a signature the seeker can read that is not a ship; both are new
mechanics, which the target's not-in-scope line excludes until ruled. They are follow-ups
`interceptor-launcher` and `missile-decoys-signature`, not catalog content. An airburst flak (a
blast that wears munitions in its disc) is follow-up `flak-airburst-at-munitions`.
**Derivation of the PD stats** (from CG5 and the tracking table). Tracking 40 is the table's PD
anchor (0.82 / 0.94 against the speed demon at TC / FCA). PD Gun is ClearPath's role pushed to its
extreme: one cell, lighter (40 kg), less damage per round (1.5-4) at a higher rate (0.06-0.025 s)
and shorter reach (300-550 m), because a munition has 20 durability (CG7): 5 to 14 rounds kill one.
It draws ammunition (`ballistics-real-ammo`) and a small cycling energy, 0.4-0.2
(`ballistics-cycle-power`). PD Laser is Spectra's role: instant beam, 5-12 per shot every 0.2-0.1 s
to 400-750 m, energy-limited at 6-10 a shot, 2 to 4 hits a missile. Flak Gun is DeathCluster's
shotgun at small calibre, 1x2: 6-8 pellets sharing 30-70 damage a volley, 350-800 m, Tracking 20
(between PD and ClearPath, because pellets cover what tracking misses).
## Retiring the LonginusX and parking the aether drive
Ruling `retire-longinusx`, in the operator's words: "I want to retire the LonginusX, the aether

View file

@ -0,0 +1,241 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using Xunit;
// The demo scenario (aetheria-release, cut demo-galaxy): a prelude galaxy of a fixed cast whose antagonist's home is the
// boss zone and the exit. The shipped catalog holds no Pirates faction yet, so each test that needs one adds it to its
// scratch copy of the catalog.
public sealed partial class RunStartTests
{
private static readonly uint[] DemoSeeds = { 1, 2, 3, 4, 5 };
// The catalog record the demo's protagonist needs, until the shipped catalog has one. Zone names need a name file,
// and the entrance's station needs a faction whose allegiance reaches some manufacturer, so it reaches every one.
private void AddPirates()
{
var pirates = new Faction
{
Name = "Pirates", ShortName = "Pirates", GeonameFile = _cache.RefOf(_cache.GetAll<NameFile>().First())
};
foreach (var faction in _cache.GetAll<Faction>().ToArray()) pirates.Allegiance[_cache.RefOf(faction)] = 1;
_cache.Upsert(pirates);
}
private Galaxy DemoGalaxy(uint seed) => RunStart.Generate(new DemoTerminus(), Inputs(() => seed));
[Fact]
public void DemoTerminus_fixed_cast()
{
AddPirates();
foreach (var seed in DemoSeeds)
Assert.Equal(new[] { "Pirates", "Zhestokost", "Lucent Media", "Aeronautics Unlimited" },
DemoGalaxy(seed).Factions.Select(faction => faction.Name));
}
[Fact]
public void DemoTerminus_gate_at_antagonist_home()
{
AddPirates();
foreach (var seed in DemoSeeds)
{
var galaxy = DemoGalaxy(seed);
// Zones are compared by index: xunit formats a failing zone into its message, and a zone drags the galaxy
// graph in with it until the test host dies.
int At(GalaxyZone zone) => Array.IndexOf(galaxy.Zones, zone);
var zhestokost = galaxy.Factions.Single(faction => faction.Name == "Zhestokost");
Assert.Equal(new[] { "Zhestokost" }, galaxy.BossZones.Keys.Select(faction => faction.Name));
Assert.Equal(At(galaxy.HomeZones[zhestokost]), At(galaxy.BossZones[zhestokost]));
Assert.Equal(At(galaxy.HomeZones[zhestokost]), At(galaxy.Exit));
Assert.NotEqual(At(galaxy.Entrance), At(galaxy.Exit));
}
}
// The player's standing is part of the run: Continue restores it, and the gate, through the saved game.
[Fact]
public void DemoTerminus_standing_survives_continue()
{
AddPirates();
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1));
Assert.True(failures.Count == 0, string.Join("; ", failures));
galaxy.Entrance.Contents = arena;
var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0], 0);
RunSave.Commit(_cache, game, zones, _items.Lots);
var restored = new Galaxy(_cache, _cache.GetGlobal<SavedGame>(), _ => { });
Dictionary<string, FactionRelationship> Standing(Galaxy g) => g.Factions.ToDictionary(faction => faction.Name, faction => g.FactionRelationships[faction]);
Assert.Equal(new Dictionary<string, FactionRelationship>
{
["Pirates"] = FactionRelationship.Friendly,
["Zhestokost"] = FactionRelationship.Hated,
["Lucent Media"] = FactionRelationship.Neutral,
["Aeronautics Unlimited"] = FactionRelationship.Neutral
}, Standing(restored));
Assert.Equal(Standing(galaxy), Standing(restored));
Assert.Equal(Array.IndexOf(galaxy.Zones, galaxy.Exit), Array.IndexOf(restored.Zones, restored.Exit));
Assert.Equal(
galaxy.BossZones.Select(boss => (boss.Key.Name, Array.IndexOf(galaxy.Zones, boss.Value))),
restored.BossZones.Select(boss => (boss.Key.Name, Array.IndexOf(restored.Zones, boss.Value))));
}
// On the shipped catalog, which has no Pirates, the run does not start: the galaxy refuses, naming the cast field,
// and the scenario never reaches staging.
[Fact]
public void DemoTerminus_missing_cast_refused()
{
Assert.DoesNotContain(_cache.GetAll<Faction>(), faction => faction.Name.StartsWith("Pirates", StringComparison.Ordinal));
var refusal = Assert.Throws<InvalidOperationException>(() => DemoGalaxy(1));
Assert.Contains("Pirates", refusal.Message);
Assert.Contains(nameof(TutorialGenerationSettings.ProtagonistFaction), refusal.Message);
}
// The operator's ruling is that the first release is a demo: the one scenario a release build lists says so by name.
[Fact]
public void DemoTerminus_is_named_a_demo()
{
var listed = Assert.Single(Scenarios.Modes);
Assert.IsType<DemoTerminus>(listed);
Assert.Equal("Terminus (Demo)", listed.Name);
}
// The run start as New Game runs it: the player is not a Pirate, so the hull is the base Longinus, made by Alakrita,
// not by the Pirates or any cast faction, with no faction on the ship, at the entrance zone's origin.
[Fact]
public void DemoTerminus_player_flies_a_non_pirates_brand_hull_the_longinus()
{
AddPirates();
foreach (var seed in DemoSeeds)
{
var (galaxy, _, staged, failures) = Launch(new DemoTerminus(), Inputs(() => seed));
Assert.True(failures.Count == 0, string.Join("; ", failures));
Assert.Equal("Longinus", staged.Player.HullData.Name);
var design = _cache.RefOf(staged.Player.HullData).Key;
var product = Assert.Single(_cache.GetAll<FactionProductData>(), p => p.Design.Key.Equals(design));
var maker = _cache.Get(product.Manufacturer);
Assert.Equal("Alakrita", maker.Name);
Assert.DoesNotContain(galaxy.Factions, faction => faction.Name == maker.Name);
Assert.Null(staged.Player.Faction);
Assert.Equal(float3.zero, staged.Player.Position);
}
}
// The ruling says the Longinus with its default fit: the fit the game generates for that hull from the run's item
// draws, which Stage seeds from the galaxy's seed. The expected fit is that same generation replayed from the same
// seed, so no item list is spelled here; a bare hull, a preset, or one item swapped for another differs from it.
[Fact]
public void DemoTerminus_player_carries_the_longinus_default_fit()
{
AddPirates();
string Fit(Entity ship) => string.Join(";", ship.Equipment
.Select(item => $"{item.Data.Name}@{item.Position}/{item.EquippableItem.Rotation}")
.OrderBy(entry => entry, StringComparer.Ordinal));
foreach (var seed in DemoSeeds)
{
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => seed));
Assert.True(failures.Count == 0, string.Join("; ", failures));
_items.Random = new CultMath.Random(galaxy.Seed * 0x9E3779B1u + 2);
var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, arena.GalaxyZone, null, 2);
var expected = EntitySerializer.Unpack(_items, null, generator.GenerateShipLoadout(hull => hull.Name == "Longinus"));
Assert.NotEmpty(expected.Equipment);
Assert.Equal(Fit(expected), Fit(staged.Player));
}
}
// The demo region's size and connectivity are authored: 64 zones, links thinned to half. The reference galaxy is the
// same cast with the thinning written out here, generated at the same seed, so any other density is a different graph.
[Fact]
public void DemoTerminus_region_is_sixty_four_zones_linked_at_half_density()
{
AddPirates();
string Links(Galaxy g) => string.Join(";", g.Zones.Select(zone =>
string.Join(",", zone.AdjacentZones.Select(adjacent => Array.IndexOf(g.Zones, adjacent)).OrderBy(index => index))));
Galaxy Reference(uint seed, float density)
{
var cast = new TutorialGenerationSettings
{
ProtagonistFaction = "Pirates", AntagonistFaction = "Zhe", BufferFaction = "Luc",
NeutralFactions = new[] { "Aero" }, QuestFaction = null, ZoneCount = 64, LinkDensity = density
};
return RunStart.Generate(new Scripted(false, _ => { }, seed, galaxy => galaxy.Prelude(cast)), Inputs());
}
foreach (var seed in DemoSeeds)
{
var galaxy = DemoGalaxy(seed);
Assert.Equal(64, galaxy.Zones.Length);
Assert.Equal(Links(Reference(seed, .5f)), Links(galaxy));
Assert.NotEqual(Links(Reference(seed, 1f)), Links(galaxy));
}
}
// A cast that names no quest faction places no quest zone: one home for each of its four factions.
[Fact]
public void ACastNamingNoQuestFactionPlacesNoQuestZone()
{
AddPirates();
var galaxy = DemoGalaxy(1);
Assert.Equal(4, galaxy.HomeZones.Count);
Assert.Equal(4, galaxy.Factions.Length);
}
// A name that resolves to nothing refuses the galaxy whichever cast field names it, and the refusal names the field.
[Theory]
[InlineData(nameof(TutorialGenerationSettings.ProtagonistFaction))]
[InlineData(nameof(TutorialGenerationSettings.AntagonistFaction))]
[InlineData(nameof(TutorialGenerationSettings.BufferFaction))]
[InlineData(nameof(TutorialGenerationSettings.QuestFaction))]
[InlineData(nameof(TutorialGenerationSettings.NeutralFactions))]
public void AnUnresolvedCastNameRefusesTheGalaxyNamingItsField(string field)
{
var cast = new TutorialGenerationSettings
{
ProtagonistFaction = _tutorialSettings.ProtagonistFaction,
AntagonistFaction = _tutorialSettings.AntagonistFaction,
BufferFaction = _tutorialSettings.BufferFaction,
QuestFaction = _tutorialSettings.QuestFaction,
NeutralFactions = _tutorialSettings.NeutralFactions,
ZoneCount = _tutorialSettings.ZoneCount,
LinkDensity = _tutorialSettings.LinkDensity
};
typeof(TutorialGenerationSettings).GetField(field).SetValue(cast, field == nameof(cast.NeutralFactions) ? new[] { "Nonesuch" } : "Nonesuch");
var scenario = new Scripted(false, _ => { }, generate: galaxy => galaxy.Prelude(cast));
var refusal = Assert.Throws<InvalidOperationException>(() => RunStart.Generate(scenario, Inputs()));
Assert.Contains(field, refusal.Message);
}
// What a release build lists under New Game is the demo alone; the tutorial and the main galaxy are listed only in
// editor and development builds (MainMenu.ShowScenarios), beside the test arenas.
[Fact]
public void ReleaseBuildsListOnlyTheDemo()
{
Assert.Equal(new[] { typeof(DemoTerminus) }, Scenarios.Modes.Select(scenario => scenario.GetType()));
Assert.Equal(new[] { typeof(TutorialGalaxy), typeof(MainGalaxy) }, Scenarios.Development.Select(scenario => scenario.GetType()));
}
// The tutorial is the galaxy it was before the demo: its cast, homes, entrance and ownership at a fixed seed, with
// no exit and no boss zone.
[Fact]
public void TutorialGalaxy_unchanged()
{
var galaxy = RunStart.Generate(new TutorialGalaxy(), Inputs(() => GalaxySeed));
Assert.Equal(TutorialSeed1, TutorialShape(galaxy));
Assert.True(galaxy.Exit == null, "the tutorial has no exit");
Assert.True(galaxy.BossZones.Count == 0, $"the tutorial has {galaxy.BossZones.Count} boss zones");
}
private static string TutorialShape(Galaxy g) =>
string.Join(",", g.Factions.Select(f => f.Name)) +
" | homes " + string.Join(",", g.Factions.Select(f => g.HomeZones.TryGetValue(f, out var z) ? Array.IndexOf(g.Zones, z) : -2)) +
" | entrance " + Array.IndexOf(g.Zones, g.Entrance) +
" | zones " + g.Zones.Length +
" | owners " + string.Join(",", g.Zones.Select(z => z.Owner == null ? "-" : z.Owner.ShortName));
// Measured on 228f241e, before the demo cut, at seed 1.
private const string TutorialSeed1 =
"Miss Terri’s Sugariffic Snack Company,Zhestokost,Lucent Media,Adrasteia,Aeronautics Unlimited,Finch Cybernetics | homes 47,10,17,49,35,1 | entrance 25 | zones 64 | owners Zhestokost,Finch,Lucent,Zhestokost,Finch,Adrasteia,Zhestokost,Adrasteia,Zhestokost,Zhestokost,Zhestokost,Zhestokost,Finch,Lucent,Adrasteia,Adrasteia,Zhestokost,Lucent,Finch,Finch,Lucent,Lucent,Finch,AU,Adrasteia,-,-,Adrasteia,Lucent,-,AU,Lucent,Zhestokost,Lucent,Lucent,AU,Adrasteia,AU,Lucent,AU,Finch,Finch,-,Lucent,Zhestokost,Zhestokost,Miss Terri's,Miss Terri's,Miss Terri's,Adrasteia,Finch,-,-,Miss Terri's,Lucent,AU,Miss Terri's,Zhestokost,Lucent,Lucent,Lucent,Finch,-,-";
}

View file

@ -285,8 +285,9 @@ public sealed partial class RunStartTests
Assert.True(price > limited * 2f, $"the mount limit priced the limited gun down: {limited} against {price}");
}
// The shipped catalog's weapon behaviours: launchers and guided weapons never mount-track, every other gun is a mount.
private static bool EvMountless(WeaponData data) => data is LauncherData || data is GuidedWeaponData;
// The shipped catalog's weapon behaviours: launchers, guided weapons and mine layers (which lay at no bearing)
// never mount-track, every other gun is a mount.
private static bool EvMountless(WeaponData data) => data is LauncherData || data is GuidedWeaponData || data is MineLayerData;
private IEnumerable<(WeaponItemData item, WeaponData data)> EvCatalogGuns() =>
_cache.GetAll<WeaponItemData>().SelectMany(item => item.Behaviors.OfType<WeaponData>().Select(data => (item, data)));

View file

@ -23,7 +23,7 @@ using int2 = CultMath.int2;
// follows, with the rules this cut's own tests must obey: SchematicCellSize 2 throughout (the 12.3 fixture's
// cell size of 1 would hide a missing `/ SchematicCellSize`), the target off the origin, and a hull whose
// centre of mass is not an integer cell.
public sealed class FireControlCut124Tests : IDisposable
public sealed partial class FireControlCut124Tests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut124-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
@ -1397,7 +1397,7 @@ public sealed class FireControlCut124Tests : IDisposable
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, damage: 100f,
weaponRange: NoLockRange, targetRange: 40f);
e.Shooter.Direction = float2(0, -1);
Aim(e, float2(0, -1));
Aim(e, float2(0, 1)); // outside the flipped mount's arc too: no solution, and the aim is not free
Assert.Equal(fires, e.Weapon.ArcAllowsFire);
var committed = 0;
using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++);
@ -2595,7 +2595,8 @@ public sealed class FireControlCut124Tests : IDisposable
{
var weapons = cache.GetAll<WeaponItemData>().ToList();
Assert.NotEmpty(weapons);
foreach (var w in weapons)
// A mine layer is the one shipped weapon that carries a blast: its radius is the mine's (MineLauncherCatalogTests).
foreach (var w in weapons.Where(w => !w.Behaviors.OfType<MineLayerData>().Any()))
{
Assert.Null(w.BlastRadius);
Assert.Null(w.Fuse);

View file

@ -364,6 +364,7 @@ public sealed class FireControlCut5Tests : IDisposable
var settings = TestSettings();
settings.FiringArc = 60; // narrow -- an abeam target is well outside it
var e = BuildBeam(settings, float3(100, 0, 0)); // directly abeam
e.Shooter.Aim = float3(0, 0, -1); // astern, outside the arc: nothing bears and the aim is not free
e.Weapon.Activate();
Assert.True(e.Weapon.Firing); // Activate() sets _firing unconditionally, same as InstantWeapon.Trigger
@ -466,11 +467,13 @@ public sealed class FireControlCut5Tests : IDisposable
shooter.Position = float3.zero;
target.Position = float3(100, 0, 0); // directly abeam
shooter.SetTarget(target);
shooter.Aim = float3(0, 0, -1); // astern, outside the arc: only a solution can fire the gun
var weapon = (Weapon) weaponItem.Behaviors.Single(b => b is Weapon);
Assert.False(weapon.ArcAllowsFire);
target.Position = float3(0, 0, 100); // dead ahead
shooter.Aim = float3(0, 0, 1); // the free aim fires, as a gun with no target would
Assert.True(weapon.ArcAllowsFire);
}
@ -489,6 +492,7 @@ public sealed class FireControlCut5Tests : IDisposable
e.Shooter.Position = float3.zero;
e.Target.Position = float3(100, 0, 0); // abeam -- outside the narrow 60-degree arc
e.Shooter.SetTarget(e.Target);
e.Shooter.Aim = float3(0, 0, -1); // astern, outside the arc: nothing bears and the aim is not free
var resolvedCount = 0;
e.Zone.ShotResolved.Subscribe(_ => resolvedCount++);

View file

@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.IO;
using CultMath;
using GameCult.Caching;
using Xunit;
// Items are titled by their product (ruling catalog-grows-generic-designs-branded-products): ItemManager.Title reads
// Brand, so the lot's maker picks which of a design's products names the unit, and nothing else carries a name.
public sealed class ItemTitleTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-itemtitle-" + Guid.NewGuid().ToString("N"));
private readonly CultCache _cache;
private readonly ItemManager _items;
private readonly FactionProductData _panopticon, _clapBack, _spiceBlend;
private readonly CultRecordRef<Faction> _stranger;
private readonly CompoundCommodityData _spice;
private readonly GearData _array;
public ItemTitleTests()
{
Directory.CreateDirectory(_root);
_cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true);
_cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
_array = new GearData { Name = "Array", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10, Durability = 5 };
_cache.Upsert(_array);
_cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 });
var finch = _cache.Upsert(new Faction { Name = "Finch", ShortName = "FIN" });
var lucent = _cache.Upsert(new Faction { Name = "Lucent", ShortName = "LUC" });
_stranger = _cache.Upsert(new Faction { Name = "Stranger", ShortName = "STR" });
_spice = new CompoundCommodityData { Name = "Spice", Shape = new Shape(), Price = 3 };
_cache.Upsert(_spice);
var design = _cache.RefOf<CraftedItemData>(_cache.GetByName<GearData>("Array"));
_panopticon = new FactionProductData { Name = "Panopticon Prime", Design = design, Manufacturer = finch };
_clapBack = new FactionProductData { Name = "ClapBack Ultra", Design = design, Manufacturer = lucent };
_cache.Commit(batch => batch.Upsert(typeof(FactionProductData), _panopticon, new CultRecordKey("array-finch")));
_cache.Commit(batch => batch.Upsert(typeof(FactionProductData), _clapBack, new CultRecordKey("array-lucent")));
_spiceBlend = new FactionProductData { Name = "Finch Reserve", Design = _cache.RefOf<CraftedItemData>(_spice), Manufacturer = finch };
_cache.Commit(batch => batch.Upsert(typeof(FactionProductData), _spiceBlend, new CultRecordKey("spice-finch")));
_items = new ItemManager(_cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
}
public void Dispose()
{
_cache.Dispose();
Directory.Delete(_root, true);
}
[Fact]
public void A_product_unit_is_titled_by_its_product()
{
var unit = _items.CreateInstance(_items.CreateLot(_panopticon));
Assert.Equal("Panopticon Prime", _items.Title(unit));
Assert.NotEqual("Panopticon Prime", _items.GetData(unit).Name);
}
[Fact]
public void Two_makers_title_their_own_units()
{
var finch = _items.CreateInstance(_items.CreateLot(_panopticon));
var lucent = _items.CreateInstance(_items.CreateLot(_clapBack));
Assert.Equal(_items.GetData(finch).Name, _items.GetData(lucent).Name);
Assert.Equal("Panopticon Prime", _items.Title(finch));
Assert.Equal("ClapBack Ultra", _items.Title(lucent));
}
[Fact]
public void Unbranded_units_are_titled_by_design()
{
var given = _items.CreateInstance(_items.CreateLot(_array, default, .5f));
Assert.Equal("Array", _items.Title(given));
var extractedLot = _items.Lots.Add(new Lot
{
Design = _cache.RefOf<ItemData>(_array), Origin = new Extracted(), Quality = .5f, Roles = new List<RoleFill>()
});
Assert.Equal("Array", _items.Title(_items.CreateInstance(extractedLot)));
var ore = new SimpleCommodity { Data = _cache.RefOf<ItemData>(_cache.GetByName<SimpleCommodityData>("Ore")), Quantity = 3 };
Assert.Equal("Ore", _items.Title(ore));
}
[Fact]
public void A_maker_without_a_product_of_the_design_is_titled_by_design()
{
var unit = _items.CreateInstance(_items.CreateLot(_array, _stranger, .5f));
Assert.Equal("Stranger", _items.Brand(unit).Maker.Name);
Assert.Equal("Array", _items.Title(unit));
}
[Fact]
public void A_crafted_commodity_with_a_product_is_titled_by_its_product()
{
var unit = _items.CreateInstance(_items.CreateLot(_spiceBlend));
Assert.IsType<CompoundCommodity>(unit);
Assert.Equal("Finch Reserve", _items.Title(unit));
}
}

View file

@ -0,0 +1,30 @@
using System.Linq;
using Xunit;
// sim-mines-presenter: the shipped catalog carries the Mine Launcher a ship can be fitted with, and what it
// carries is what the sim's mine layer reads (MineLayerData for the mine's own timings, the item's WeaponType,
// Fuse and BlastRadius for the layer and the blast).
public sealed class MineLauncherCatalogTests
{
[Fact]
public void TheShippedCatalogHasAMineLauncherThatLaysMines()
{
var cache = RestoredHullsTests.OpenCatalog();
var launcher = Assert.Single(cache.GetAll<GearData>().OfType<WeaponItemData>(), item => item.Name == "Mine Launcher");
Assert.Equal(HardpointType.Launcher, launcher.HardpointType);
Assert.NotNull(launcher.Shape);
Assert.Contains(cache.GetAll<FactionProductData>(), product => cache.RefOf(launcher).Key.Equals(product.Design.Key));
Assert.Equal(WeaponType.Mine, launcher.WeaponType);
Assert.Equal(WeaponFuse.Proximity, launcher.Fuse);
Assert.Equal(25f, launcher.BlastRadius);
var layer = Assert.IsType<MineLayerData>(Assert.Single(launcher.Behaviors));
Assert.Equal(2f, layer.ArmingDelay);
Assert.Equal(2f, layer.FuseDelay);
Assert.Equal(30f, layer.Lifetime);
Assert.True(layer.Damage.Max > 0f);
Assert.True(layer.Velocity.Max > 0f);
Assert.True(layer.Cooldown.Max > 0f);
}
}

View file

@ -446,6 +446,24 @@ public sealed class MineTests : IDisposable
Assert.True(shipsA[4].Hull.Durability < 1000000f);
}
// The trigger gate never arcs a mine layer (FireControl.ArcPermitsFire; IsMineLayer is its own half of the
// exemption). The layer is unfused, so the fused half cannot pass it: only the mine-layer exemption can. An unfused
// layer lays nothing (Lay), so the gate is the layer this is decided at. The fixture checks the aim is outside
// the arc, with no target designated, so the gate has no other way to pass.
[Fact]
public void AMineLayerPassesTheTriggerGateWithTheAimOutsideItsArc()
{
var lab = Build(fuseKind: null);
Assert.Null(FireControl.FuseOf(lab.WeaponItem, out _));
Assert.True(FireControl.IsMineLayer(lab.Weapon));
lab.Layer.Aim = -FireControl.MountDirection(lab.WeaponItem);
Assert.False(FireControl.InArc(lab.WeaponItem, lab.Layer.Aim), "fixture: the aim must be outside the arc");
Assert.False(FireControl.Solution(lab.Weapon, lab.Layer, lab.Layer.Target.Value).Free);
Assert.False(FireControl.Solution(lab.Weapon, lab.Layer, lab.Layer.Target.Value).Bears);
Assert.True(FireControl.ArcPermitsFire(lab.Weapon, lab.Layer));
}
[Fact]
public void LayingFiresNoShot()
{

View file

@ -719,6 +719,7 @@ public sealed partial class MiningCut3Tests : IDisposable
Tick((shooter, eyeAt));
var gun = shooter.Weapons.Single();
shooter.Aim = float3(0, 0, -1); // astern: only a solution can fire the gun
Assert.True(shooter.SetTarget(ahead));
Assert.True(gun.ArcAllowsFire);
Assert.True(gun.StanceAllowsFire);
@ -728,6 +729,33 @@ public sealed partial class MiningCut3Tests : IDisposable
Assert.True(gun.StanceAllowsFire);
}
// The same bearing through the solution: a chunk ahead is a solution, one to the side is not, and a chunk is a
// solution without a subject entity (its round flies the aim, as an untargeted one).
[Fact]
public void AChunkTargetBearsByItsPose()
{
var kind = Kind("Asteroid", 4f);
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f, .25f), Rock(150f)));
var eyeAt = float2(-10, 50);
var shooter = SpawnShip(s, eyeAt, sensor: true, weaponRanges: 300f);
Tick((shooter, eyeAt));
var gun = shooter.Weapons.Single();
var ahead = FireControl.Solution(gun, shooter, new ChunkId(s.Belts[0], 0));
var aside = FireControl.Solution(gun, shooter, new ChunkId(s.Belts[0], 1));
Assert.True(ahead.Bears);
Assert.Null(ahead.Subject);
Assert.False(aside.Bears);
// The bearing is the chunk's pose minus the shooter's position: from (100, -100) the side chunk at (150, 0)
// is 26 degrees off the nose, though its pose sum with the shooter's position would be far abeam.
var nearAt = float2(100, -100);
var near = SpawnShip(s, nearAt, sensor: true, weaponRanges: 300f);
Tick((shooter, eyeAt), (near, nearAt));
Assert.True(FireControl.Solution(near.Weapons.Single(), near, new ChunkId(s.Belts[0], 1)).Bears);
}
// A writable copy of the shipped catalog plus a scratch run store, through a registry scoped to the shipped
// assembly's own document types (RestoredHullsTests' composition): this test assembly's own catalog global
// would otherwise be demanded of a catalog that was never authored with it.

View file

@ -0,0 +1,278 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using CultMath;
using UniRx;
using Xunit;
// The demo's goal (aetheria-release, cut boss-gate): the boss zone spawns its faction's boss, and the exit gate opens
// only when that boss is dead. Every input is the production one: the demo galaxy, GenerateZone, the Zone death path,
// and the run save.
public sealed partial class RunStartTests
{
private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true)
{
items.Random = new CultMath.Random(7);
return ZoneGenerator.GenerateZone(items, _zoneSettings, galaxy, zone, galaxy.IsPrelude, ambient);
}
private Zone ZoneOf(Galaxy galaxy, GalaxyZone zone) =>
new Zone(_items, _planetSettings, BossZonePack(galaxy, _items, zone), zone, galaxy);
private static Faction Zhestokost(Galaxy galaxy) => galaxy.Factions.Single(faction => faction.Name == "Zhestokost");
private ItemManager LoggingItems(List<string> log) =>
new ItemManager(_cache, new ProvenanceLedger(), AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("GameplaySettings"), log.Add);
private static string HullName(ItemManager items, EntityPack pack) => items.GetData(pack.Hull).Name;
private HullData[] SoldShipHulls() => _cache.GetAll<FactionProductData>()
.Where(product => product.Manufacturer.IsSet())
.Select(product => _cache.Get(product.Design) as HullData)
.Where(hull => hull != null && hull.HullType == HullType.Ship && hull.Price > 0)
.Distinct().ToArray();
[Fact]
public void Boss_spawns_once_in_boss_zone()
{
AddPirates();
foreach (var seed in DemoSeeds.Take(3))
{
var galaxy = DemoGalaxy(seed);
var antagonist = Zhestokost(galaxy);
foreach (var zone in galaxy.Zones)
{
var bosses = BossZonePack(galaxy, _items, zone).Entities.Where(entity => entity.Boss).ToArray();
if (zone != galaxy.Exit) { Assert.Empty(bosses); continue; }
var boss = Assert.Single(bosses);
Assert.IsType<ShipPack>(boss);
Assert.Same(antagonist, _cache.Get(boss.Faction));
// Generation is repeatable per zone: the same zone yields the same boss hull.
var again = Assert.Single(BossZonePack(galaxy, _items, zone).Entities, entity => entity.Boss);
Assert.Equal(HullName(_items, boss), HullName(_items, again));
}
}
}
// A zone that is not ambient still has its boss: the boss is the goal, not traffic.
[Fact]
public void Boss_spawns_in_a_zone_that_is_not_ambient()
{
AddPirates();
var galaxy = DemoGalaxy(1);
var pack = BossZonePack(galaxy, _items, galaxy.Exit, ambient: false);
Assert.Single(pack.Entities, entity => entity.Boss);
Assert.DoesNotContain(pack.Entities, entity => !entity.Boss && entity is ShipPack);
}
[Fact]
public void Boss_hull_from_BossHull_else_fallback()
{
AddPirates();
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var hulls = SoldShipHulls().OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).ToArray();
var priciest = hulls.First();
var cheaper = hulls.Last();
Assert.True(cheaper.Price < priciest.Price, "the catalog needs two ship hulls of different price for this test to tell them apart");
// Set and sold: the boss flies it, and nothing is logged about a gap.
antagonist.BossHull = _cache.RefOf(cheaper);
var log = new List<string>();
var items = LoggingItems(log);
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal(cheaper.Name, HullName(items, boss));
Assert.DoesNotContain(log, line => line.Contains("BossHull"));
// Unset: the most expensive ship hull on offer, and the log names the missing BossHull.
antagonist.BossHull = default;
log.Clear();
boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal(priciest.Name, HullName(items, boss));
Assert.Contains(log, line => line.Contains("Zhestokost") && line.Contains("no BossHull"));
}
// Set but sold by no product: the boss falls back the same way, and the log says which hull has no seller.
[Fact]
public void Boss_hull_nobody_sells_falls_back()
{
AddPirates();
var galaxy = DemoGalaxy(1);
var antagonist = Zhestokost(galaxy);
var sold = SoldShipHulls();
// A ship hull no product sells, dearer than any that is sold: the fallback must not pick it.
var unsold = new HullData { Name = "Unsold Test Hull", HullType = HullType.Ship, Price = sold.Max(hull => hull.Price) + 1 };
_cache.Upsert(unsold);
antagonist.BossHull = _cache.RefOf(unsold);
var log = new List<string>();
var items = LoggingItems(log);
var boss = Assert.Single(BossZonePack(galaxy, items, galaxy.Exit).Entities, entity => entity.Boss);
Assert.Equal(sold.OrderByDescending(hull => hull.Price).ThenBy(hull => hull.Name, StringComparer.Ordinal).First().Name, HullName(items, boss));
Assert.Contains(log, line => line.Contains(unsold.Name) && line.Contains("BossHull"));
}
[Fact]
public void Exit_sealed_until_boss_dies()
{
AddPirates();
var galaxy = DemoGalaxy(1);
var exit = ZoneOf(galaxy, galaxy.Exit);
var boss = Assert.Single(exit.Entities, entity => entity.IsBoss);
Assert.True(RunGoal.BossAlive(exit));
Assert.False(RunGoal.ExitOpen(exit));
// Death removes the boss from the zone (the one death path), and that alone opens the gate.
boss.HeatstrokeDeath.OnNext(Unit.Default);
Assert.DoesNotContain(boss, exit.Entities);
Assert.False(RunGoal.BossAlive(exit));
Assert.True(RunGoal.ExitOpen(exit));
// No other zone is ever an exit, boss or no boss.
foreach (var zone in galaxy.Zones.Where(zone => zone != galaxy.Exit))
Assert.False(RunGoal.ExitOpen(ZoneOf(galaxy, zone)));
}
// Continue: the mark is saved with the zone and restored, so a boss alive when the run was saved is alive after.
[Fact]
public void Boss_mark_survives_continue()
{
AddPirates();
var (galaxy, arena, staged, failures) = Launch(new DemoTerminus(), Inputs(() => 1));
Assert.True(failures.Count == 0, string.Join("; ", failures));
galaxy.Entrance.Contents = arena;
galaxy.Exit.Contents = ZoneOf(galaxy, galaxy.Exit);
var before = Assert.Single(galaxy.Exit.Contents.Entities, entity => entity.IsBoss);
Assert.False(RunGoal.ExitOpen(galaxy.Exit.Contents));
var (game, zones) = RunSave.Capture(_cache, galaxy, arena, staged.Player, new SavedActionBarBinding[0], 0);
RunSave.Commit(_cache, game, zones, _items.Lots);
var restored = new Galaxy(_cache, _cache.GetGlobal<SavedGame>(), _ => { });
var exit = new Zone(_items, _planetSettings, restored.Exit.PackedContents, restored.Exit, restored);
var after = Assert.Single(exit.Entities, entity => entity.IsBoss);
Assert.Equal(before.Name, after.Name);
Assert.Equal(before.Faction.Name, after.Faction.Name);
Assert.False(RunGoal.ExitOpen(exit));
after.HeatstrokeDeath.OnNext(Unit.Default);
Assert.True(RunGoal.ExitOpen(exit));
}
// The gate fails closed. The main galaxy puts its bosses on choke points, not at its exit, so its exit zone never gets
// one: the gate stays sealed on arrival and when every boss in the galaxy dies, because a boss that never existed is
// not a dead one.
[Fact]
public void Exit_sealed_where_no_boss_was_spawned_main_galaxy()
{
foreach (var seed in new uint[] { 1, 2, 3, 4, 5, 6, 7, 8 })
{
var galaxy = RunStart.Generate(new MainGalaxy(), Inputs(() => seed));
var exit = ZoneOf(galaxy, galaxy.Exit);
Assert.False(RunGoal.ExitOpen(exit), $"seed {seed}: the gate is open on arrival");
foreach (var boss in exit.Entities.Where(entity => entity.IsBoss).ToArray())
boss.HeatstrokeDeath.OnNext(Unit.Default);
Assert.False(RunGoal.ExitOpen(exit), $"seed {seed}: the gate is open with no boss spawned");
}
}
// The same for a demo exit whose boss could not be generated: the zone logs the gap by name and the gate stays sealed.
[Fact]
public void Exit_sealed_and_logged_when_the_boss_cannot_be_generated()
{
AddPirates();
var galaxy = DemoGalaxy(1);
galaxy.BossZones.Clear();
var log = new List<string>();
var items = LoggingItems(log);
var pack = BossZonePack(galaxy, items, galaxy.Exit);
Assert.False(pack.BossSpawned);
Assert.Contains(log, line => line.Contains("Galaxy.Exit") && line.Contains("exit-without-boss"));
var exit = new Zone(items, _planetSettings, pack, galaxy.Exit, galaxy);
Assert.False(RunGoal.ExitOpen(exit));
}
// A boss killed before the save stays dead after it, and the gate stays open: the spawn fact travels with the zone.
[Fact]
public void Exit_open_after_save_of_a_slain_boss()
{
AddPirates();
var galaxy = DemoGalaxy(1);
var exit = ZoneOf(galaxy, galaxy.Exit);
Assert.Single(exit.Entities, entity => entity.IsBoss).HeatstrokeDeath.OnNext(Unit.Default);
var repacked = exit.PackZone();
Assert.True(repacked.BossSpawned);
Assert.DoesNotContain(repacked.Entities, entity => entity.Boss);
Assert.True(RunGoal.ExitOpen(new Zone(_items, _planetSettings, repacked, galaxy.Exit, galaxy)));
}
// The gate shares no point with an adjacency wormhole: every one is farther from it than a ship's wormhole reach.
[Fact]
public void Exit_gate_shares_no_point_with_a_wormhole()
{
AddPirates();
var ratio = float.Parse(File.ReadLines(Path.Combine(FindRepoRoot(), "Assets", "Resources", "Settings.asset"))
.Select(line => Regex.Match(line, @"^ WormholeDistanceRatio: (.+)$")).First(match => match.Success).Groups[1].Value.Trim(),
System.Globalization.CultureInfo.InvariantCulture);
var reach = AuthoredSettings.Load(FindRepoRoot()).Read<GameplaySettings>("GameplaySettings").WormholeExitRadius;
foreach (var seed in DemoSeeds)
{
var galaxy = DemoGalaxy(seed);
var zone = ZoneOf(galaxy, galaxy.Exit);
var gate = RunGoal.ExitGatePosition(zone, ratio);
foreach (var adjacent in galaxy.Exit.AdjacentZones)
{
var wormhole = math.normalize(adjacent.Position - galaxy.Exit.Position) * zone.Pack.Radius * ratio;
Assert.True(math.length(gate - wormhole) > 2 * reach, $"seed {seed}: the exit gate sits {math.length(gate - wormhole)} from a wormhole");
}
}
}
// The gate sits on the wormholes' radius, in the middle of the widest gap between neighbours, whatever the
// neighbours' number and order: an empty ring, one, two straddling the +-pi seam, and eight spread evenly (every gap
// under one radian). Checked against a brute-force sweep of every direction.
[Theory]
[InlineData(new double[0])]
[InlineData(new[] { 40.0 })]
[InlineData(new[] { 170.0, -170.0 })]
[InlineData(new[] { 100.0, 10.0, 20.0, 350.0, -90.0 })]
[InlineData(new[] { 0.0, 45.0, 90.0, 135.0, 180.0, -135.0, -90.0, -45.0 })]
public void Exit_gate_sits_in_the_widest_gap_at_the_wormhole_radius(double[] neighbourDegrees)
{
AddPirates();
var galaxy = DemoGalaxy(1);
var zone = ZoneOf(galaxy, galaxy.Exit);
const float ratio = 0.75f;
var radius = zone.Pack.Radius * ratio;
// Away from the origin and with close neighbours, so a direction taken from raw positions instead of the
// offset from the zone cannot pass.
var originalPosition = galaxy.Exit.Position;
var origin = galaxy.Exit.Position = math.float2(500, -300);
var original = galaxy.Exit.AdjacentZones;
galaxy.Exit.AdjacentZones = neighbourDegrees
.Select(degrees => new GalaxyZone
{
Position = origin + math.float2((float) Math.Cos(degrees * Math.PI / 180), (float) Math.Sin(degrees * Math.PI / 180)) * 10
}).ToList();
float2 gate;
try { gate = RunGoal.ExitGatePosition(zone, ratio); }
finally { galaxy.Exit.AdjacentZones = original; galaxy.Exit.Position = originalPosition; }
Assert.Equal(radius, math.length(gate), 2);
if (neighbourDegrees.Length == 0) { Assert.Equal(radius, gate.x, 2); Assert.Equal(0, gate.y, 2); return; }
double Separation(double angle) => neighbourDegrees.Min(degrees =>
{
var difference = Math.Abs(angle - degrees * Math.PI / 180) % (2 * Math.PI);
return difference > Math.PI ? 2 * Math.PI - difference : difference;
});
var best = Enumerable.Range(0, 7200).Max(step => Separation(step * Math.PI / 3600));
Assert.True(Separation(Math.Atan2(gate.y, gate.x)) >= best - 0.002,
$"the gate's nearest wormhole is {Separation(Math.Atan2(gate.y, gate.x))} rad away; the widest gap's middle is {best}");
}
}

View file

@ -44,7 +44,7 @@ public sealed partial class RunStartTests
public override void Stage(ScenarioStage stage) => _stage(stage);
}
private static IEnumerable<Scenario> EveryScenario => Scenarios.Modes.Concat(Scenarios.Tests);
private static IEnumerable<Scenario> EveryScenario => Scenarios.Modes.Concat(Scenarios.Development).Concat(Scenarios.Tests);
// The galaxy inputs the menu hands to RunStart, read from the authored settings.
private GalaxyStage Inputs(Func<uint> clock = null)
@ -96,6 +96,7 @@ public sealed partial class RunStartTests
[Fact]
public void EveryScenarioStages()
{
AddPirates();
Assert.Equal(EveryScenario.Count(), EveryScenario.Select(scenario => scenario.Name).Distinct().Count());
foreach (var scenario in EveryScenario)
{
@ -522,6 +523,7 @@ public sealed partial class RunStartTests
[Fact]
public void EveryUnsoldDesignIsAScenarioTestDesign()
{
AddPirates();
var runs = EveryScenario.Select(scenario => Launch(scenario, Inputs(() => GalaxySeed)).staged).ToList();
Assert.Empty(UnsoldAndUnplaced(runs));

View file

@ -0,0 +1,26 @@
using System.Linq;
using Xunit;
// A weapon is thermally online only while its performance exceeds the shutdown threshold (Entity.UpdatePerformance
// compares against EntitySettings.ShutdownPerformance), and wear runs at full rate below it. Hull cells
// start at Entity.StartingCellTemperature, so every shipped weapon must perform there, or a ship fitted with it
// can never fire it. The catalog comes from the shipped GameData/Aetheria.cc and the temperature from the
// constant Entity builds its cells with, so a unit slip in authoring (celsius for kelvin) fails here.
public sealed class ShippedWeaponOperabilityTests
{
[Fact]
public void EveryShippedWeaponPerformsAtAHullsStartingTemperature()
{
using var cache = RestoredHullsTests.OpenCatalog();
var weapons = cache.GetAll<GearData>().OfType<WeaponItemData>().ToArray();
Assert.NotEmpty(weapons);
var threshold = new EntitySettings().ShutdownPerformance;
Assert.True(threshold > 0f);
var dead = weapons.Where(weapon => weapon.Performance(Entity.StartingCellTemperature) <= threshold)
.Select(weapon => $"{weapon.Name} ({weapon.MinimumTemperature}..{weapon.MaximumTemperature} K)").ToArray();
Assert.Empty(dead);
}
}

View file

@ -0,0 +1,210 @@
/* 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 CultMath;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
// Controls 2 (rulings fire-on-solutions, controls-solution-subject, controls-solution-trigger): FireControl.Solution is
// each gun's one solution against the designated target, and ArcPermitsFire is the one trigger gate over it. The
// fixture is FireControlCut124Tests': a forward mount at the origin facing +z, the designated target 100 ahead.
public sealed partial class FireControlCut124Tests
{
private const float SolutionArc = 120f;
private static GunSolution SolutionOf(Engagement e) => FireControl.Solution(e.Weapon, e.Shooter, e.Shooter.Target.Value);
// A designated target in the arc is fired on whatever the aim says; the round goes to its intercept, not the aim.
// Kills an aim-only gate (the astern aim holds) and a direction that ignores the subject.
[Fact]
public void ASolutionFiresWithoutTheAim()
{
var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc);
Aim(e, float2(0, -1));
var solution = SolutionOf(e);
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
Assert.True(solution.Bears);
Assert.False(solution.Free);
Assert.Same(e.Target, solution.Subject);
Near(FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target), solution.Direction);
Near(float2(0, 1), solution.Direction);
}
[Fact]
public void FreeFireNeedsTheAimInArc()
{
var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc);
e.Shooter.SetTarget(TargetRef.None);
Aim(e, float2(0, 1));
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
var free = SolutionOf(e);
Assert.True(free.Free);
Assert.False(free.Bears);
Near(float2(0, 1), free.Direction);
Aim(e, float2(0, -1));
Assert.False(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
Assert.False(SolutionOf(e).Free);
}
// A designated target 90 degrees off a 120 degree forward mount gives no solution. With the aim astern the gun
// holds; with the aim ahead it fires free along the aim, and the price of that shot is zero.
[Fact]
public void ATargetOutOfArcGivesNoSolution()
{
var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc, weaponRange: 200f);
e.Target.Position = e.Shooter.Position + float3(100, 0, 0);
Aim(e, float2(0, -1));
Assert.False(SolutionOf(e).Bears);
Assert.False(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
Aim(e, float2(0, 1));
var solution = SolutionOf(e);
Assert.False(solution.Bears);
Assert.True(solution.Free);
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
Near(float2(0, 1), solution.Direction);
Assert.True(ReferenceEquals(e.Target, solution.Subject), "a designated target out of arc is still the solution's subject");
Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target));
}
[Fact]
public void AFusedWeaponIgnoresTheGate()
{
var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: NoLockRange, hardpointArc: SolutionArc);
e.Shooter.SetTarget(TargetRef.None);
Aim(e, float2(0, -1));
var solution = SolutionOf(e);
Assert.False(solution.Bears);
Assert.False(solution.Free);
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
}
// HitProbability above zero implies Bears and no Bears means no price, over bearings, ranges and visibility around
// a weapon of range 100: the price's gate and the solution's are the one Bears. The grid is checked to price
// something, so a gate that closed everything could not pass it.
[Fact]
public void BearsIsThePricesGate()
{
var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc, weaponRange: 100f);
var priced = 0;
foreach (var degrees in new[] { 0f, 30f, 59f, 61f, 90f, 135f, 180f })
foreach (var range in new[] { 20f, 99f, 101f, 150f })
foreach (var visible in new[] { true, false })
{
var at = radians(degrees);
e.Target.Position = e.Shooter.Position + float3(sin(at), 0, cos(at)) * range;
if (visible && !e.Shooter.VisibleEntities.Contains(e.Target)) e.Shooter.VisibleEntities.Add(e.Target);
if (!visible) e.Shooter.VisibleEntities.Remove(e.Target);
var bears = FireControl.Bears(e.Weapon, e.Shooter, e.Target, out _);
var price = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target);
Assert.Equal(bears, SolutionOf(e).Bears);
if (price > 0f) Assert.True(bears, $"priced {price} at {degrees} degrees, {range} out, visible {visible}, without bearing");
if (!bears) Assert.Equal(0f, price);
if (price > 0f) priced++;
}
Assert.True(priced > 0, "fixture: nothing on the grid was priced");
}
// Each branch of what a round does flies along Solution.Direction as it stood at fire time: a target that bears
// (aim ignored), an unfused round at a designated target out of arc (the clamped aim), a fused round with no lock.
[Fact]
public void SolveFliesAlongTheSolution()
{
var bearing = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc);
Aim(bearing, float2(0, -1));
Assert.True(SolutionOf(bearing).Bears);
AssertFlies(bearing);
var outOfArc = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc, weaponRange: 200f);
outOfArc.Target.Position = outOfArc.Shooter.Position + float3(100, 0, 0);
Aim(outOfArc, float2(1, 4));
Assert.False(SolutionOf(outOfArc).Bears);
AssertFlies(outOfArc);
var fused = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: NoLockRange, hardpointArc: SolutionArc);
fused.Shooter.SetTarget(TargetRef.None);
Aim(fused, float2(0, -1));
AssertFlies(fused);
}
private static void AssertFlies(Engagement e)
{
var direction = SolutionOf(e).Direction;
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
Near(direction, SafeAssert.OnlyShot(e.Zone).TravelDirection);
}
[Fact]
public void AFullArcTracksTheAimAllRound()
{
var settings = TestSettings();
settings.FiringArc = 360;
var e = Build(settings, SolidShape(5, 4));
e.Shooter.SetTarget(TargetRef.None);
for (var degrees = 0; degrees < 360; degrees += 30)
{
var aim = float2(sin(radians(degrees)), cos(radians(degrees)));
Aim(e, aim);
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
Near(aim, SolutionOf(e).Direction);
}
}
// An agent's aim is its intercept (Combat sets it), so an in-arc target is a solution and the free aim both: its
// fire is the same with or without the aim.
[Fact]
public void TheAgentStillFiresAtItsIntercept()
{
var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc);
var intercept = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
Aim(e, intercept);
var solution = SolutionOf(e);
Assert.True(solution.Bears && solution.Free);
Assert.True(FireControl.ArcPermitsFire(e.Weapon, e.Shooter));
Assert.True(FireControl.AgentFires(e.Weapon, e.Shooter, e.Target));
Near(intercept, solution.Direction);
}
// Solve flies along the solution against the target it is given, not the shooter's designated one (the HUD forecast
// and an agent's burst ask about other entities). A decoy 40 degrees off, in the same zone, is passed while the
// designated target sits dead ahead; the round goes to the decoy's intercept. Kills a Solve that reads the shooter's
// target, a constant, or a swapped argument; the fixture checks the two intercepts differ.
[Fact]
public void SolveFliesToTheTargetItIsGiven()
{
var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc, weaponRange: 200f);
var hull = e.Items.ItemData.RefOf<ItemData>(e.HullData);
var decoy = new Ship(e.Items, e.Zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = 400 }, new EntitySettings());
e.Zone.Entities.Add(decoy);
decoy.Activate();
var at = radians(40f);
decoy.Position = e.Shooter.Position + float3(sin(at), 0, cos(at)) * 100f;
e.Shooter.VisibleEntities.Add(decoy);
e.Shooter.EntityInfoGathered[decoy] = 1f;
e.Shooter.SetIff(decoy, true);
Aim(e, float2(0, -1));
var designated = FireControl.Solution(e.Weapon, e.Shooter, e.Target).Direction;
var other = FireControl.Solution(e.Weapon, e.Shooter, decoy).Direction;
Assert.True(FireControl.Bears(e.Weapon, e.Shooter, decoy, out _));
Assert.True(length(designated - other) > .3f, "fixture: the two intercepts must differ");
Assert.Same(e.Target, e.Shooter.Target.Value.Entity);
Near(other, FireControl.Solve(e.Weapon, e.WeaponItem, e.Shooter, decoy).TravelDirection);
Near(designated, FireControl.Solve(e.Weapon, e.WeaponItem, e.Shooter, e.Target).TravelDirection);
}
}

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 "mine-launcher": return MineLauncherCatalog(args.Contains("apply"));
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], mine-launcher [apply]");
return 1;
}
}
@ -1457,4 +1458,115 @@ public static class Program
Console.WriteLine("\nAuthored \"Asteroid\" in Aetheria.cc");
return 0;
}
// sim-mines-presenter: the Mine Launcher item, so a ship can be fitted with the sim's mine layer. Its numbers
// are the 2021 record's (GameData/Legacy/AetherDB.2021-03-05.msgpack, recovered in 8c71a637: mass 75, price
// 125000, damage 50, range 2000, energy 15, heat 500, visibility 10, magazine 6, reload 5, spread 5, velocity
// 50, cooldown 2, thermal limits 173.15..473.15 K, kelvin like every shipped item); BlastRadius 25, the delays
// 2 and 2 and the 30 s lifetime are the retired prefab's. The optimum and plateau are the 2021 record's
// sibling weapon's (it carries 173.15..473.15 K too), because that record sets neither. An already authored
// Mine Launcher has its thermal envelope brought to these values. Dry run unless passed "apply".
private static int MineLauncherCatalog(bool apply)
{
const string name = "Mine Launcher";
var db = AetherDb.Open(catalogWritable: apply);
const float minimumTemperature = 173.15f, maximumTemperature = 473.15f, optimalTemperature = 249.65f, plateauWidth = 36f;
if (db.Cache.GetAll<GearData>().SingleOrDefault(gear => gear.Name == name) is { } authored)
{
if (authored is not WeaponItemData existing) throw new InvalidOperationException($"\"{name}\" exists and is not a weapon.");
if (existing.MinimumTemperature == minimumTemperature && existing.MaximumTemperature == maximumTemperature &&
existing.OptimalTemperature == optimalTemperature && existing.PlateauWidth == plateauWidth)
{
Console.WriteLine($"\"{name}\" is already authored; nothing to do.");
return 0;
}
Console.WriteLine($"\"{name}\" thermal envelope {existing.MinimumTemperature}..{existing.MaximumTemperature} (optimum {existing.OptimalTemperature}, plateau {existing.PlateauWidth}) -> {minimumTemperature}..{maximumTemperature} K (optimum {optimalTemperature}, plateau {plateauWidth}).");
if (!apply)
{
Console.WriteLine("\nDry run. Pass \"apply\" to correct it.");
return 0;
}
existing.MinimumTemperature = minimumTemperature;
existing.MaximumTemperature = maximumTemperature;
existing.OptimalTemperature = optimalTemperature;
existing.PlateauWidth = plateauWidth;
CultRecordRefs.Validate(existing);
db.Cache.Commit(batch => batch.Upsert(typeof(GearData), existing, db.Cache.RefOf(existing).Key));
Console.WriteLine($"\nCorrected \"{name}\" in Aetheria.cc");
return 0;
}
PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
var launcher = new WeaponItemData
{
Name = name,
Hardpoint = HardpointType.Launcher,
Shape = new Shape(),
WeaponType = WeaponType.Mine,
WeaponRange = WeaponRange.Medium,
WeaponCaliber = WeaponCaliber.Medium,
Fuse = WeaponFuse.Proximity,
BlastRadius = 25f,
Mass = 75f,
Price = 125000,
Durability = 100f,
MinimumTemperature = minimumTemperature,
MaximumTemperature = maximumTemperature,
OptimalTemperature = optimalTemperature,
PlateauWidth = plateauWidth,
Behaviors =
{
new MineLayerData
{
DamageType = DamageType.Kinetic,
DamageCurve = new BezierCurve { Keys = new[] { new float4(0f, 1f, 0f, 0f), new float4(1f, 1f, 0f, 0f) } },
Damage = Constant(50f),
Range = Constant(2000f),
MinRange = Constant(0f),
Energy = Constant(15f),
Heat = Constant(500f),
Visibility = Constant(10f),
MagazineSize = 6,
ReloadTime = 5f,
Spread = Constant(5f),
Velocity = Constant(50f),
Count = Constant(1f),
Cooldown = Constant(2f),
ArmingDelay = 2f,
FuseDelay = 2f,
Lifetime = 30f
}
}
};
Console.WriteLine($"Adds \"{name}\": Launcher, Mine, Proximity fuse, blast radius 25, one MineLayerData (arming 2, fuse 2, lifetime 30).");
if (!apply)
{
Console.WriteLine("\nDry run. Pass \"apply\" to author it.");
return 0;
}
// A design no product sells cannot spawn, and the shipped catalog holds none: sell it as the first
// shipped launcher is sold, by the same maker.
var launcherProducts = db.Cache.GetAll<FactionProductData>()
.Where(p => db.Cache.Get(p.Design) is WeaponItemData weapon && weapon.Hardpoint == HardpointType.Launcher)
.OrderBy(p => db.Cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();
if (launcherProducts.Length == 0) throw new InvalidOperationException("No shipped launcher product names a maker for the Mine Launcher.");
var maker = launcherProducts[0].Manufacturer;
Console.WriteLine($"Sold by {db.Cache.Get(maker)?.ShortName ?? "(none)"}, as the first shipped launcher product is.");
CultRecordRefs.Validate(launcher);
db.Cache.Commit(batch =>
{
var design = batch.Upsert(typeof(GearData), launcher);
batch.Upsert(typeof(FactionProductData), new FactionProductData
{
Name = name,
Description = "Lays a drifting proximity mine that arms after two seconds.",
Design = new CultRecordRef<CraftedItemData>(design),
Manufacturer = maker
});
});
Console.WriteLine($"\nAuthored \"{name}\" in Aetheria.cc");
return 0;
}
}