Merge origin/master (8a98aef5 sim-mines presenter) into eureka/aetheria-release-controls-mount-aim
This commit is contained in:
commit
594d347704
37 changed files with 2255 additions and 54 deletions
151
Assets/Content/Prefabs/Mine.prefab
Normal file
151
Assets/Content/Prefabs/Mine.prefab
Normal 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
|
||||
7
Assets/Content/Prefabs/Mine.prefab.meta
Normal file
7
Assets/Content/Prefabs/Mine.prefab.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b04728b2b65e057429eb6e8529299c8a
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" +
|
||||
|
|
|
|||
|
|
@ -187,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))
|
||||
|
|
|
|||
74
Assets/Scripts/Gameplay/MineInstance.cs
Normal file
74
Assets/Scripts/Gameplay/MineInstance.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Gameplay/MineInstance.cs.meta
Normal file
2
Assets/Scripts/Gameplay/MineInstance.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 35c62f5e26769f645b1a1614a70422e1
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -143,6 +143,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) =>
|
||||
|
|
@ -169,20 +181,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);
|
||||
|
||||
|
|
@ -223,17 +238,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]]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
42
Assets/Scripts/ServerShared/RunGoal.cs
Normal file
42
Assets/Scripts/ServerShared/RunGoal.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/ServerShared/RunGoal.cs.meta
Normal file
2
Assets/Scripts/ServerShared/RunGoal.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: de03c3545cb04ce38bf85b713dc5df74
|
||||
41
Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs
Normal file
41
Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Scenarios/DemoTerminus.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5ba7152d3c8b46a98924a4148be550db
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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 =
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
241
tests/Aetheria.Shared.Tests/DemoTerminusTests.cs
Normal file
241
tests/Aetheria.Shared.Tests/DemoTerminusTests.cs
Normal 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,-,-";
|
||||
}
|
||||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -2595,7 +2595,8 @@ public sealed partial 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);
|
||||
|
|
|
|||
98
tests/Aetheria.Shared.Tests/ItemTitleTests.cs
Normal file
98
tests/Aetheria.Shared.Tests/ItemTitleTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
30
tests/Aetheria.Shared.Tests/MineLauncherCatalogTests.cs
Normal file
30
tests/Aetheria.Shared.Tests/MineLauncherCatalogTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
278
tests/Aetheria.Shared.Tests/RunGoalTests.cs
Normal file
278
tests/Aetheria.Shared.Tests/RunGoalTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
26
tests/Aetheria.Shared.Tests/ShippedWeaponOperabilityTests.cs
Normal file
26
tests/Aetheria.Shared.Tests/ShippedWeaponOperabilityTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1505,4 +1506,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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue