Merge origin/master into consumable-supply
This commit is contained in:
commit
cf6503bf4e
49 changed files with 3229 additions and 99 deletions
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -54,6 +54,8 @@
|
|||
*.sbsar filter=lfs diff=lfs merge=lfs -text
|
||||
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
||||
GameData/*.cc filter=lfs diff=lfs merge=lfs -text
|
||||
GameData/Mods/**/*.cc filter=lfs diff=lfs merge=lfs -text
|
||||
GameData/Mods/**/*.glb filter=lfs diff=lfs merge=lfs -text
|
||||
*.a filter=lfs diff=lfs merge=lfs -text
|
||||
*AkSoundEngine filter=lfs diff=lfs merge=lfs -text
|
||||
*AkWaapiClient filter=lfs diff=lfs merge=lfs -text
|
||||
|
|
|
|||
106
Assets/Editor/AetheriaBuild.cs
Normal file
106
Assets/Editor/AetheriaBuild.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditor;
|
||||
using UnityEditor.AddressableAssets.Settings;
|
||||
using UnityEditor.Build.Reporting;
|
||||
using Debug = UnityEngine.Debug;
|
||||
|
||||
// The one way to make a Win64 player directory. Owns what the directory contains: Aetheria.exe and its data,
|
||||
// GameData/Aetheria.cc, GameData/Narrative/**, GameData/Mods/** and ModTools/AetherDb. Manual builds, smokes and
|
||||
// release cuts all call this and stage nothing themselves.
|
||||
//
|
||||
// Unity -batchmode -quit -projectPath <repo> -executeMethod AetheriaBuild.Build
|
||||
// -aetheriaVersion <semver> -aetheriaOut <dir> -logFile <log>
|
||||
//
|
||||
// AetherDb is published from tools/AetherDb, so the headless build's CULTLIB_ROOT / CULTMATH_ROOT (Directory.Build.props)
|
||||
// must be set for dotnet when the defaults do not hold the pinned revisions.
|
||||
// Any failure logs "AetheriaBuild FAILED at <step>" and exits 1.
|
||||
public static class AetheriaBuild
|
||||
{
|
||||
private static readonly Regex SemVer = new Regex(@"^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$");
|
||||
|
||||
public static void Build()
|
||||
{
|
||||
var step = "arguments";
|
||||
try
|
||||
{
|
||||
var version = Argument("-aetheriaVersion");
|
||||
if (!SemVer.IsMatch(version)) throw new InvalidOperationException("-aetheriaVersion is not a semver");
|
||||
var output = Path.GetFullPath(Argument("-aetheriaOut"));
|
||||
var repoRoot = Directory.GetParent(UnityEngine.Application.dataPath).FullName;
|
||||
|
||||
step = "version";
|
||||
UnityEditor.PlayerSettings.bundleVersion = version; // the game has its own global PlayerSettings
|
||||
|
||||
step = "addressables";
|
||||
AddressableAssetSettings.BuildPlayerContent(out var content);
|
||||
if (!string.IsNullOrEmpty(content.Error)) throw new InvalidOperationException("Addressables build reported an error: " + content.Error);
|
||||
|
||||
step = "player";
|
||||
Directory.CreateDirectory(output);
|
||||
var scenes = EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(scene => scene.path).ToArray();
|
||||
if (scenes.Length == 0) throw new InvalidOperationException("EditorBuildSettings has no enabled scene");
|
||||
var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions
|
||||
{
|
||||
scenes = scenes,
|
||||
locationPathName = Path.Combine(output, "Aetheria.exe"),
|
||||
target = BuildTarget.StandaloneWindows64,
|
||||
options = BuildOptions.None,
|
||||
});
|
||||
if (report.summary.result != BuildResult.Succeeded || report.summary.totalErrors != 0)
|
||||
throw new InvalidOperationException($"BuildPlayer ended {report.summary.result} with {report.summary.totalErrors} errors");
|
||||
|
||||
step = "gamedata";
|
||||
PlayerStaging.Stage(repoRoot, output);
|
||||
|
||||
step = "aetherdb";
|
||||
Publish(repoRoot, Path.Combine(output, "ModTools"));
|
||||
|
||||
step = "burst";
|
||||
var burst = Path.Combine(output, "Aetheria_BurstDebugInformation_DoNotShip");
|
||||
if (Directory.Exists(burst)) Directory.Delete(burst, true);
|
||||
|
||||
Debug.Log($"AetheriaBuild OK {version} {output}");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Debug.LogError($"AetheriaBuild FAILED at {step}: {error.GetType().Name}: {error.Message}");
|
||||
EditorApplication.Exit(1);
|
||||
return;
|
||||
}
|
||||
EditorApplication.Exit(0);
|
||||
}
|
||||
|
||||
private static void Publish(string repoRoot, string modTools)
|
||||
{
|
||||
var info = new ProcessStartInfo("dotnet")
|
||||
{
|
||||
Arguments = $"publish tools/AetherDb -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o \"{modTools}\"",
|
||||
WorkingDirectory = repoRoot,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
using (var process = Process.Start(info))
|
||||
{
|
||||
var errors = process.StandardError.ReadToEndAsync();
|
||||
var text = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0)
|
||||
throw new InvalidOperationException($"dotnet publish exited {process.ExitCode}:\n{text}\n{errors.Result}");
|
||||
}
|
||||
if (!File.Exists(Path.Combine(modTools, "AetherDb.exe")))
|
||||
throw new InvalidOperationException("dotnet publish produced no ModTools/AetherDb.exe");
|
||||
}
|
||||
|
||||
private static string Argument(string name)
|
||||
{
|
||||
var args = Environment.GetCommandLineArgs();
|
||||
var at = Array.IndexOf(args, name);
|
||||
if (at < 0 || at + 1 >= args.Length) throw new InvalidOperationException(name + " is required");
|
||||
return args[at + 1];
|
||||
}
|
||||
}
|
||||
2
Assets/Editor/AetheriaBuild.cs.meta
Normal file
2
Assets/Editor/AetheriaBuild.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d38caad223051494ba36c734ce79950d
|
||||
42
Assets/Editor/PlayerStaging.cs
Normal file
42
Assets/Editor/PlayerStaging.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
|
||||
// What a player directory holds beside the executable's data: GameData/Aetheria.cc, GameData/Narrative/** and, when
|
||||
// the repo has one, GameData/Mods/**. Nothing else under GameData ships (run.cc and player.cc are the player's own;
|
||||
// SoundbanksInfo.json is read by no code). Unity-free so the headless suite can prove the rules; AetheriaBuild is the
|
||||
// only caller.
|
||||
internal static class PlayerStaging
|
||||
{
|
||||
public const string Catalog = "Aetheria.cc";
|
||||
public const string Narrative = "Narrative";
|
||||
public const string Mods = "Mods";
|
||||
|
||||
// Replaces <playerDir>/GameData with a copy of the three shipped entries of <repoRoot>/GameData.
|
||||
// Throws InvalidOperationException naming the entry when the catalog or Narrative is absent.
|
||||
public static void Stage(string repoRoot, string playerDir)
|
||||
{
|
||||
var source = Path.Combine(repoRoot, "GameData");
|
||||
var destination = Path.Combine(playerDir, "GameData");
|
||||
var catalog = Path.Combine(source, Catalog);
|
||||
var narrative = Path.Combine(source, Narrative);
|
||||
var mods = Path.Combine(source, Mods);
|
||||
|
||||
if (!File.Exists(catalog)) throw new InvalidOperationException($"GameData/{Catalog} is missing; a player never ships without its catalog.");
|
||||
if (!Directory.Exists(narrative)) throw new InvalidOperationException($"GameData/{Narrative} is missing.");
|
||||
|
||||
if (Directory.Exists(destination)) Directory.Delete(destination, true);
|
||||
Directory.CreateDirectory(destination);
|
||||
File.Copy(catalog, Path.Combine(destination, Catalog));
|
||||
CopyTree(narrative, Path.Combine(destination, Narrative));
|
||||
if (Directory.Exists(mods)) CopyTree(mods, Path.Combine(destination, Mods));
|
||||
}
|
||||
|
||||
private static void CopyTree(string from, string to)
|
||||
{
|
||||
Directory.CreateDirectory(to);
|
||||
foreach (var directory in Directory.GetDirectories(from, "*", SearchOption.AllDirectories))
|
||||
Directory.CreateDirectory(Path.Combine(to, directory.Substring(from.Length + 1)));
|
||||
foreach (var file in Directory.GetFiles(from, "*", SearchOption.AllDirectories))
|
||||
File.Copy(file, Path.Combine(to, file.Substring(from.Length + 1)));
|
||||
}
|
||||
}
|
||||
2
Assets/Editor/PlayerStaging.cs.meta
Normal file
2
Assets/Editor/PlayerStaging.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 16dce8087f9149b44b6e294cb0851dd8
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
|
|
@ -13,9 +14,15 @@ public static class AetheriaStores
|
|||
// Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given.
|
||||
// Throws when a [CultGlobal] type routed to the catalog has no record. The one exemption is a writable catalog with
|
||||
// no records at all, a seed store being authored from nothing; a populated catalog is held to it however it opens.
|
||||
public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false)
|
||||
// The registry is the process-wide one unless the caller scopes it (the test host does, so its own document types never
|
||||
// become catalog globals); the global check above is judged against whichever registry opened the cache.
|
||||
public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false,
|
||||
CultDocumentRegistry registry = null)
|
||||
{
|
||||
var cache = new CultCache();
|
||||
// A read-only open of an absent catalog would hydrate an empty game; a shipped player missing its catalog stops here.
|
||||
if (!catalogWritable && !File.Exists(catalogPath))
|
||||
throw new FileNotFoundException($"Catalog {catalogPath} does not exist; a read-only open never invents one.", catalogPath);
|
||||
var cache = new CultCache(registry);
|
||||
try
|
||||
{
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, !catalogWritable), CatalogTypes);
|
||||
|
|
|
|||
|
|
@ -187,6 +187,8 @@ public interface IPowerConsumer
|
|||
// superseded draft quoted -- checked directly here rather than trusted from that document.
|
||||
Union(39, typeof(TargetingSystemData)),
|
||||
Union(40, typeof(MineLayerData)),
|
||||
Union(41, typeof(ThrottleLockData)),
|
||||
Union(42, typeof(VapourDumpData)),
|
||||
JsonObject(MemberSerialization.OptIn)]
|
||||
public abstract class BehaviorData
|
||||
{
|
||||
|
|
|
|||
|
|
@ -160,17 +160,18 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
|
|||
|
||||
var diff = entity.Position.xz - Entity.Position.xz;
|
||||
var dist = length(diff);
|
||||
var obscuration = Entity.Zone.Obscuration(Entity.Position.xz, entity.Position.xz);
|
||||
float previous, next;
|
||||
Entity.EntityInfoGathered.TryGetValue(entity, out previous);
|
||||
if (!_pingedEntities.Contains(entity) && dist < _pingRadius)
|
||||
{
|
||||
_pingedEntities.Add(entity);
|
||||
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true));
|
||||
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true, obscuration));
|
||||
}
|
||||
else
|
||||
{
|
||||
var angle = acos(dot(forward, normalize(diff)));
|
||||
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false));
|
||||
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration));
|
||||
}
|
||||
next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt;
|
||||
//Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%");
|
||||
|
|
@ -182,12 +183,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
|
|||
// Mining Cut 3 (docs/mining-cut-refresh.md): the one detection gain rule -- the info one observation of a
|
||||
// target adds. A ping adds `visibility × sensitivity × PingBoost × distance` once; a passive tick adds
|
||||
// `visibility × sensitivity × curve(angle/π) × dt / distance`. `response` is PingBoost for a ping and the
|
||||
// sensitivity curve at the bearing for a passive tick. The entity loop above and the chunk query
|
||||
// (Entity.ChunkInfo, through PassiveRate) both call this; nothing else multiplies these terms.
|
||||
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping) =>
|
||||
ping
|
||||
// sensitivity curve at the bearing for a passive tick. `obscuration` is the fraction of the signal that
|
||||
// survives the vapour clouds on the sight line (Zone.Obscuration, 1f with none), scaling both. The entity
|
||||
// loop above and the chunk query (Entity.ChunkInfo, through PassiveRate) both call this; nothing else
|
||||
// multiplies these terms.
|
||||
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping, float obscuration) =>
|
||||
(ping
|
||||
? visibility * sensitivity * response * distance
|
||||
: visibility * sensitivity * response * dt / distance;
|
||||
: visibility * sensitivity * response * dt / distance) * obscuration;
|
||||
|
||||
// The passive gain per second this sensor draws from a target of `visibility` at the planar `position`:
|
||||
// Gain over one second, with the bearing measured from this sensor's own facing exactly as Execute does.
|
||||
|
|
@ -195,7 +198,8 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
|
|||
{
|
||||
var diff = position - Entity.Position.xz;
|
||||
var angle = acos(dot(Direction.xz, normalize(diff)));
|
||||
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false);
|
||||
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false,
|
||||
Entity.Zone.Obscuration(Entity.Position.xz, position));
|
||||
}
|
||||
|
||||
// An upper bound on sensitivity x response over every bearing, so PassiveRate(v, p) <= v x this / distance
|
||||
|
|
|
|||
35
Assets/Scripts/ServerShared/Behaviors/ThrottleLock.cs
Normal file
35
Assets/Scripts/ServerShared/Behaviors/ThrottleLock.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/* 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 MessagePack;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
// Presence marks the lock: Entity.ThrottleLocked is true while an active effect carries one, and Ship.Update
|
||||
// then reads full forward intent in place of MovementDirection. The behaviour holds no state and does nothing
|
||||
// itself; turning stays with the pilot.
|
||||
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
|
||||
public class ThrottleLockData : BehaviorData
|
||||
{
|
||||
public override Behavior CreateInstance(EquippedItem item)
|
||||
{
|
||||
return new ThrottleLock(this, item);
|
||||
}
|
||||
public override Behavior CreateInstance(ConsumableItemEffect item)
|
||||
{
|
||||
return new ThrottleLock(this, item);
|
||||
}
|
||||
}
|
||||
|
||||
public class ThrottleLock : Behavior
|
||||
{
|
||||
public ThrottleLock(ThrottleLockData data, EquippedItem item) : base(data, item)
|
||||
{
|
||||
}
|
||||
|
||||
public ThrottleLock(ThrottleLockData data, ConsumableItemEffect item) : base(data, item)
|
||||
{
|
||||
}
|
||||
|
||||
public override bool Execute(float dt) => true;
|
||||
}
|
||||
61
Assets/Scripts/ServerShared/Behaviors/VapourDump.cs
Normal file
61
Assets/Scripts/ServerShared/Behaviors/VapourDump.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using MessagePack;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
// A coolant dump that becomes a vapour cloud: the first time it runs it vents one cloud around its venter,
|
||||
// drifting with the venter's velocity. Radius, Opacity and Lifetime are evaluated through the host, so role
|
||||
// quality applies as for any other stat. Later runs vent nothing.
|
||||
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
|
||||
public class VapourDumpData : BehaviorData
|
||||
{
|
||||
[Inspectable, JsonProperty("radius"), Key(1), RuntimeInspectable]
|
||||
public PerformanceStat Radius = new PerformanceStat();
|
||||
|
||||
// How much of a sight line's signal a fresh cloud removes, in [0, 1].
|
||||
[Inspectable, JsonProperty("opacity"), Key(2), RuntimeInspectable]
|
||||
public PerformanceStat Opacity = new PerformanceStat();
|
||||
|
||||
// Seconds from venting until the cloud has faded away and is removed.
|
||||
[Inspectable, JsonProperty("lifetime"), Key(3), RuntimeInspectable]
|
||||
public PerformanceStat Lifetime = new PerformanceStat();
|
||||
|
||||
public override Behavior CreateInstance(EquippedItem item)
|
||||
{
|
||||
return new VapourDump(this, item);
|
||||
}
|
||||
|
||||
public override Behavior CreateInstance(ConsumableItemEffect item)
|
||||
{
|
||||
return new VapourDump(this, item);
|
||||
}
|
||||
}
|
||||
|
||||
public class VapourDump : Behavior
|
||||
{
|
||||
private VapourDumpData _data;
|
||||
private bool _vented;
|
||||
|
||||
public VapourDump(VapourDumpData data, EquippedItem item) : base(data, item)
|
||||
{
|
||||
_data = data;
|
||||
}
|
||||
|
||||
public VapourDump(VapourDumpData data, ConsumableItemEffect item) : base(data, item)
|
||||
{
|
||||
_data = data;
|
||||
}
|
||||
|
||||
public override bool Execute(float dt)
|
||||
{
|
||||
if (_vented) return true;
|
||||
_vented = true;
|
||||
Entity.Zone.Vent(new VapourCloud
|
||||
{
|
||||
Body = new KinematicBody { Position = Entity.Position.xz, Velocity = Entity.Velocity },
|
||||
Radius = Evaluate(_data.Radius),
|
||||
Opacity = Evaluate(_data.Opacity),
|
||||
Lifetime = Evaluate(_data.Lifetime),
|
||||
Venter = Entity
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Behaviors/VapourDump.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Behaviors/VapourDump.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 961a74683c574807a300eb26694f206d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -872,6 +872,9 @@ public abstract class Entity
|
|||
initializable.Initialize();
|
||||
}
|
||||
|
||||
// Derived, never written: true iff an active consumable effect carries a ThrottleLock.
|
||||
public bool ThrottleLocked => _activeConsumables.Any(e => e.Behaviors.OfType<ThrottleLock>().Any());
|
||||
|
||||
public ConsumableItemEffect FindActiveConsumable(ConsumableItemData data)
|
||||
{
|
||||
return _activeConsumables.FirstOrDefault(ac => ac.Data == data);
|
||||
|
|
|
|||
|
|
@ -130,8 +130,8 @@ public static class FireControl
|
|||
// arc, so the gun can fire along it. Direction: where a round from this gun travels and where its barrel points --
|
||||
// the subject's intercept when it bears, else the arc-clamped aim. A chunk subject bears through its pose and
|
||||
// flies the aim, as an untargeted round (Subject is null for it). Derived on every read and never stored: turrets
|
||||
// traverse instantly in the simulation. Traverse speed belongs to the link (ruling arc-and-traverse-on-the-link),
|
||||
// which will make this stateful behind the same signature. It never prices; HitProbability is the one price.
|
||||
// traverse instantly in the simulation. Traverse is the weapon's Tracking (ruling mount-arc-gun-traverse), priced in
|
||||
// TrackingRate; this holds no slew state, and a rig's slew is presentation. It never prices; HitProbability is the one price.
|
||||
public static GunSolution Solution(Weapon weapon, Entity shooter, TargetRef subject)
|
||||
{
|
||||
var bears = false;
|
||||
|
|
|
|||
|
|
@ -233,27 +233,29 @@ public class Ship : Entity
|
|||
if (_active && !_exitingWormhole && !_enteringWormhole)
|
||||
{
|
||||
RecalculateThrust();
|
||||
// The lock replaces intent here, once; player and agent intent both pass through this one read.
|
||||
var move = ThrottleLocked ? float2(0, 1) : MovementDirection;
|
||||
foreach (var thruster in _allThrusters) thruster.Axis = 0;
|
||||
var rightThrusterTorqueCompensation = abs(RightStrafeTotalTorque) / RightStrafeTorqueThrusters.Count;
|
||||
foreach (var thruster in _rightThrusters)
|
||||
{
|
||||
var thrust = 0f;
|
||||
thrust += MovementDirection.x;
|
||||
thrust += move.x;
|
||||
if (RightStrafeTorqueThrusters.Contains(thruster))
|
||||
thrust -= MovementDirection.x * (rightThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
|
||||
thrust -= move.x * (rightThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
|
||||
thruster.Axis = thrust;
|
||||
}
|
||||
var leftThrusterTorqueCompensation = abs(LeftStrafeTotalTorque) / LeftStrafeTorqueThrusters.Count;
|
||||
foreach (var thruster in _leftThrusters)
|
||||
{
|
||||
var thrust = 0f;
|
||||
thrust += -MovementDirection.x;
|
||||
thrust += -move.x;
|
||||
if (LeftStrafeTorqueThrusters.Contains(thruster))
|
||||
thrust += MovementDirection.x * (leftThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
|
||||
thrust += move.x * (leftThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
|
||||
thruster.Axis = thrust;
|
||||
}
|
||||
foreach (var thruster in _forwardThrusters) thruster.Axis += MovementDirection.y;
|
||||
foreach (var thruster in _reverseThrusters) thruster.Axis += -MovementDirection.y;
|
||||
foreach (var thruster in _forwardThrusters) thruster.Axis += move.y;
|
||||
foreach (var thruster in _reverseThrusters) thruster.Axis += -move.y;
|
||||
|
||||
foreach (var thruster in _clockwiseThrusters) thruster.Axis += Turn;
|
||||
foreach (var thruster in _counterClockwiseThrusters) thruster.Axis += -Turn;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,22 @@ public sealed class ShipAuthoring
|
|||
[Key(2)] public string ModelAsset;
|
||||
[Key(3)] public List<ShipAnchor> Anchors = new List<ShipAnchor>();
|
||||
[Key(4)] public List<ShipPolyline> SchematicLines = new List<ShipPolyline>();
|
||||
// The moving parts of the rig, presentation only (ruling rig-is-presentation): no joint value feeds the simulation.
|
||||
[Key(5)] public List<ShipJoint> Joints = new List<ShipJoint>();
|
||||
}
|
||||
|
||||
// A rig joint: a bone of the model's armature. Axes are three floats per degree of freedom, a unit vector in Ship Root
|
||||
// space at rest (glTF axes, +Y up); Min and Max are that degree of freedom's limits in degrees.
|
||||
[MessagePackObject]
|
||||
public sealed class ShipJoint
|
||||
{
|
||||
// The bone's aetheria.id, which is also its GLB node id.
|
||||
[Key(0)] public string Id;
|
||||
// The nearest joint above this one in the model, or null for a root joint.
|
||||
[Key(1)] public string Parent;
|
||||
[Key(2)] public float[] Axes;
|
||||
[Key(3)] public float[] Min;
|
||||
[Key(4)] public float[] Max;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
|
|
@ -25,13 +41,15 @@ public sealed class ShipAnchor
|
|||
// Stable semantic identity. HullData.Hardpoints[].Transform names this ID for mounts.
|
||||
[Key(0)] public string Id;
|
||||
// One of: map-icon, hull-collider, shield, tractor, thruster-emitter, weapon-mount,
|
||||
// weapon-muzzle, radiator-mesh, or articulation (a pivot, never a mount).
|
||||
// weapon-muzzle, or radiator-mesh.
|
||||
[Key(1)] public string Role;
|
||||
// Stable node ID in the compiled model, never a Blender display name.
|
||||
[Key(2)] public string ModelNodeId;
|
||||
// Only child roles use ParentId; e.g. a muzzle names its weapon hardpoint.
|
||||
[Key(3)] public string ParentId;
|
||||
[Key(4)] public int Order;
|
||||
// On a weapon-mount anchor: the id of the nearest joint above its node in the model; null or empty is a fixed mount.
|
||||
[Key(5)] public string Joint;
|
||||
}
|
||||
|
||||
// Evaluated or baked Grease Pencil stroke coordinates in Blender's right-handed Z-up
|
||||
|
|
@ -53,8 +71,7 @@ public static class ShipAuthoringStore
|
|||
// ShipAnchor.Role's vocabulary.
|
||||
private static readonly HashSet<string> Roles = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh",
|
||||
"articulation"
|
||||
"map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh"
|
||||
};
|
||||
|
||||
// The anchor role a visible hardpoint's mount plays, or null for an internal hardpoint, which has no anchor.
|
||||
|
|
@ -272,6 +289,7 @@ public static class ShipAuthoringStore
|
|||
foreach (var hardpoint in mounts.Values.Where(hardpoint => IsWeapon(hardpoint.Type)))
|
||||
if (!anchors.Any(anchor => anchor.Role == "weapon-muzzle" && anchor.ParentId == hardpoint.Transform))
|
||||
throw new InvalidOperationException($"{ship.Id}: weapon hardpoint {hardpoint.Transform} needs at least one muzzle anchor.");
|
||||
ValidateRig(ship, anchors, mounts, nodeIds);
|
||||
|
||||
foreach (var line in ship.SchematicLines ?? new List<ShipPolyline>())
|
||||
{
|
||||
|
|
@ -291,4 +309,111 @@ public static class ShipAuthoringStore
|
|||
throw new InvalidOperationException($"{ship.Id}: schematic line color must be finite RGBA.");
|
||||
}
|
||||
}
|
||||
|
||||
// A degree of freedom counts as yaw when its axis lies within 5 degrees of Ship Root up (glTF +Y).
|
||||
private static readonly float YawAxisCos = MathF.Cos(5f * MathF.PI / 180f);
|
||||
|
||||
// The joints, which anchors ride them, and whether each driven joint's chain can sweep the arc its guns state. The
|
||||
// joints are presentation, so this judges only that the rig could point the gun where the hardpoint says it may fire.
|
||||
private static void ValidateRig(ShipAuthoring ship, List<ShipAnchor> anchors, Dictionary<string, HardpointData> mounts, HashSet<string> nodeIds)
|
||||
{
|
||||
var joints = ship.Joints ?? new List<ShipJoint>();
|
||||
var byId = new Dictionary<string, ShipJoint>(StringComparer.Ordinal);
|
||||
foreach (var joint in joints)
|
||||
{
|
||||
if (joint == null || string.IsNullOrWhiteSpace(joint.Id) || byId.ContainsKey(joint.Id))
|
||||
throw new InvalidOperationException($"{ship.Id}: joint IDs must be present and unique.");
|
||||
if (nodeIds.Contains(joint.Id))
|
||||
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} is also an anchor's model node.");
|
||||
byId.Add(joint.Id, joint);
|
||||
}
|
||||
foreach (var joint in joints)
|
||||
{
|
||||
var dofs = joint.Min?.Length ?? 0;
|
||||
if (dofs < 1 || joint.Max?.Length != dofs || joint.Axes?.Length != 3 * dofs)
|
||||
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} needs one or more degrees of freedom: Axes holds three values each, Min and Max one each.");
|
||||
for (var dof = 0; dof < dofs; dof++)
|
||||
{
|
||||
var (x, y, z) = (joint.Axes[3 * dof], joint.Axes[3 * dof + 1], joint.Axes[3 * dof + 2]);
|
||||
var (min, max) = (joint.Min[dof], joint.Max[dof]);
|
||||
if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z) || !float.IsFinite(min) || !float.IsFinite(max))
|
||||
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} axes and limits must be finite.");
|
||||
if (MathF.Abs(MathF.Sqrt(x * x + y * y + z * z) - 1f) > 1e-3f)
|
||||
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} axis {dof} must be a unit vector.");
|
||||
if (min < -180f || max > 180f || min > max)
|
||||
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} limits {dof} must satisfy -180 <= Min <= Max <= 180.");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(joint.Parent) && !byId.ContainsKey(joint.Parent))
|
||||
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} has an unknown parent joint.");
|
||||
}
|
||||
foreach (var joint in joints)
|
||||
{
|
||||
var step = joint;
|
||||
for (var depth = 0; !string.IsNullOrEmpty(step.Parent); depth++)
|
||||
{
|
||||
if (depth >= joints.Count)
|
||||
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} is on a parent cycle.");
|
||||
step = byId[step.Parent];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var anchor in anchors.Where(anchor => !string.IsNullOrEmpty(anchor.Joint)))
|
||||
{
|
||||
if (anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh")
|
||||
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} may not ride a joint (follow-up gimballed-thrusters).");
|
||||
if (anchor.Role != "weapon-mount")
|
||||
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} may not name a joint; only a weapon mount rides one.");
|
||||
if (!byId.ContainsKey(anchor.Joint))
|
||||
throw new InvalidOperationException($"{ship.Id}: weapon mount {anchor.Id} names an unknown joint.");
|
||||
}
|
||||
|
||||
var ridden = anchors.Where(anchor => anchor.Role == "weapon-mount" && !string.IsNullOrEmpty(anchor.Joint))
|
||||
.Select(anchor => (Hardpoint: mounts[anchor.Id], Joint: anchor.Joint)).ToArray();
|
||||
foreach (var (hardpoint, _) in ridden)
|
||||
if (!(hardpoint.FiringArc > 0f))
|
||||
throw new InvalidOperationException($"{ship.Id}: weapon hardpoint {hardpoint.Transform} rides a joint, so it must state its firing arc above 0.");
|
||||
foreach (var group in ridden.GroupBy(mount => mount.Joint, StringComparer.Ordinal))
|
||||
{
|
||||
var lead = group.First().Hardpoint;
|
||||
foreach (var (hardpoint, _) in group)
|
||||
if (hardpoint.Rotation != lead.Rotation || hardpoint.FiringArc != lead.FiringArc)
|
||||
throw new InvalidOperationException($"{ship.Id}: weapon hardpoints {lead.Transform} and {hardpoint.Transform} ride joint {group.Key} and must share Rotation and FiringArc.");
|
||||
var reach = YawReach(ship, group.Key);
|
||||
if (reach < lead.FiringArc)
|
||||
throw new InvalidOperationException($"{ship.Id}: joint {group.Key} chain reaches {reach} degrees of yaw, short of the {lead.FiringArc} degree firing arc of {lead.Transform}.");
|
||||
}
|
||||
}
|
||||
|
||||
// The yaw a driven joint's chain can sweep: the summed range of every degree of freedom whose axis is yaw, capped at a turn.
|
||||
private static float YawReach(ShipAuthoring ship, string jointId)
|
||||
{
|
||||
var joints = ship.Joints.ToDictionary(joint => joint.Id, StringComparer.Ordinal);
|
||||
var reach = 0f;
|
||||
foreach (var joint in RigChains.Chain(ship, jointId).Select(id => joints[id]))
|
||||
for (var dof = 0; dof < joint.Min.Length; dof++)
|
||||
if (MathF.Abs(joint.Axes[3 * dof + 1]) >= YawAxisCos)
|
||||
reach += joint.Max[dof] - joint.Min[dof];
|
||||
return MathF.Min(reach, 360f);
|
||||
}
|
||||
}
|
||||
|
||||
// A rig's driven joints and the chains that move them. A driven joint is one a weapon-mount anchor names. Its chain runs
|
||||
// from the joint up through its ancestors and stops before the nearest ancestor that is itself driven (that joint has
|
||||
// its own gun, and its pose is that gun's business), listed root-first. The judge and the plan both read this one rule.
|
||||
public static class RigChains
|
||||
{
|
||||
public static HashSet<string> Driven(ShipAuthoring ship) =>
|
||||
new HashSet<string>(ship.Anchors.Where(anchor => anchor.Role == "weapon-mount" && !string.IsNullOrEmpty(anchor.Joint)).Select(anchor => anchor.Joint),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
public static string[] Chain(ShipAuthoring ship, string jointId)
|
||||
{
|
||||
var joints = ship.Joints.ToDictionary(joint => joint.Id, StringComparer.Ordinal);
|
||||
var driven = Driven(ship);
|
||||
var chain = new List<string> { jointId };
|
||||
for (var parent = joints[jointId].Parent; !string.IsNullOrEmpty(parent) && !driven.Contains(parent); parent = joints[parent].Parent)
|
||||
chain.Add(parent);
|
||||
chain.Reverse();
|
||||
return chain.ToArray();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ public static class ShipModCatalog
|
|||
// named. A collision (with the shipped catalog, or with another package's hull name) excludes every package involved,
|
||||
// since neither has a claim to the name. The derived catalog holds the shipped catalog plus the included packages.
|
||||
// Unsafe inputs and outputs (a missing catalog, a derived file that would replace a source) still throw: nothing is composed.
|
||||
public static Composition Compose(string shippedCatalog, string outputCatalog, string modsRoot)
|
||||
public static Composition Compose(string shippedCatalog, string outputCatalog, string modsRoot, CultDocumentRegistry registry = null)
|
||||
{
|
||||
var source = Path.GetFullPath(shippedCatalog);
|
||||
var output = Path.GetFullPath(outputCatalog);
|
||||
|
|
@ -84,7 +84,7 @@ public static class ShipModCatalog
|
|||
Directory.CreateDirectory(workspace);
|
||||
File.Copy(source, temporary);
|
||||
Composition composition;
|
||||
using (var cache = AetheriaStores.Open(temporary, catalogWritable: true))
|
||||
using (var cache = AetheriaStores.Open(temporary, catalogWritable: true, registry: registry))
|
||||
{
|
||||
// Collisions are judged against the shipped records alone, before any package is written, so the
|
||||
// verdict does not depend on directory order. CultCache indexes names per concrete type and lets the
|
||||
|
|
@ -153,14 +153,15 @@ public static class ShipModCatalog
|
|||
// The one game-side choice of catalog: the shipped file when no package is installed or none survives composition,
|
||||
// otherwise the derived file, composed afresh from the shipped one and the surviving packages. Recomposed every
|
||||
// call, so the derived file is never stale and never an authority. Excluded packages come back named, for the menu.
|
||||
public static (string Catalog, Exclusion[] Excluded) ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot)
|
||||
public static (string Catalog, Exclusion[] Excluded) ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot,
|
||||
CultDocumentRegistry registry = null)
|
||||
{
|
||||
if (!Directory.Exists(modsRoot) ||
|
||||
!Directory.GetDirectories(modsRoot).Any(directory => File.Exists(Path.Combine(directory, "ship.cc"))))
|
||||
return (shippedCatalog, Array.Empty<Exclusion>());
|
||||
Composition composition;
|
||||
// A failure of the composition itself (an unwritable derived path, say) is still a mod problem, never a reason not to boot.
|
||||
try { composition = Compose(shippedCatalog, derivedCatalog, modsRoot); }
|
||||
try { composition = Compose(shippedCatalog, derivedCatalog, modsRoot, registry); }
|
||||
catch (Exception error) { return (shippedCatalog, new[] { new Exclusion { Package = "(all mods)", Reason = error.Message } }); }
|
||||
return (composition.Included.Length == 0 ? shippedCatalog : derivedCatalog, composition.Excluded);
|
||||
}
|
||||
|
|
@ -171,7 +172,7 @@ public static class ShipModCatalog
|
|||
throw new InvalidOperationException($"{path}: ship ID must match its package directory name.");
|
||||
var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset));
|
||||
// Containment is ShipAuthoringStore.Validate's job: it refuses rooted and `..` model paths before this line.
|
||||
if (!File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.OrdinalIgnoreCase))
|
||||
if (!File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"{ship.Id}: model asset must name an existing GLB inside its package.");
|
||||
var modelNodes = ReadNodeIds(modelPath);
|
||||
foreach (var anchor in ship.Anchors)
|
||||
|
|
@ -182,6 +183,7 @@ public static class ShipModCatalog
|
|||
foreach (var anchor in ship.Anchors)
|
||||
if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh") && !modelNodes[anchor.ModelNodeId].HasMesh)
|
||||
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} needs a mesh, but model node {anchor.ModelNodeId} has none.");
|
||||
BindRig(ship, modelNodes);
|
||||
CultRecordRefs.Validate(hull);
|
||||
return new Package
|
||||
{
|
||||
|
|
@ -190,9 +192,43 @@ public static class ShipModCatalog
|
|||
};
|
||||
}
|
||||
|
||||
// GLB is the xenos asset boundary. Only its node extras, and whether each node names a mesh, are read here; geometry
|
||||
// belongs to the runtime importer.
|
||||
public static Dictionary<string, (uint Index, bool HasMesh)> ReadNodeIds(string path)
|
||||
// The rig the record states must be the rig the model has: every joint is a node, each joint's Parent and each mount's
|
||||
// Joint is the nearest joint above its node, ancestors without an id being walked through by ReadNodeIds, and a thruster or radiator
|
||||
// mesh sits under no joint (follow-up gimballed-thrusters).
|
||||
private static void BindRig(ShipAuthoring ship, Dictionary<string, (uint Index, bool HasMesh, int Parent)> nodes)
|
||||
{
|
||||
var joints = new HashSet<string>((ship.Joints ?? new List<ShipJoint>()).Select(joint => joint.Id), StringComparer.Ordinal);
|
||||
var idAt = nodes.ToDictionary(node => (int)node.Value.Index, node => node.Key);
|
||||
// Parent is already the nearest ancestor that carries an id, so the walk up is the chain of those.
|
||||
string JointAbove(string nodeId)
|
||||
{
|
||||
// glTF forbids cycles; the bound keeps a malformed model from hanging the binder.
|
||||
var parent = nodes[nodeId].Parent;
|
||||
for (var steps = 0; parent >= 0 && steps <= nodes.Count; steps++, parent = nodes[idAt[parent]].Parent)
|
||||
if (joints.Contains(idAt[parent])) return idAt[parent];
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var joint in ship.Joints ?? new List<ShipJoint>())
|
||||
{
|
||||
if (!nodes.ContainsKey(joint.Id))
|
||||
throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={joint.Id} for a joint.");
|
||||
if (!string.Equals(JointAbove(joint.Id) ?? "", joint.Parent ?? "", StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} must name the nearest joint above its model node as its Parent.");
|
||||
}
|
||||
foreach (var anchor in ship.Anchors.Where(anchor => anchor.Role == "weapon-mount" || anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh"))
|
||||
{
|
||||
var above = JointAbove(anchor.ModelNodeId);
|
||||
if (string.Equals(above ?? "", anchor.Joint ?? "", StringComparison.Ordinal)) continue;
|
||||
throw new InvalidOperationException(anchor.Role == "weapon-mount"
|
||||
? $"{ship.Id}: weapon mount {anchor.Id} must name the nearest joint above its model node as its Joint."
|
||||
: $"{ship.Id}: {anchor.Role} anchor {anchor.Id} sits under a joint in the model (follow-up gimballed-thrusters).");
|
||||
}
|
||||
}
|
||||
|
||||
// GLB is the xenos asset boundary. Only its node extras, whether each node names a mesh, and its nearest identified ancestor's node index
|
||||
// (-1 for none) are read here; geometry belongs to the runtime importer.
|
||||
public static Dictionary<string, (uint Index, bool HasMesh, int Parent)> ReadNodeIds(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
|
@ -203,17 +239,29 @@ public static class ShipModCatalog
|
|||
if (reader.ReadUInt32() != 0x4E4F534A || chunkLength > stream.Length - stream.Position)
|
||||
throw new InvalidOperationException($"{path}: missing GLB JSON chunk.");
|
||||
var json = JObject.Parse(Encoding.UTF8.GetString(reader.ReadBytes((int)chunkLength)));
|
||||
var ids = new Dictionary<string, (uint Index, bool HasMesh)>(StringComparer.Ordinal);
|
||||
var nodes = json["nodes"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>();
|
||||
var ids = new Dictionary<string, (uint Index, bool HasMesh, int Parent)>(StringComparer.Ordinal);
|
||||
var nodes = (json["nodes"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>()).ToArray();
|
||||
var parents = Enumerable.Repeat(-1, nodes.Length).ToArray();
|
||||
for (var parent = 0; parent < nodes.Length; parent++)
|
||||
foreach (var child in nodes[parent]["children"]?.Select(token => (int)token) ?? Enumerable.Empty<int>())
|
||||
if (child >= 0 && child < nodes.Length) parents[child] = parent;
|
||||
uint index = 0;
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var id = (string)node["extras"]?["aetheria.id"];
|
||||
if (string.IsNullOrEmpty(id)) { index++; continue; }
|
||||
if (!ids.TryAdd(id, (index, node["mesh"] != null && node["mesh"].Type != JTokenType.Null)))
|
||||
if (!ids.TryAdd(id, (index, node["mesh"] != null && node["mesh"].Type != JTokenType.Null, IdentifiedAncestor(parents, nodes, (int)index))))
|
||||
throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}.");
|
||||
index++;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static int IdentifiedAncestor(int[] parents, JObject[] nodes, int index)
|
||||
{
|
||||
var parent = parents[index];
|
||||
for (var steps = 0; parent >= 0 && steps <= nodes.Length; steps++, parent = parents[parent])
|
||||
if (!string.IsNullOrEmpty((string)nodes[parent]["extras"]?["aetheria.id"])) return parent;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ public sealed class ShipModPlan
|
|||
public string[] Radiators;
|
||||
// Each weapon mount (role weapon-mount) and its muzzle anchors, by Order then id.
|
||||
public (string Mount, string[] Muzzles)[] Weapons;
|
||||
// One entry per driven joint (one a weapon mount names), by its first weapon in hull order: the joint, its chain
|
||||
// root-first (RigChains), and the lead mount (the joint's first weapon in hull order) with that mount's first muzzle.
|
||||
// Presentation only: the assembler rigs these and nothing here reaches the simulation.
|
||||
public (string Joint, string[] Chain, string LeadMount, string LeadMuzzle)[] Rigs;
|
||||
|
||||
// Maps a pair Validate accepts; every rule about which anchor may play which part is Validate's, so this refuses nothing.
|
||||
public static ShipModPlan Build(HullData hull, ShipAuthoring visual)
|
||||
|
|
@ -26,6 +30,11 @@ public sealed class ShipModPlan
|
|||
var muzzles = visual.Anchors.Where(anchor => anchor.Role == "weapon-muzzle")
|
||||
.OrderBy(anchor => anchor.Order).ThenBy(anchor => anchor.Id, StringComparer.Ordinal).ToArray();
|
||||
|
||||
var jointOf = visual.Anchors.Where(anchor => anchor.Role == "weapon-mount" && !string.IsNullOrEmpty(anchor.Joint))
|
||||
.ToDictionary(anchor => anchor.Id, anchor => anchor.Joint, StringComparer.Ordinal);
|
||||
var leads = hardpoints.Where(hardpoint => jointOf.ContainsKey(hardpoint.Transform)).GroupBy(hardpoint => jointOf[hardpoint.Transform], StringComparer.Ordinal)
|
||||
.Select(group => (Joint: group.Key, Lead: group.First().Transform));
|
||||
|
||||
return new ShipModPlan
|
||||
{
|
||||
MapIcon = Only("map-icon"),
|
||||
|
|
@ -36,7 +45,9 @@ public sealed class ShipModPlan
|
|||
Radiators = Mounts(HardpointType.Radiator),
|
||||
Weapons = hardpoints.Where(hardpoint => ShipAuthoringStore.IsWeapon(hardpoint.Type)).Select(hardpoint => (
|
||||
hardpoint.Transform,
|
||||
muzzles.Where(muzzle => muzzle.ParentId == hardpoint.Transform).Select(muzzle => muzzle.Id).ToArray())).ToArray()
|
||||
muzzles.Where(muzzle => muzzle.ParentId == hardpoint.Transform).Select(muzzle => muzzle.Id).ToArray())).ToArray(),
|
||||
Rigs = leads.Select(rig => (rig.Joint, RigChains.Chain(visual, rig.Joint), rig.Lead,
|
||||
muzzles.First(muzzle => muzzle.ParentId == rig.Lead).Id)).ToArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
105
Assets/Scripts/ServerShared/ThrustAllocator.cs
Normal file
105
Assets/Scripts/ServerShared/ThrustAllocator.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/* 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 CultMath;
|
||||
|
||||
/// <summary>
|
||||
/// Turns intent (which way to go, which way to turn) into every thruster's throttle, whatever the thrusters' actual
|
||||
/// strength and position. One per ship. A column is one thruster's effect per unit throttle: x starboard acceleration,
|
||||
/// y forward acceleration, z clockwise yaw rate in rad/s. Throttles are bounded to [0,1].
|
||||
///
|
||||
/// One <see cref="BoundedLeastSquares"/> solve per call over the n throttles and four error variables (the unwanted or
|
||||
/// missing translation on each half-axis, paid for linearly). Yaw is a heavily weighted row, so a hold is exact and a
|
||||
/// turn is served first; the linear translation cost means the actuator with the least translation per unit of turn
|
||||
/// serves first and the next joins only as it saturates. Nothing classifies a column as attitude or drive: the onset
|
||||
/// at which a second actuator joins a turn falls out of the columns.
|
||||
/// </summary>
|
||||
public sealed class ThrustAllocator
|
||||
{
|
||||
const float TranslationWeight = 100f;
|
||||
const float YawWeight = 1000f;
|
||||
const float ErrorCostWeight = .3f;
|
||||
const float ErrorCostOffset = 10f;
|
||||
const float Ridge = .1f;
|
||||
const float ErrorMax = 2f;
|
||||
const double KktRelativeTolerance = 1e-12;
|
||||
const int ErrorVariables = 4;
|
||||
const int FixedRows = 3 + ErrorVariables;
|
||||
|
||||
int _columns = -1;
|
||||
float[] _a, _b, _lo, _hi, _x, _workspace;
|
||||
|
||||
public void Allocate(ReadOnlySpan<float3> columns, float2 move, float turn, Span<float> throttle)
|
||||
{
|
||||
var n = columns.Length;
|
||||
if (n != _columns) Resize(n);
|
||||
for (var i = 0; i < n; i++) throttle[i] = 0;
|
||||
|
||||
// Nothing asked, nothing fired: that is a fact about the intent, decided before the solve. The solver's warm
|
||||
// start is left as the last solved point, which is where the next non-zero call resumes.
|
||||
if (Intent(move, turn, 0) == 0 && Intent(move, turn, 1) == 0 && Intent(move, turn, 2) == 0) return;
|
||||
|
||||
var vars = n + ErrorVariables;
|
||||
var rows = FixedRows + n;
|
||||
|
||||
// Rows 0 and 1 are the translation axes (starboard, forward), row 2 the yaw.
|
||||
for (var axis = 0; axis < 3; axis++)
|
||||
{
|
||||
float plus = 0, minus = 0;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var c = Component(columns[i], axis);
|
||||
if (c > 0) plus += c;
|
||||
else if (c < 0) minus -= c;
|
||||
}
|
||||
var extreme = Math.Max(plus, minus);
|
||||
var intent = Intent(move, turn, axis);
|
||||
var weight = axis == 2 ? YawWeight : TranslationWeight;
|
||||
// Intent is a fraction of the half-axis extreme it points into.
|
||||
var demand = intent >= 0 ? intent * plus : intent * minus;
|
||||
var scale = extreme > 0 ? weight / extreme : 0f;
|
||||
var row = axis * vars;
|
||||
for (var i = 0; i < n; i++) _a[row + i] = Component(columns[i], axis) * scale;
|
||||
_b[axis] = demand * scale;
|
||||
if (axis < 2)
|
||||
{
|
||||
var errorWeight = extreme > 0 ? weight : 0f;
|
||||
_a[row + n + 2 * axis] = -errorWeight;
|
||||
_a[row + n + 2 * axis + 1] = errorWeight;
|
||||
}
|
||||
}
|
||||
|
||||
// A linear cost on each error variable, as w*e = -w*kappa, and a ridge on each throttle.
|
||||
for (var k = 0; k < ErrorVariables; k++)
|
||||
{
|
||||
_a[(3 + k) * vars + n + k] = ErrorCostWeight;
|
||||
_b[3 + k] = -ErrorCostWeight * ErrorCostOffset;
|
||||
}
|
||||
for (var i = 0; i < n; i++) _a[(FixedRows + i) * vars + i] = Ridge;
|
||||
|
||||
var status = BoundedLeastSquares.Solve(rows, vars, _a, _b, _lo, _hi, _x, _workspace, out _, KktRelativeTolerance);
|
||||
if (status == BoundedLeastSquaresStatus.InvalidInput) return;
|
||||
for (var i = 0; i < n; i++) throttle[i] = _x[i];
|
||||
}
|
||||
|
||||
static float Intent(float2 move, float turn, int axis) =>
|
||||
Math.Min(Math.Max(axis == 0 ? move.x : axis == 1 ? move.y : turn, -1f), 1f);
|
||||
|
||||
static float Component(float3 c, int axis) => axis == 0 ? c.x : axis == 1 ? c.y : c.z;
|
||||
|
||||
void Resize(int n)
|
||||
{
|
||||
_columns = n;
|
||||
var vars = n + ErrorVariables;
|
||||
var rows = FixedRows + n;
|
||||
_a = new float[rows * vars];
|
||||
_b = new float[rows];
|
||||
_lo = new float[vars];
|
||||
_hi = new float[vars];
|
||||
_x = new float[vars];
|
||||
_workspace = new float[BoundedLeastSquares.WorkspaceLength(vars)];
|
||||
for (var i = 0; i < vars; i++) _hi[i] = i < n ? 1f : ErrorMax;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/ThrustAllocator.cs.meta
Normal file
11
Assets/Scripts/ServerShared/ThrustAllocator.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5b0e3f7a9c2d4e61a8f4d3c7b1e90a26
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
32
Assets/Scripts/ServerShared/VapourClouds.cs
Normal file
32
Assets/Scripts/ServerShared/VapourClouds.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using CultMath;
|
||||
using static CultMath.math;
|
||||
using float2 = CultMath.float2;
|
||||
|
||||
// A vented vapour cloud: a free-floating disc that thins every sensor sight line through it. Not an Entity,
|
||||
// never priced or rolled, never saved. Zone.Vent is the only adder and Zone's cloud step the only remover;
|
||||
// Zone.Obscuration is the one reader detection uses.
|
||||
public sealed class VapourCloud
|
||||
{
|
||||
public FloatingBodyId Id;
|
||||
public KinematicBody Body;
|
||||
public double VentedAt;
|
||||
public float Lifetime;
|
||||
public float Radius;
|
||||
// The opacity at the moment of venting; it fades linearly to nothing over Lifetime.
|
||||
public float Opacity;
|
||||
public Entity Venter;
|
||||
|
||||
// A cloud with no lifetime divides by zero; saturate maps the NaN and the infinity to nothing left.
|
||||
public float OpacityAt(double now) =>
|
||||
saturate(Opacity) * saturate(1f - (float) (now - VentedAt) / Lifetime);
|
||||
|
||||
// Whether the segment a-b reaches the disc: the closest point of the segment to the centre lies within the
|
||||
// radius. A cloud past either end of the segment touches it only if it is near that end.
|
||||
public bool Touches(float2 a, float2 b)
|
||||
{
|
||||
var ab = b - a;
|
||||
var length2 = lengthsq(ab);
|
||||
var t = length2 > 0f ? saturate(dot(Body.Position - a, ab) / length2) : 0f;
|
||||
return lengthsq(Body.Position - (a + ab * t)) <= Radius * Radius;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/VapourClouds.cs.meta
Normal file
11
Assets/Scripts/ServerShared/VapourClouds.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: ced45a9738974452b0065b57ee527e8a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -20,6 +20,10 @@ public class Zone
|
|||
// Laid mines, in lay order. Zone owns their existence: Lay is the one adder, the mine step the one remover,
|
||||
// and every removal is a blast. Runtime state, never saved.
|
||||
public ReactiveCollection<Mine> Mines = new ReactiveCollection<Mine>();
|
||||
|
||||
// Vented vapour clouds, in vent order. Zone owns their existence: Vent is the one adder, StepClouds the one
|
||||
// remover, and Obscuration the one reader that detection uses. Runtime state, never saved.
|
||||
public ReactiveCollection<VapourCloud> Clouds = new ReactiveCollection<VapourCloud>();
|
||||
public Dictionary<CultRecordKey, BodyData> Planets = new Dictionary<CultRecordKey, BodyData>();
|
||||
public Dictionary<CultRecordKey, Planet> PlanetInstances = new Dictionary<CultRecordKey, Planet>();
|
||||
|
||||
|
|
@ -225,6 +229,7 @@ public class Zone
|
|||
foreach (var entity in Entities.ToArray()) entity.Update(deltaTime);
|
||||
|
||||
StepMines(deltaTime);
|
||||
StepClouds(deltaTime);
|
||||
|
||||
// Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that
|
||||
// firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and
|
||||
|
|
@ -238,6 +243,39 @@ public class Zone
|
|||
Mines.Add(mine);
|
||||
}
|
||||
|
||||
public void Vent(VapourCloud cloud)
|
||||
{
|
||||
cloud.Id = NextBodyId();
|
||||
cloud.VentedAt = _time;
|
||||
Clouds.Add(cloud);
|
||||
}
|
||||
|
||||
// The fraction of a sight line's signal that survives the clouds between `a` and `b`: the product of
|
||||
// (1 - opacity now) over every cloud whose disc the segment touches, so 1f when none does. Derived from the
|
||||
// live clouds at each call and stored nowhere; Sensor.Gain is where it enters detection.
|
||||
public float Obscuration(float2 a, float2 b)
|
||||
{
|
||||
var through = 1f;
|
||||
for (var i = 0; i < Clouds.Count; i++)
|
||||
{
|
||||
var cloud = Clouds[i];
|
||||
if (cloud.Touches(a, b)) through *= 1f - cloud.OpacityAt(_time);
|
||||
}
|
||||
return through;
|
||||
}
|
||||
|
||||
// After StepMines: each cloud drifts as a KinematicBody on the zone's force, as a mine does, and is removed
|
||||
// once its lifetime has run out. Nothing else removes a cloud.
|
||||
private void StepClouds(float deltaTime)
|
||||
{
|
||||
var settings = _itemManager.GameplaySettings;
|
||||
foreach (var cloud in Clouds.ToArray())
|
||||
{
|
||||
cloud.Body.Step(deltaTime, GetForce(cloud.Body.Position), settings);
|
||||
if (cloud.VentedAt + cloud.Lifetime <= _time) Clouds.Remove(cloud);
|
||||
}
|
||||
}
|
||||
|
||||
// After the entity loop and before FireControl.Step, in lay order: each mine drifts as a KinematicBody, is
|
||||
// triggered by the first eligible hull its armed disc touches, and detonates through FireControl.Detonate at
|
||||
// its trigger plus fuse delay or at its lifetime, whatever its layer does (ruling mine-owner-leash). The
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
<CultLibRevision>45c2f4006f5f7dab357426cabd3197e8d0a17f66</CultLibRevision>
|
||||
<CultMathRoot Condition="'$(CultMathRoot)' == '' and '$(CULTMATH_ROOT)' != ''">$(CULTMATH_ROOT)</CultMathRoot>
|
||||
<CultMathRoot Condition="'$(CultMathRoot)' == ''">$(CultLibRoot)</CultMathRoot>
|
||||
<!-- CultMath: cultmath-unity-v0.3.0 in Packages/manifest.json. -->
|
||||
<CultMathRevision>614fd445845f6251ee2986707beecea0bb211791</CultMathRevision>
|
||||
<!-- CultMath: cultmath-unity-v0.4.0 in Packages/manifest.json. -->
|
||||
<CultMathRevision>252e226c51826d626f4113673f31d96577b67ec9</CultMathRevision>
|
||||
<GitExecutable Condition="'$(GitExecutable)' == '' and Exists('C:\Program Files\Git\cmd\git.exe')">C:\Program Files\Git\cmd\git.exe</GitExecutable>
|
||||
<GitExecutable Condition="'$(GitExecutable)' == ''">git</GitExecutable>
|
||||
</PropertyGroup>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,6 @@
|
|||
"com.unity.modules.xr": "1.0.0",
|
||||
"org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0",
|
||||
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60",
|
||||
"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.3.0"
|
||||
"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,11 +272,11 @@
|
|||
"hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66"
|
||||
},
|
||||
"org.gamecult.cultmath": {
|
||||
"version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.3.0",
|
||||
"version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.0",
|
||||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {},
|
||||
"hash": "614fd445845f6251ee2986707beecea0bb211791"
|
||||
"hash": "252e226c51826d626f4113673f31d96577b67ec9"
|
||||
},
|
||||
"com.unity.modules.accessibility": {
|
||||
"version": "1.0.0",
|
||||
|
|
|
|||
|
|
@ -7014,8 +7014,9 @@ That file's Cut 4 (AI heading planner) and Cut 5 (combat facing) are carried as
|
|||
- **TA7. The Duel arena moves the player by itself.** With forward thrust the player's velocity swings
|
||||
between about +28 and -28 m/s along the nose every ~60 ticks with no `VelocityLimit` clamp (limit 100,
|
||||
drag 0.1), while intent zero leaves it at rest. Translation measured in the Duel is not thrust; tests that
|
||||
measure translation use `RestoredHullsTests.BuildThrustedShip`. Cause not traced (the zone's field at
|
||||
the spawn is the first suspect).
|
||||
measure translation use `RestoredHullsTests.BuildThrustedShip`. Traced 2026-10-10 (TW1 below): a
|
||||
harness artifact of stepping `player.Update` without `Zone.Update`, which leaves every orbit at the
|
||||
origin and stacks the zone's gravity wells on the spawn.
|
||||
- **TA8. CultMath 0.3.0 re-rolls generated content.** Full suite at `80d2c7f3` with only
|
||||
`CultMathRevision` moved to `614fd445` (tag `cultmath-unity-v0.3.0`): 1057 pass, 5 fail.
|
||||
`TutorialGalaxy_unchanged` (faction homes differ), three `RestoredHullsTests` that pick zones by name
|
||||
|
|
@ -7153,6 +7154,87 @@ Rationale changes:
|
|||
by this revision: the yaw weight is 1000 against a ridge of .1, and uniqueness of the answer depends on
|
||||
the tolerance as well as the ridge.
|
||||
|
||||
### Wiring refresh at `08d064c8` (2026-10-10)
|
||||
|
||||
Pass: Imagination `imagination-aeth-allocator-wiring`, session `self-2026-10-10-ag`. Body:
|
||||
`GameCult/Aetheria` origin/master `08d064c8` (thrust-allocator-core merged, CultMath pinned at 0.4.0,
|
||||
`CultMathRoot` `cultlib-252e226c` on Yggdrasil), detached worktree `C:\aeth-wt\map-articulated`. Probes:
|
||||
scratch partial `RunStartTests` facts `ImagProbeDuelOscillation` and `ImagProbeCatalogThrusters`, run on
|
||||
Yggdrasil through the stopgap (logs `j2.log`, `j3.log` in the session scratchpad
|
||||
`imagination-aeth-allocator-wiring`). The allocator cases patched the scratch clone's `Ship.Update` only:
|
||||
after the mixer, a static flag overwrote every `Axis` with `ThrustAllocator.Allocate` over columns built
|
||||
from live `Thrust` (equal to `NominalThrust` on the fully powered Duel ship). Each case is a fresh Duel
|
||||
launch stepped 240 ticks at 1/60 s; heading in degrees from the launch heading.
|
||||
|
||||
- **TW1. The Duel oscillation is a harness artifact** (follow-up `duel-velocity-oscillation`, TA7).
|
||||
`Orbit.Position` starts at `float2.zero` (`Zone.cs:726`) and is set only by `Zone.Update`
|
||||
(`Zone.cs:218-223`). Stepping `player.Update` alone leaves all 38 bodies of the Duel's arena zone (4 suns,
|
||||
4 gas giants, 30 planets, wells 546-9420 m wide) at the origin, which is the player's spawn
|
||||
(`Duel.cs`: `stage.Player(fighter, float2(0, 0))`). Their wells stack there: `Zone.GetForce` is 0 at the
|
||||
origin, -64 m/s^2 at (0,5), -982 at (0,20), -8082 at (0,80). Under thrust the ship climbs out and falls
|
||||
back through the stack, which is the swing TA7 saw:
|
||||
|
||||
| Case (Duel player, 240 ticks) | Along-nose speed, max / min (m/s) | Heading at tick 120 / 240 |
|
||||
|---|---|---|
|
||||
| A mixer, forward, `player.Update` | 31.5 / -30.6 (first below -1 at tick 36) | -12.09 / -21.94 |
|
||||
| B mixer, forward, `arena.Update` | 101.7 / 1.9 | -12.09 / -21.94 |
|
||||
| C mixer, forward, `player.Update`, `GravityStrength` 0 | 101.7 / 1.9 (B to the digit) | as B |
|
||||
| D mixer, intent zero, `player.Update` | 0 / 0 | 0 |
|
||||
| E allocator, forward, `player.Update` | 31.5 / -30.8 | .018 / .036 |
|
||||
| F allocator, forward, `player.Update`, `GravityStrength` 0 | 101.9 / 0 | .018 / .036 |
|
||||
| I mixer, forward .3, `player.Update` | 12.7 / -12.3 | -3.88 / -7.56 |
|
||||
| J allocator, forward .3, `player.Update` | 12.7 / -12.3 | .001 / .002 |
|
||||
|
||||
The first `Zone.Update` moves the bodies to their orbits: the spawn's height goes from -3316 to -69 and
|
||||
the field at the player stays under 4.3 m/s^2 for the whole run (case B). The game ticks the zone before
|
||||
any ship (`Zone.Update` sets orbits first), so play never sees the stack. Neither the mixer, damping, the
|
||||
tick nor `Steering.Toward` is involved (no `Turn` in A to J; D and C rule out drift and drag).
|
||||
- **TW2. Wiring is irrelevant to the oscillation and removes the drift.** E and J match A and I in speed
|
||||
(the well decides it) and hold heading to hundredths of a degree where the mixer turns 6.4 deg/s (TA3,
|
||||
reproduced at `08d064c8`: -12.09 deg in 120 ticks). Full forward: both drives at 1 and the Talaria (2,14)
|
||||
at .039 falling to .027 as heat moves the drives, so the ship crabs to starboard at 1.6 m/s at top speed.
|
||||
Forward .3: drives at .319 and .283, no Talaria. With `Turn` .5 (LaunchDuel's intent) the allocator
|
||||
turns 107 deg/s (.5 of the 215.5 extreme, Talaria .68, both drives 1); the mixer 77.
|
||||
- **TW3. Top speed overshoots the limit by one tick of thrust.** Case B settles at 101.4-101.7 m/s against
|
||||
`VelocityLimit` 100 with `LimitClamp` about 1.7 m/s each tick: the limit clamps before the drives push in
|
||||
the same tick. Mixer and allocator alike; nothing in this campaign's flight cuts owns the order.
|
||||
- **TW4. No shipped hull reaches the allocator's large-hull regime.** The catalog holds two Ship hulls:
|
||||
Djinni, 8 thruster hardpoints (Reversed 3, CounterClockwise 2, Clockwise 2, None 1), and Longinus, 4.
|
||||
`GameData` holds only `Aetheria.cc`, `Narrative/` and `SoundbanksInfo.json`; no package hull is in the
|
||||
tree. Finding `large-hull-degradation` starts at about 32 thrusters.
|
||||
- **TW5. Every thruster's Thrust carries PowerSupply^1.** All seven catalog thruster designs (Large,
|
||||
Medium, Small Drive, Talaria, Victoire, RevvITup 2.0, deep space burnout) carry `PowerSupply` with
|
||||
exponent 1 on `Thrust`; none carries it on `EnergyUsage`. All thrusters share `PowerTiers.Medium`
|
||||
(`Thruster.cs:91`). Under a brownout that grants one fraction to the whole tier every column scales
|
||||
alike and the solved balance survives; it breaks only where grants differ within the tier. That is the
|
||||
question follow-up `allocator-brownout-columns` keeps.
|
||||
- **TW6. What moved since `80d2c7f3`.** `Ship.cs` gained only throttle-lock's two lines (`:236-237`): the
|
||||
mixer is now `:238-261` inside the gate `:233-262`. `Thruster.cs`, `Behaviors.cs:96-99`,
|
||||
`Settings.cs:214`, `SteeringTests.cs`, `BrownoutTests.cs`, `ConditionRatioTests.cs`, `SimClockTests.cs`
|
||||
and `RunStartTests.cs` are unchanged. `Entity.cs`'s commented `IAnalogBehavior` field is `:2360`.
|
||||
`EvasionTermTests.cs` moved +16: `TheAggregatesReadLiveThrust` `:716-734` (the `ForwardThrust` asserts
|
||||
`:724-725`), `EveryAggregateAndTheCompensationReadLiveThrust` `:1013-1072`. `ThrottleLockTests.cs`
|
||||
exists (274 lines, 7 facts and theories); every assertion is an exact mixer `Axis` (1 or 0) on a fixture
|
||||
whose thrusters are found by mount rotation. `ThrustAllocatorTests.TheStartDoesNotChangeTheAnswer`
|
||||
(`:253-265`) asserts start independence at 1e-5 on one point while finding `solver-converged-early`
|
||||
stands; the CultMath stop fix (`cultlib-gaps:cut_spec:cut-bls-release-stop.r1`) owns what happens to it.
|
||||
|
||||
Refresh decisions (specs `cut-thrust-allocator.r3`, `cut-envelope-from-columns.r2`):
|
||||
|
||||
- **Translation may be measured in the Duel if the arena is stepped.** TA7's rule is replaced by TW1's
|
||||
cause: a test that reads velocity steps `arena.Update`, or uses `BuildThrustedShip`; a test that reads
|
||||
only heading may step `player.Update` (heading in A equals B to the digit). The unplaced-orbit defect
|
||||
is the zone's, not flight's (follow-up `zone-orbits-unplaced-before-first-tick`).
|
||||
- **`Column(thrust)` from the first cut.** The wiring cut adds `Column(float thrust)` and passes
|
||||
`NominalThrust`; envelope-from-columns passes live thrust to the same method. r1 had the envelope cut
|
||||
change the signature, which was churn on one owner.
|
||||
- **Folded:** `allocator-residual-pins` (a micro-intent test in `ThrustAllocatorTests`). **Left, with
|
||||
reasons:** `allocator-large-hull` (TW4: no shipped hull is near 32 thrusters; it gates on package hulls);
|
||||
`allocator-brownout-columns` (TW5: a design question, not the wiring); `steering-feasible-direction`
|
||||
(reads the envelope's clockwise and counter-clockwise terms, so it follows envelope-from-columns);
|
||||
`allocator-ai-planner` (plans through the wired allocator, so it follows this cut). Answered by the
|
||||
wiring when it holds: `asymmetric-drive-torque` (flight control balances the drives, TW2).
|
||||
|
||||
## Spec refresh 2026-10-09: scarce consumables, the fog bank, multiply, tiered boosters, the shipped AetherDb
|
||||
|
||||
Pass: Imagination `imagination-spec-refresh`, session `self-2026-10-09-morning`, follow-up
|
||||
|
|
@ -7623,3 +7705,298 @@ retire-firing-arc r1, ships-addon-link-rows r1; link-rig-record r3; ships-addon-
|
|||
`mind-commit-0c23fa01...` (ships-addon-link-package r2, link-rig-presenter r2, link-calibre-judge r2, question
|
||||
`hardpoint-arc-caps`, withdrew question `calibre-arc-caps`) and `mind-commit-40788d58...` (ships-addon-link-package r3,
|
||||
r2's arm smoke pins fixed). context-pack read every anchor SAME at `acea1aa7`.
|
||||
|
||||
## Vectored thruster rows
|
||||
|
||||
Pass: Imagination (`imagination-aeth-vectoring`), session `self-2026-10-10-ag`. Body: `GameCult/Aetheria` origin/master
|
||||
`30d02719`; the allocator at `1fc98bdc` (branch `eureka/aetheria-release-thrust-allocator-core`, read-only, a fix
|
||||
batch in flight); the rig record at `562de34c` (branch `eureka/aetheria-release-link-rig-record`, Soul pass running);
|
||||
CultMath `BoundedLeastSquares` at CultLib origin/main `50512e6e`. One design owns follow-ups `vectored-thruster-rows`
|
||||
(operator: "if that dine and dash could tilt that vector for each row of thrusters it would be even more hyper
|
||||
maneuverable"; a gimbal per row, not per thruster) and `gimballed-thrusters`. Prior art:
|
||||
`docs/research/thrust-vectoring-prior-art.md` at `30d02719` (cited as VP, by section).
|
||||
|
||||
### Body facts
|
||||
|
||||
- **V1. A column is linear in the push direction.** The allocator's column is xy = push * thrust / mass and
|
||||
z = `Torque` * thrust * `TorqueMultiplier` / mass (spec `cut-thrust-allocator.r1`, adds; `ThrustAllocator.cs:8-18`
|
||||
at `1fc98bdc`). `Torque` is `-dot(normalize(toCenter), float2(1,0).Rotate(rotation))` (`Thruster.cs:62-71`), linear
|
||||
in the direction vector. So a thruster tilted by angle a has column c(a) = cos a * c(0) + sin a * c(pi/2): two fixed
|
||||
columns per thruster carry every tilt. This is VP 1.1's virtual-actuator trick, exact for Aetheria's planar plant.
|
||||
- **V2. BoundedLeastSquares has box bounds only** (`BoundedLeastSquares.cs`, CultLib `50512e6e`: `lo <= x <= hi`,
|
||||
dense primal active set, iteration cap 100, warm start in `x`). A gimbal wedge |v| <= tan(theta) u is not a box
|
||||
(VP 2). The design below never needs the wedge: the per-tick slew window in the row's own frame is narrow (3 deg at
|
||||
90 deg/s and 30 Hz, 12 deg at 360 deg/s), so v is boxed by the sine of the window and the recovered angle is
|
||||
clamped to the window and the gimbal. No CultMath capability is missing, so no cultlib-gaps follow-up.
|
||||
- **V3. The sim already has the data seam.** `HardpointData.FiringArc` (key 6) is the hardpoint's arc
|
||||
(`ItemData.cs:567-569`); `EquippedItem.Hardpoint` gives a behaviour its hardpoint (`Entity.cs:1688`, read by
|
||||
`FireControl.ArcFor`, `FireControl.cs:36-41`). `HardpointData` uses keys 0-6; `ThrusterData` keys 1-5
|
||||
(`Thruster.cs:12-27`; `BehaviorData` owns key 0). At `562de34c` the judge refuses a `thruster-emitter` anchor on a
|
||||
joint, naming follow-up `gimballed-thrusters` (`ShipAuthoring.cs:360-363`), and checks that weapon mounts on one
|
||||
joint share `Rotation` and `FiringArc` and that the chain's yaw reach covers the arc (`:370-385`).
|
||||
- **V4. The gimbal is a sim fact.** By V1 the angle changes push and turn, which the allocator and the evasion
|
||||
envelope read. In V6, fixed rows leave forward and reverse at E 0.361 on the small hull; tilting rows take both to
|
||||
0.000. So the row angle, its limit and its slew are simulation state and stats (target invariant `sim-owns-facts`);
|
||||
the joint only draws `Thruster.Angle` (rulings `rig-is-presentation`, `mount-arc-gun-traverse`).
|
||||
- **V5. Probe harness.** Scratch C# console at `scratchpad/imagination-aeth-vectoring/harness` (local commit
|
||||
`b5162ab`, then a v2 that fixed M4's scoring and added M3c and M5), `Program.cs` plus a verbatim copy of
|
||||
`BoundedLeastSquares.cs` from `50512e6e`, run on Yggdrasil through `ygg-verify.sh` (`dotnet run -c Release`, image
|
||||
`mcr.microsoft.com/dotnet/sdk:10.0`), 2026-10-10, exit 0 both runs (`run1.log`, `run2.log` beside it). The core
|
||||
solve copies `ThrustAllocator` at `1fc98bdc` exactly (weights 100 and 1000, L1 split error variables, ridge 0.1,
|
||||
KKT 1e-12, cap 100); every method shares it and shares one demand scale (V7). Hulls: *small*, two mains (accel 10)
|
||||
and two side rows of four (accel 2, pushing across the hull, gimbal +-45 deg); *large*, four mains and four rows of
|
||||
eight (36 thrusters). 30 Hz, 90 ticks per intent from rows at 0, 12 intents (full strafes, forward, reverse, two
|
||||
diagonals, full turns, forward plus turn, strafe plus half turn, two partial). E is the realized wrench's error
|
||||
against the demand, normalised per axis by the box extreme. REF is a brute-force grid over both row angles
|
||||
(37 x 37, no slew limit) with the fixed-column solve inside, minimum weighted error.
|
||||
- **V6. Results** (mean E over the 12 intents after 90 ticks; worst single solve's iterations; us per tick on
|
||||
Yggdrasil):
|
||||
|
||||
| Method | small 90 deg/s | small 360 deg/s | large 90 deg/s | solves/tick | worst iters (large) | us/tick (large) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| REF grid (static optimum, shared row angle) | 0.134 | 0.134 | n/a | 1369 | n/a | n/a |
|
||||
| M0 fixed rows (today) | 0.298 | 0.298 | 0.310 | 1 | 95 | 36 |
|
||||
| M1 row as one actuator: shared throttle, (u, v) in the row frame, slew-boxed | 0.142 | 0.138 | 0.133 | 1 | 20 | 3 |
|
||||
| M2 cascade: per-thruster (u, v), one angle per row, re-solve | 0.137 | 0.131 | 0.131 | 2 | 95 | 267 |
|
||||
| M3 angle step linearised at last tick's throttles, one solve | 0.201 | 0.196 | 0.215 | 1 | 95 | 74 |
|
||||
| M3c as M3, idle rows linearised at throttle .25 | 0.138 | 0.136 | 0.132 | 1 | 100 (2 IterationLimit) | 79 |
|
||||
| M5 M1's angle, then per-thruster re-solve | 0.142 | 0.138 | 0.131 | 2 | 95 | 44 |
|
||||
| M4 per-thruster gimbals (no row constraint; upper bound) | 0.138 | 0.135 | 0.129 | 1 | 89 | 328 |
|
||||
|
||||
No method produced NaN. M1, M2, M3c, M4 and M5 reach E 0.000 on forward and reverse (M0 and M3: 0.361). The large
|
||||
hull's 95-iteration solves are the fixed per-thruster solve itself (M0 is also 95): finding
|
||||
`cut-thrust-allocator-core.s1.large-hull-degradation`, follow-up `allocator-large-hull`. M1 collapses each row to
|
||||
two variables (12 on the large hull instead of 36) and stays at 20 iterations, which is the "group identical
|
||||
columns" remedy that follow-up names, arrived at for free.
|
||||
- **V7. The demand scale must be the gimbal reach.** The allocator scales intent by box extremes it sums from the
|
||||
columns it is given (`ThrustAllocator.cs:44-61`). With rows tilting, current-column extremes move every tick, so
|
||||
"full forward" would change meaning as the rows swing. The probe scales by the reach: per thruster and half-axis,
|
||||
the maximum of A cos a + B sin a over the gimbal (closed form: sqrt(A^2 + B^2) when atan2(B, A) lies inside the arc,
|
||||
else the better end). Small hull: +y 31.3 (mains 20, eight row thrusters 2 sin 45 deg each), -y 11.3, +-x 8.0,
|
||||
+-z 12.53. The same reach is the envelope's box (cut `envelope-from-columns`), so intent and evasion read one
|
||||
geometry.
|
||||
- **V8. Zero thrust: hold the angle, but never linearise through the throttle.** Schedule strafe right, idle, strafe
|
||||
left, forward, idle, reverse, 30 ticks each, both hulls, 90 deg/s. M1 and M5: every step within the window (3.00
|
||||
deg max), idle drift 0.000 deg, idle throttle 0, no sign flips in the late half of any phase, and reverse ends at
|
||||
-45/45 (correct). M2: one or two late sign flips, and 3 deg of idle drift on the large hull (its stage one aims
|
||||
idle rows with v alone). M3 is trapped: a row at zero throttle has a zero angle column, so it never tilts, forward
|
||||
stays at E 0.361 and reverse ends at 45/-45 (the wrong way). M3c unsticks it but chatters (15 late flips), fires
|
||||
at full throttle while idle on the large hull and hits the cap. Holding the previous angle when the row's (u, v)
|
||||
is near zero (|(u, v)| < 1e-6) is enough once (u, v) are free variables: v can aim an idle row without thrust, and
|
||||
the box's overcount is useful there.
|
||||
|
||||
### Model page rows
|
||||
|
||||
| Kind | Identity | Lifecycle | Authority |
|
||||
|---|---|---|---|
|
||||
| Gimbal limit (`HardpointData.FiringArc`, key 6, on a `Thruster` hardpoint; question `vector-row-data-home`) | The hardpoint's mount id (`Transform`) on its hull. Full width in degrees, as for a gun; the row swings +-FiringArc/2 about `Rotation`. 0 is a fixed thruster; thrusters get no `GameplaySettings.FiringArc` fallback. | Authored per hull with the other hardpoint fields; Save or Package writes it; a package edit changes it on Continue (moves no cell). | The ship author. The judge requires every member of a row to share it and the row's joint chain to reach it. The thruster reads it at activation. |
|
||||
| Row (`HardpointData.Row`, new key 7, string) | Unique within its hull; empty means not in a row. A `Thruster` hardpoint with `FiringArc` > 0 and no `Row` is a row of one. | Authored per hull; renaming a row changes no save (no save names a row; ruling `no-save-compatibility-before-players`). | The ship author. Judge: only on `Thruster` hardpoints; members share `Rotation` and a `FiringArc` > 0. Never derived from a joint (ruling `rig-is-presentation`). |
|
||||
| Slew rate (`ThrusterData.Slew`, new key 6, `PerformanceStat`, deg/s) | A stat of the thruster design. | Authored per design; heat, durability and quality move it live like `Thrust`. A row slews at its slowest live member. | The gear designer, as a weapon's traverse is (ruling `mount-arc-gun-traverse`). |
|
||||
| Live row angle (`Thruster.Angle`, runtime) | The thruster behaviour; every member of a row holds the same value. Radians from the hardpoint's `Rotation`. | 0 at `Activate`; moves at most slew x dt per tick; held while the row is idle; never saved (a load restarts at 0). | `ThrustAllocator` decides it; `Ship.Update` is its one writer. `Execute`, the envelope and presentation read it. The rig draws it and never writes it. |
|
||||
| Row actuator (runtime) | The row, built at `Activate` from its members' hardpoints. | Rebuilt when the thruster set changes (the allocator's resize rule). A dead member's columns are zero, so the row keeps steering with the rest. | `ThrustAllocator` reads the summed along and across columns and the slew window, and decides one throttle and one angle step per row. |
|
||||
|
||||
### Authority map
|
||||
|
||||
- **Owner.** `ThrustAllocator` decides every fixed thruster's throttle and every row's throttle and angle step, in one
|
||||
`BoundedLeastSquares` solve (question `vector-row-formulation`, recommended `shared-throttle-row`).
|
||||
- **Inputs.** Intent (unchanged); each fixed thruster's column; per row, the summed column along and across its live
|
||||
angle and its slew window [max(-arc/2 - angle, -slew dt), min(arc/2 - angle, slew dt)]; the reach (V7).
|
||||
- **Outputs.** `Thruster.Axis` for every thruster (a row's members share the row's) and `Thruster.Angle` for every
|
||||
row member, read by `Execute`, `PowerRequest`, the envelope and presentation.
|
||||
- **Derived.** The reach and the envelope (one helper); a row's summed columns; the slew window.
|
||||
- **No longer owners.** `Allocate` no longer sums its box extremes from the current columns; they come from the reach
|
||||
helper. A joint, its limits and the IK pose decide nothing in the sim.
|
||||
- **Forbidden writers.** Any `Angle` write outside `Ship.Update`; any read of `ShipJoint`, `ShipAnchor.Joint` or a
|
||||
Unity transform in `Thruster`, `Ship` or `ThrustAllocator`; a second throttle solve or fallback mixer; an angle step
|
||||
linearised through last tick's throttles (M3, V8).
|
||||
- **Shared paths.** Player helm, agents, the throttle lock and tests reach rows only through intent.
|
||||
`Thruster.Column(angle)` is the one geometry function `Execute`, the allocator and the envelope call.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Why one actuator per row.** M1 matches M5 (per-member throttles after the same angle) within 0.002 mean E on all
|
||||
three sweeps, is within 0.008 of the static optimum, keeps the allocator's one solve (ruling
|
||||
`one-weighted-solve-no-rotation-demand`; the thrust-allocator authority map forbids a second solve) and is the
|
||||
cheapest in iterations and time. M2 is the most accurate (0.131) but doubles the variables, reaches 95 of 100
|
||||
iterations on 36 thrusters and drifts idle rows. What M1 gives up is differential throttle inside a row: on these
|
||||
hulls it measured nothing, and a row that needs it can be authored as two rows. The operator decides: question
|
||||
`vector-row-formulation`.
|
||||
- **Why the limit is the hardpoint's arc and the slew the gear's.** It is ruling `mount-arc-gun-traverse` applied to
|
||||
thrusters: how far a mount swings is ship authoring; how fast is the part's own stat. Reusing `FiringArc` adds no
|
||||
field that the add-on, Studio and C4 must learn; its meaning (how far the mounted item may point from `Rotation`)
|
||||
already fits. Question `vector-row-data-home` holds the alternatives.
|
||||
- **Why the envelope counts the whole gimbal, not the slew.** The envelope already ignores the time to swing the
|
||||
hull (cut `envelope-from-columns` sums box extremes); counting slew time only for rows would be a second,
|
||||
inconsistent time model. If the pace pass finds slow gimbals over-credited, slew time enters the envelope for hull
|
||||
turn and rows together.
|
||||
- **Sequence.** Both cuts land after `thrust-allocator` and `envelope-from-columns`, whose `Ship.Update` wiring and
|
||||
`RecalculateEnvelope` they change, and after `link-rig-record`, whose judge they extend. The rig and the add-on are
|
||||
follow-up `vector-rows-presentation`: nothing authors a `Row` until the add-on does, so the record cut is inert
|
||||
data until then.
|
||||
|
||||
### Cut order
|
||||
|
||||
1. `vector-rows-record` after `link-rig-record`, `thrust-allocator` and `envelope-from-columns`:
|
||||
`HardpointData.Row`, `ThrusterData.Slew`, the judge's row rules, thruster anchors allowed on joints with the
|
||||
reach check.
|
||||
2. `vector-rows-allocator` after `vector-rows-record`, `thrust-allocator-core`, `thrust-allocator` and
|
||||
`envelope-from-columns`: rows as one actuator each in the solve, `Thruster.Angle`, `Column(angle)`, the reach
|
||||
helper for demand and envelope. Its `Ship.cs` anchors are written against the merged wiring at Hands time
|
||||
(the spec says where).
|
||||
|
||||
### Admitted
|
||||
|
||||
2026-10-10 (`imagination-aeth-vectoring`): receipt `mind-commit-2829a7e1...` (cut_specs `cut-vector-rows-record.r1`,
|
||||
`cut-vector-rows-allocator.r1`, questions `vector-row-formulation` and `vector-row-data-home`, follow-up
|
||||
`vector-rows-presentation`) and `mind-commit-e1337a9c...` (`cut-vector-rows-record.r2` superseding r1: its
|
||||
ShipValidationTests anchor named the wrong class). context-pack read every anchor SAME: record r2 at `562de34c`,
|
||||
allocator r1 at `1fc98bdc`.
|
||||
# Lock warning (cut lock-warning) and consumable-ai r2
|
||||
|
||||
Imagination imagination-aeth-lock-warning, session self-2026-10-10-ag, 2026-10-10.
|
||||
Pinned to Aetheria origin/master 08d064c8dce6081bb48e1922a3090c855db2e7c6 (lock-warning)
|
||||
and to branch eureka/aetheria-release-consumable-ai head f2ecc96a (consumable-ai r2,
|
||||
not merged to master). For Self to fold into docs/aetheria-release-map.md.
|
||||
|
||||
## Body facts (probes and source reads)
|
||||
|
||||
LW1. The sim has no fact a ship owns about locks on it. `Entity.TargetedBy`
|
||||
(Subject) and `Entity.TargetedByCount` (Entity.cs:174-175 at 08d064c8) count ships
|
||||
whose *selected* target is this one (Entity.cs:255-259). Selecting a target emits
|
||||
nothing, so that count is not something the targeted ship can sense. Its one
|
||||
reader is the HUD's music switch (ActionGameManager.cs:1063).
|
||||
|
||||
LW2. The HUD's lock bar is the player's own *outgoing* lock: one `LockIndicator`
|
||||
per own `LockWeapon` (ActionGameManager.cs:1139-1144), driven by `targetLock.Lock`
|
||||
(noise, frequency, spin; :1440-1452). No incoming-lock presenter exists.
|
||||
|
||||
LW3. A lock is built only inside `LockWeapon.Execute` (LockWeapon.cs:81-110): the
|
||||
lock resets when the target changes (86-90); it grows only on a hostile entity
|
||||
target while the angle between the shooter's Aim and the target is under
|
||||
LockAngle (100-105); outside that it decays (106). The lock speed scales with
|
||||
`Entity.EntityInfoGathered[target]` raised to SensorImpact, the shooter's private
|
||||
sensor state. `IsLocked` is `_lock > .99` (66).
|
||||
|
||||
LW4. Behaviours execute only while their item is Active
|
||||
(EquippedItem.Update, Entity.cs:1982-1998; consumable effects at 1626-1635).
|
||||
A behaviour that stops executing leaves its fields as they were, so any
|
||||
"painting" flag stored on the weapon goes stale when the launcher goes offline.
|
||||
A warning must therefore be re-asserted each tick by the emitter, never read
|
||||
from emitter state that might be stale.
|
||||
|
||||
LW5. `Entity.Update` returns at once when the entity is inactive
|
||||
(Entity.cs:1376-1379) and already walks `Zone.Entities` every tick for
|
||||
hostility (1406-1414). `Deactivate` (772-787) disposes subscriptions and clears
|
||||
the visibility collections. `Entity.VisibilitySources` (88, decayed in Update
|
||||
1395-1400) is the existing precedent for state other entities push onto a ship.
|
||||
|
||||
LW6. consumable-ai h1 (f2ecc96a) reads `e.Weapons.OfType<LockWeapon>().Any(w =>
|
||||
w.Lock > .5)` over `ship.VisibleEnemies` filtered to enemies targeting this ship
|
||||
(Combat.cs:147-148), after the `target == null` early return (Combat.cs:33-34).
|
||||
Finding reads-enemy-weapon-state: private emitter state, and the unseen-locker
|
||||
case inverted (a locker the ship cannot see never warns).
|
||||
|
||||
LW7. `Entity.TryActivateConsumable` (Entity.cs:895-907 at f2ecc96a) has no
|
||||
liveness guard, and `CanActivateConsumable` (883-886) admits a Stackable design
|
||||
every call. A dead or docked ship's agent still ticks (finding
|
||||
deactivated-ship-spends-cargo); a stack of vents drains one per locked tick
|
||||
(finding stackable-vent-drained-per-tick).
|
||||
|
||||
LW8. ConsumableAiTests (f2ecc96a) holds a lock steady by turning lockers away
|
||||
after one tick (`HoldLocks`, 204-210). Once warnings come from paints, a locker
|
||||
looking away is the no-paint case, so every vent fixture must keep its lockers
|
||||
painting.
|
||||
|
||||
## Authority map: the incoming-lock warning
|
||||
|
||||
- Owner: `Entity` (ServerShared) owns `IncomingLocks`, the ship's list of
|
||||
`LockWarning` facts, published once per Entity.Update.
|
||||
- Emission owner: `LockWeapon.Execute` is the one place that decides a beam is
|
||||
on a ship (the LockAngle test it already makes). Each tick it paints, it
|
||||
pushes one paint to the target: `target.ReceivePaint(Entity.Position,
|
||||
Item?.Data, Lock)`. Nothing else calls ReceivePaint.
|
||||
- Inputs: paints received since the ship's last Update (emitter position,
|
||||
emitting gear design, lock strength). Never the emitter's weapon list, target
|
||||
slot or EntityInfoGathered.
|
||||
- Outputs: `IReadOnlyList<LockWarning> IncomingLocks`, each with Bearing (unit
|
||||
xz direction from this ship to the emitter at paint time), EmitterClass (the
|
||||
emitting gear's `EquippableItemData`, null for a consumable-borne emitter)
|
||||
and Strength (0..1; content is question lock-warning-content).
|
||||
- Derived state: the paint inbox is a one-tick buffer, swapped into
|
||||
IncomingLocks at the top of the receiver's Update and cleared. The list is
|
||||
empty on an inactive ship and cleared by Deactivate.
|
||||
- Forbidden writers and readers: CombatState and every Agents/ file read no
|
||||
`LockWeapon`, `.Lock` or `IsLocked` of another ship; ActionGameManager reads
|
||||
enemy lock state only through `CurrentEntity.IncomingLocks`.
|
||||
`TargetedBy`/`TargetedByCount` are not lock facts and nothing new reads them.
|
||||
- Shared paths: the AI's vent rule (CombatState) and the player's HUD
|
||||
indicator read the same list: same ship, same information (ruling
|
||||
same-ship-same-inputs, finding reads-enemy-weapon-state).
|
||||
- Timing: an emitter that executes after the receiver in tick N is published at
|
||||
the receiver's tick N+1, so each publish window holds each emitter's one
|
||||
Execute; a warning lags its paint by at most one tick and never outlives it by
|
||||
more than one (LW4 is why: no stale flags).
|
||||
- Deletion line: none in this cut (additive); consumable-ai r2 deletes the
|
||||
`VisibleEnemies`/`LockWeapon.Lock` read at Combat.cs:147-148.
|
||||
|
||||
## Rationale
|
||||
|
||||
- Push from the beam, not a scan by the receiver. A receiver-side scan of
|
||||
`Zone.Entities` for lock weapons aimed at it would re-derive LockWeapon's arc
|
||||
test (two owners of "the beam is on you") or read a stored painting flag that
|
||||
goes stale offline (LW4). The push re-asserts the paint every tick it is true,
|
||||
in the code that already decides it, and the receiver owns only what it
|
||||
learns.
|
||||
- Why the receiver is still the owner: the warning is a fact about what this
|
||||
ship senses (ruling sensor-stat-set item 4), and a later receiver stat
|
||||
(passive listening gear, the sensor-stat-set cut) belongs here, not on the
|
||||
emitter.
|
||||
- Search versus track is not in this cut. The only emitter in the sim is the
|
||||
LockWeapon, a narrow track beam by nature (item 1). A Mode field would have
|
||||
one producer and no reader that could ever see the other value. The
|
||||
sensor-stat-set cut adds Mode when it lands the first wide search emitter.
|
||||
This cut is the first slice of follow-up sensor-stat-set-cut: the receiver
|
||||
half of item 4 for the emitter that exists. That follow-up stays open for
|
||||
power, beam width, intercept factor, passive listening, shared tracks and
|
||||
search mode.
|
||||
- What the ship learns about lock progress is a product fork: item 4 lists
|
||||
bearing, emitter class and search versus track, not progress. Question
|
||||
lock-warning-content. Recommendation `strength`; see the question.
|
||||
- Prior art for that fork (from general knowledge, no Eyes pass this time;
|
||||
confidence moderate): a real radar warning receiver shows emitter type,
|
||||
bearing, a relative signal strength (closeness and threat, not the shooter's
|
||||
lock progress) and mode (search, track, launch). Flight games mostly reduce
|
||||
this to stages: a "locking" tone, a "locked" tone, then a missile alert.
|
||||
A continuous lock-progress readout is a game abstraction, chosen for the
|
||||
reaction window it gives.
|
||||
- The music switch (LW1) is a separate instruments-bound leak and a separate
|
||||
product choice (gun-only enemies never paint), so it is a follow-up, not part
|
||||
of this cut.
|
||||
|
||||
## consumable-ai r2
|
||||
|
||||
- The vent rule reads `ship.IncomingLocks`: vent when any one warning's
|
||||
Strength passes .5 (the strongest, never a sum), before the `target == null`
|
||||
return so a ship painted by a locker it cannot see or has not targeted still
|
||||
vents. The unseen-locker test flips from "does not vent" to "vents".
|
||||
- `TryActivateConsumable` refuses on an inactive entity: the one path guards
|
||||
player and AI alike (finding deactivated-ship-spends-cargo).
|
||||
- The AI does not activate a consumable whose kind already has an active
|
||||
effect, Stackable or not (finding stackable-vent-drained-per-tick). The
|
||||
player may still stack by hand; `CanActivateConsumable` is unchanged.
|
||||
- Tests pin the vent edge at .49/.51, the arc at 14/16 degrees, and an optimum
|
||||
range distinct from the shortest gun's range (findings
|
||||
lock-and-arc-bands-unpinned, optimum-range-not-pinned).
|
||||
- Order: the consumable-only edits land on the branch at f2ecc96a, then Hands
|
||||
merges origin/master (with lock-warning merged), then rewrites the vent rule;
|
||||
Combat.cs is untouched by lock-warning, so its anchors read the same after
|
||||
the merge.
|
||||
|
||||
## Admitted (receipt mind-commit-35d69407...)
|
||||
|
||||
- cut_spec cut-lock-warning.r1 (base 08d064c8), question lock-warning-content
|
||||
(raised in it, recommended `strength`), cut_spec cut-consumable-ai.r2 (base
|
||||
f2ecc96a, depends_on lock-warning), resolution superseding consumable-ai r1,
|
||||
follow-ups music-reads-targeted-count and lock-warning-audio-cue.
|
||||
- context-pack: lock-warning 9/9 anchors SAME at 08d064c8; consumable-ai r2
|
||||
12/12 SAME at f2ecc96a (temporary detached worktrees, removed).
|
||||
|
|
|
|||
131
docs/research/thrust-vectoring-prior-art.md
Normal file
131
docs/research/thrust-vectoring-prior-art.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# Prior art: control allocation with tilting / gimballed thrusters
|
||||
|
||||
Serves the fork: vectored thruster rows in Aetheria (follow-ups `aetheria-release:follow_up:vectored-thruster-rows`, `aetheria-release:follow_up:gimballed-thrusters`). A thruster row tilts its thrust vector within a gimbal limit at a slew rate; the thrust allocator chooses the tilt; one gimbal per row, not per thruster.
|
||||
|
||||
Eyes pass, 2026-10-10, session self-2026-10-10-ag. Facts with evidence pointers. No recommendation. Anything inferred is marked "(inferred)". Items marked [not retrieved] were seen only through search snippets or citations in other papers, not read in full.
|
||||
|
||||
## 0. The allocator today (the thing the prior art is compared against)
|
||||
|
||||
Source: `Assets/Scripts/ServerShared/ThrustAllocator.cs` at HEAD `1fc98bdc` of branch `eureka/aetheria-release-thrust-allocator-core` (read via `git show`).
|
||||
|
||||
- Input: `ReadOnlySpan<float3> columns` (x starboard acceleration, y forward acceleration, z clockwise yaw rate per unit throttle), `float2 move`, `float turn`. Output: one throttle per thruster in [0,1].
|
||||
- One `BoundedLeastSquares.Solve` per call over n throttles plus 4 error variables (unwanted/missing translation per half-axis, linear cost). Yaw row weight 1000, translation rows weight 100, ridge 0.1 on each throttle. Variable bounds: throttles in [0,1], errors in [0, 2].
|
||||
- Columns are fixed inputs: the allocation matrix is constant with respect to the unknowns, so the problem is linear in the throttles.
|
||||
|
||||
## 1. Question (a): standard formulations for allocation with tilting actuators
|
||||
|
||||
### 1.1 Virtual actuators: force components as the linear unknowns, angle and magnitude recovered afterwards
|
||||
|
||||
- Voliro hexacopter (Kamel, Verling et al., ETH, arXiv 1801.04581, section "D. Control allocation"). The allocation matrix is nonlinear in the tilt angles. The paper states a nonlinear least squares solve was judged too costly on the onboard computer and proposes "a variable transformation": each rotor's force is split into a vertical and a lateral component, F_v,i = c n_i^2 cos(alpha_i) and F_l,i = c n_i^2 sin(alpha_i) (eqs 16-17). The 12-vector of components maps linearly to the wrench by a constant matrix, solved by pseudo-inverse. Recovery: n_i^2 = (1/c) sqrt(F_v,i^2 + F_l,i^2) (eq 22), alpha_i = atan2(F_l,i, F_v,i) (eq 23). The paper reports several hundred Hz on a Pixhawk.
|
||||
- Same technique in the marine literature, named "extended thrust configuration" (Sørdalen 1997, Control Engineering Practice 5(9):1223-1231, [not retrieved]; described in Mukwege/Nguyen/Garone arXiv 2510.08119 intro and in Maneuvering-based Dynamic Thrust Allocation, arXiv 2507.18309, section 2: "separating the force components and using the rectangular configuration matrix is convenient as this leads to a constant configuration matrix"). In 2507.18309 fixed-direction thrusters stay in polar form (magnitude) while azimuth thrusters use rectangular components, in one allocation.
|
||||
- Mukwege/Nguyen/Garone (arXiv 2510.08119, sections 2-4) write the whole problem as d = M F with F in R^(3m) (three force components per thruster, M constant 6 x 3m) and treat the orientation as F_j / |F_j|.
|
||||
|
||||
### 1.2 Nonlinear allocation, solved by sequential local linearisation
|
||||
|
||||
- Johansen, Fossen, Berge, "Constrained nonlinear control allocation with singularity avoidance using sequential quadratic programming", IEEE TCST 12(1):211-216, 2004 [not retrieved; abstract and the description in 2510.08119 section 1.1 and 4]. Each sample: a convex QP approximating the nonlinear program; actuator rate and position constraints; a singularity penalty on 1/det(J(alpha) J(alpha)^T) added to the cost.
|
||||
- Per 2510.08119 section 4, the penalty (i) does not work when thrusters are fewer than the degrees of freedom (det is identically zero), and (ii) gives no systematic behaviour around zero demand d = 0. The same paper names the equality constraint d = J(alpha) T and the penalty as the source of non-convexity.
|
||||
- Ryll/Cuniato line and the ETH Voliro follow-ups [not retrieved in full]: a search result describes Cuniato et al. (IEEE T-RO) as a differential allocation including servo and propeller dynamics, claimed free of singularities.
|
||||
|
||||
### 1.3 Sequential / cascaded (forces first, then angles)
|
||||
|
||||
- TU Delft thesis on dual-axis tilt-rotor quadplanes (repository.tudelft.nl, search snippet only) reports a nonlinear SQP allocator and states that a cascaded scheme (linear then angular accelerations) was chosen over a combined one because of large roll and pitch changes. [not retrieved in full]
|
||||
- A 2021 tilt-rotor quadrotor paper (search snippet, not retrieved) splits the effectiveness matrix into two parts (yaw torque and forward velocity first, then all dynamics) and iterates to convergence.
|
||||
- Voliro's solution (1.1) is itself two-stage: linear solve for components, then closed-form angle recovery.
|
||||
|
||||
### 1.4 Rate and gimbal limits entering the allocation
|
||||
|
||||
- Per-tick bounds from current state and rate (box on the actuator variable): Tiltrotor NMPC (Nguyen/Garone et al., arXiv 2406.06130, section 4.1): "we use the current state of actuators and their rate of change to update u_min and u_max" every sample, and constrain the controller so its virtual command lies in the image h(u) of the admissible actuator box. There the unknowns are thrust and tilt angle themselves (u = thrust_i, tilt_i) with the rate-updated box; the paper's allocator in its experiments is a pseudo-inverse.
|
||||
- Mukwege et al. 2510.08119 section 4: thrust saturation as F_i magnitude bound, thrust rate as |F_i - F_i0| <= bound reusing the previous solution F_i0, pointing and angular-rate constraints as convex cones C_i with F_i in C_i (conic combination of 3 vectors, or half-space form A F <= b). Resulting problem class: QCQP at most. For real time it states thrust saturation can be rewritten as three box constraints per actuator (F_x, F_y, F_z min/max), "at the cost of extra conservativeness".
|
||||
- Marine DP (MARIN, search snippet [not retrieved]): physical limits modelled are saturation, azimuth turn rate and RPM change rate, solved with SQP.
|
||||
- Maneuvering-based Dynamic Thrust Allocation (arXiv 2507.18309, abstract and section 3.4): a nonlinear reference filter driven by a control Lyapunov/barrier function tracks the optimal allocation with rate limitation of the thruster forces; the optimal allocation itself is computed unconstrained in the manifold p + Q*lambda (particular solution plus nullspace).
|
||||
- Härkegård and active-set methods (Bodson & Frost, NASA, quoted in a search result [not retrieved]): Härkegård's active-set algorithms for constrained least-squares allocation; "Resolving actuator redundancy - optimal control vs. control allocation", Automatica 41(1):137-144, 2005 (bibliographic fact only, [not retrieved]). Dynamic allocation with rate limits as constraint on the increment is a standard formulation of this literature (inferred from the 2406.06130 and 2510.08119 descriptions; the Härkegård paper was not read).
|
||||
- Voliro paper states the limitation of the linear trick directly (section IV-C text near eq 23): "the tilting angles and the angular velocities are nonlinearly coupled. Therefore, it is difficult to constrain them separately. This means, that this allocation doesn't account for the slower dynamics of the tilting angles." Example given: hovering at 90 deg roll, two rotors on the vertical axis must "constantly rotate 180 back and forth at a high rate"; tilt servos cannot follow, so forces and moments appear in directions not commanded. The authors state this motivated a further step (text beyond the retrieved excerpt).
|
||||
|
||||
## 2. Question (b): virtual-actuator trick inside a least-squares / QP allocator
|
||||
|
||||
### Bound shapes
|
||||
|
||||
- A real tilt limit is a cone (3D) or a wedge (2D) on the force vector: |F_lateral| <= tan(theta_max) * F_vertical, F_vertical >= 0. 2510.08119 section 4 states pointing constraints are "convex cones C_i", yielding at most a QCQP; the linear-programming-friendly approximation is a box on each component (eqs 40-42), explicitly conservative.
|
||||
- In a 2D plane with a one-sided (non-reversible) thruster, the cone degenerates to two half-spaces on (F_v, F_l) (inferred from the cone statement above; Aetheria's columns are planar and throttles are in [0,1]). Whether `BoundedLeastSquares` accepts general linear inequality constraints or only variable box bounds was not examined by this pass; the allocator file shows only variable bounds [lo, hi].
|
||||
- Marine azimuth with a full circle: the Valčić/Prpić-Oršić result is that removing a forbidden sector from the azimuth circle leaves a non-convex feasible region ("Pacman shape") causing large azimuth swings and slower allocation (CROSBI 899099 and related entries, search results only).
|
||||
|
||||
### Conditioning
|
||||
|
||||
- Mukwege et al. 2510.08119 section 1 and 3 state that pseudo-inverse allocations are "sensible to the singularities" and produce "discontinuous actuator orientations references"; because servos have finite turning rate, crossing a singularity leaves "a period of time where the system is not able to fulfill the demands." They note singular configurations are "also usually related to energy-efficient configurations."
|
||||
- The linear virtual formulation raises the number of unknowns from one scalar per actuator to two or three (Voliro: 12 variables for 6 rotors). Over-actuation (more unknowns than DOF) means the solution is not unique (Voliro section D; 2406.06130 section 1 mention of ill-conditioned effectiveness matrix).
|
||||
- 2510.08119 adds a rest-configuration nullspace term to the convex cost (q1 b(d) K_b^T F - q2 ||...||^2, eq 38) which they say preserves convexity and keeps a minimum force on each actuator; they present it as the fix for the orientation discontinuity.
|
||||
|
||||
### Warm start
|
||||
|
||||
- No source retrieved states warm-start behaviour of a bounded least-squares/QP solver specifically under virtual-actuator allocation. 2510.08119 section 4 states the rate constraint reuses the previous solution F_i0 as the centre of the rate box (so the previous solution is part of the constraint, not only an initial guess). Voliro: the pseudo-inverse is memoryless. Orbiter forum simplex thread (section 4): solve time 0-15 ms for 22 thrusters, no warm-start detail.
|
||||
|
||||
### Singularity near zero thrust
|
||||
|
||||
- Angle atan2(F_l, F_v) is undefined at F = 0 and discontinuous near it: Voliro eq 23; 2510.08119 intro and section 3 (Lipschitz constant of the orientation is bounded only if |F_j| stays above a minimum, their condition C2: |F_j + s_j(d)| >= epsilon_2, enforced by the nullspace smoothing term).
|
||||
- 2510.08119 section 5 numerical examples (marine vessel with 3 azimuth thrusters; quadcopter) report orientation jumps when the pseudo-inverse solution crosses the singular point, and smoothing removing them. Their Figure 4 text: "the smoothing action appears when a discontinuity in azimuth orientation is close."
|
||||
- The plain virtual-actuator formulation has the zero-thrust ambiguity because the unknown F carries the angle implicitly; the output angle command is a function of the solution. (Inferred from eqs 22-23 plus the above.)
|
||||
|
||||
## 3. Question (c): games
|
||||
|
||||
Retrieval for games was by web search of wiki and forum pages; no engine source code was read.
|
||||
|
||||
- Kerbal Space Program: engines have a `ModuleGimbal` with configurable range (typically a few degrees; kerbal.wiki Part_modules example LV-909 shows about 4 degrees each way in pitch and yaw), per-axis response toggles (kOS docs: gimbal responds to pitch, yaw, roll controls separately; LIMIT suffix; LOCK snaps to zero), a response-speed field whose effect is unclear (a forum thread, topic 46553, reports no observable difference between speeds 1 and 100; a reply says the speed field is used only when `useGimbalResponseSpeed` is true). Roll participation of off-axis engines was added in 0.24 (forum summary). Gimbal acts only while the engine burns; RCS and reaction wheels cover the rest (forum posts). KerbCom Avionics mod added a solver balancing engine gimbals, reaction wheels and RCS (forum announcement; algorithm not retrieved). KSP's own internal allocation was not found in any retrieved source.
|
||||
- Space Engineers: without mods, thrust is applied through the grid's centre of mass so offset thrusters make no torque (Keen support thread "Realistic Thrusters // Thruster Torque & Thrust Allocation", forum answers). Vectoring is done by mounting thrusters on rotors/sub-grids and scripting (Whip's, Vector Thrust OS named by players). The "Realistic Thrusters" mod/suggestion is the only torque-from-offset treatment found.
|
||||
- Avorion: Directional Thruster block with nozzles on two opposing sides, aimed by rotating the block, affecting only that axis, 2.5x thrust along it (wiki; players report 3x); regular thrusters have nozzles on all six sides; thruster output scales against ship mass; turning authority comes from distance to centre of mass (wiki, developer post). No continuous gimbal found.
|
||||
- Starsector: movement stats only (wiki: acceleration, turn acceleration, max turn rate as hull-mod multipliers); flux does not gate movement. No thrust allocation.
|
||||
- Children of a Dead Earth: store/feature text says moment of inertia, delta-v and acceleration are computed; searches found no description of gimbals or RCS allocation. Nothing established.
|
||||
- Orbiter (simulator, not shipped game with gimbals evidence): forum "Simplex-based thruster control" (thread 25232) describes an RCS class using the simplex method on arbitrary thruster layouts "no groups", simultaneous rotation and translation, commands scaled linearly when out of reach; 0-15 ms on 22 thrusters; occasional instability with unrealistically large torques; no mention of gimbals. ESA ATV uses simplex-based jet selection precomputed into a lookup table (search result [not retrieved]).
|
||||
- No retrieved source describes a shipped game doing a physically allocated vectored thrust with an optimiser. (A negative result of limited searching, not proof of absence.)
|
||||
|
||||
## 4. Question (d): shared-angle actuators (one gimbal per row)
|
||||
|
||||
- No retrieved source formulates allocation for several thrusters sharing one tilt angle with individual magnitudes. This was searched directly (control allocation, shared tilt, common tilt, group of thrusters one gimbal) and returned nothing matching.
|
||||
- Nearest found: TU Delft thesis snippet on a quadcopter with a single gimbal-style common tilt (Zheng et al.): two extra servos and spherical joints tilt the whole rotor set so the combined thrust vector is steerable and thrust is attitude-independent. The retrieved excerpt does not describe the allocation. [not retrieved in full]
|
||||
- Structure of the problem as stated by the established formulations (inferred): with one common angle alpha per row, row member i produces force t_i (cos alpha, sin alpha). In virtual-actuator terms every member's component pair (F_v,i, F_l,i) must have the same ratio tan(alpha). The linear-component trick's advantage (angle free per actuator, constant matrix) is lost because the ratio equality across members is bilinear in (t_i, alpha); the formulation with per-thruster free components would overcount freedom. If the row's members have equal nominal directions, the row's total force and torque are (sum of t_i) and (sum of t_i * lever_i) (inferred), which makes the row's angle a single nonlinear variable shared by several linear throttles.
|
||||
- Related structural facts in sources: 2507.18309 section 2 mixes fixed-direction thrusters (polar form, magnitude only) and azimuth thrusters (rectangular form, two components) in one allocation; this covers per-thruster angle plus fixed thrusters, not shared angle.
|
||||
- Sequential treatment of coupled problems exists in the literature: the tilt-rotor papers' cascaded or iterated split (1.3). A row-shared angle solved as an outer scalar search with an inner bounded linear solve is not described in any retrieved source (inferred as one possible shape; no source).
|
||||
|
||||
## 5. Converged / disagrees
|
||||
|
||||
Converged (stated by two or more retrieved sources):
|
||||
|
||||
1. Treating each tilting actuator as 2 or 3 linear force components with a constant allocation matrix, then recovering magnitude and angle by norm and atan2, is a standard approach: Voliro 1801.04581 (eq 16-23), Sørdalen extended thrust as cited in 2510.08119 and 2507.18309, Mukwege 2510.08119 (F in R^(3m)).
|
||||
2. The recovered angle is discontinuous or undefined near zero component magnitude: Voliro (eq 23 and the 90 degree roll example), 2510.08119 (intro, section 3, section 5), 2507.18309 (azimuth penalty motivation).
|
||||
3. Servo slowness is not represented by the linear trick alone: Voliro (explicit statement) and 2510.08119 (finite turning rate discussion) agree; solutions in the sources add a rate constraint (2510.08119 section 4), a rate-updated bound (2406.06130 section 4.1), a nullspace smoothing term (2510.08119), or a rate-limited reference filter (2507.18309).
|
||||
4. Rate limits enter as per-sample bounds centred on the previous actuator state or previous solution: 2406.06130 (u_min/u_max updated from current state and rate), 2510.08119 (|F_i - F_i0| <= bound).
|
||||
5. Tilt or pointing limits are cones in force space and convex; box approximations of the components are the cheap, conservative form: 2510.08119 (cone C_i, eqs 40-42).
|
||||
6. Redundancy yields non-unique solutions chosen by a secondary objective (energy, nullspace, rest configuration): Voliro, 2510.08119, 2507.18309, 2406.06130.
|
||||
|
||||
Disagrees / differs across sources:
|
||||
|
||||
1. Linear vs nonlinear: Voliro rejects nonlinear least squares for compute cost; the marine SQP line (Johansen et al. 2004, MARIN) accepts a local QP per sample with a singularity penalty; 2510.08119 states the penalty fails when the thruster count is below the DOF count and around d = 0.
|
||||
2. Where limits live: 2406.06130 puts the actuator box in the controller (NMPC) and uses a plain pseudo-inverse allocator; 2510.08119 puts constraints in the allocator QP/QCQP; 2507.18309 puts rate limitation in a downstream reference filter.
|
||||
3. Singularity handling: penalty on det(J J^T) (Johansen 2004) vs nullspace rest configuration (2510.08119) vs differential allocation avoiding the algebraic map (Cuniato, snippet only) vs forbidden-zone removal (marine; Valčić says it causes non-convexity and large swings and proposes modelling interaction as losses instead).
|
||||
4. Solvers: simplex/LP with scaling of out-of-reach commands (Orbiter thread) vs QP/active set (Härkegård, Bodson) vs SQP (Johansen, TU Delft) vs closed-form pseudo-inverse (Voliro, 2406.06130).
|
||||
5. Games: Space Engineers applies thrust through the centre of mass by default (no torque from offset); Orbiter and the ATV approach allocate by simplex from true positions; KSP gimbals are small-range controls fed by control inputs per axis. They do not agree on whether positional torque is physically computed.
|
||||
|
||||
## 6. Sources
|
||||
|
||||
Read in full or in relevant part (text extracted):
|
||||
- Kamel, Verling et al., "Voliro: An Omnidirectional Hexacopter With Tiltable Rotors", arXiv 1801.04581. https://arxiv.org/pdf/1801.04581
|
||||
- Mukwege, Nguyen, Garone, "General formulation of an analytic and Lipschitz continuous control allocation for thrust-vectored controlled rigid-bodies", arXiv 2510.08119v3 (Apr 2026). https://arxiv.org/pdf/2510.08119
|
||||
- Maneuvering-based Dynamic Thrust Allocation for Fully-Actuated Vessels, arXiv 2507.18309. https://arxiv.org/pdf/2507.18309
|
||||
- Nonlinear Model Predictive Control of Tiltrotor Quadrotors with Feasible Control Allocation, arXiv 2406.06130. https://arxiv.org/abs/2406.06130
|
||||
- Sorge, Ciresola, Michieletto, Cenedese, "Dynamic Control Allocation for Dual-Tilt UAV Platforms", arXiv 2604.05677 (text extracted, only keyword-checked; no claim above rests on it). https://arxiv.org/pdf/2604.05677
|
||||
- Constrained Dynamic Control Allocation in the Presence of Singularity and Infeasible Solutions, arXiv 1607.05209 (abstract only). https://arxiv.org/abs/1607.05209
|
||||
- Aetheria ThrustAllocator.cs, branch eureka/aetheria-release-thrust-allocator-core, commit 1fc98bdc.
|
||||
|
||||
Cited, not retrieved (bibliographic or snippet only):
|
||||
- Johansen & Fossen, "Control allocation - A survey", Automatica 49(5):1087-1103, 2013, DOI 10.1016/j.automatica.2013.01.035. Full text not found (NTNU URLs returned HTML stubs).
|
||||
- Johansen, Fossen, Berge, IEEE TCST 12(1):211-216, 2004.
|
||||
- Sørdalen, "Optimal thrust allocation for marine vessels", Control Engineering Practice 5(9):1223-1231, 1997.
|
||||
- Härkegård & Glad, "Resolving actuator redundancy - optimal control vs. control allocation", Automatica 41(1):137-144, 2005; Härkegård active-set allocation as cited in Bodson & Frost (NASA NTRS 20100024149 appeared in search results).
|
||||
- Cuniato et al., IEEE T-RO, differential allocation for tiltable-rotor platforms (search summary).
|
||||
- Valčić & Prpić-Oršić, forbidden-zone handling in thrust allocation (CROSBI 838045, 899099).
|
||||
- MARIN, "An advanced thrust allocation algorithm for DP applications" (search result).
|
||||
- TU Delft thesis on dual-axis tilt-rotor quadplanes, https://repository.tudelft.nl/file/File_3e64f087-21b4-458c-8fc6-6ea6559a1bbe
|
||||
- KSP: https://kerbal.wiki/index.php/Part_modules, https://forum.kerbalspaceprogram.com/topic/46553-engine-gimbal-reaction-speed, https://ksp-kos.readthedocs.io/en/latest/structures/vessels/gimbal.html (the wiki Gimbal page was blocked by bot protection).
|
||||
- Space Engineers: https://support.keenswh.com/spaceengineers/pc/topic/45350-realistic-thrusters-thruster-torque-thrust-allocation
|
||||
- Avorion: https://avorion.fandom.com/wiki/Directional_Thruster
|
||||
- Starsector: https://starsector.wiki.gg/wiki/Auxiliary_Thrusters
|
||||
- Orbiter: https://orbiter-forum.com/threads/simplex-based-thruster-control.25232
|
||||
|
|
@ -11,6 +11,8 @@
|
|||
<!-- AuthoredSettings (Unity Settings.asset YAML reader), so fixtures load the shipped gameplay settings
|
||||
instead of hand-copying them. -->
|
||||
<ProjectReference Include="..\..\tools\AetherDb\AetherDb.csproj" />
|
||||
<!-- The player build's staging rules: Unity-free, owned by Assets/Editor, compiled here so the suite can prove them. -->
|
||||
<Compile Include="..\..\Assets\Editor\PlayerStaging.cs" Link="PlayerStaging.cs" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
|
|
|
|||
|
|
@ -116,7 +116,10 @@ public sealed class AetheriaStoresTests : IDisposable
|
|||
public void MissingCatalogGlobalIsLoud()
|
||||
{
|
||||
var empty = Path.Combine(_root, "empty.cc");
|
||||
using (AetheriaStores.Open(empty, catalogWritable: true)) { }
|
||||
// A catalog file that exists but lacks the global: the read-only open reaches the global check.
|
||||
File.Copy(Catalog, empty);
|
||||
using (var seed = AetheriaStores.Open(empty, catalogWritable: true))
|
||||
Assert.True(seed.Commit(batch => batch.Remove(seed.RefOf(seed.GetGlobal<TestCatalogGlobal>()).Key)));
|
||||
var error = Assert.Throws<InvalidOperationException>(() => AetheriaStores.Open(empty));
|
||||
Assert.Contains("aetheria.tests.catalogglobal", error.Message);
|
||||
using (AetheriaStores.Open(Catalog)) { }
|
||||
|
|
@ -200,6 +203,23 @@ public sealed class AetheriaStoresTests : IDisposable
|
|||
Assert.Null(record);
|
||||
}
|
||||
|
||||
// A shipped player with no catalog must not boot into an empty game: a read-only open of a missing path throws
|
||||
// naming the path, and does not create the file. A writable open still seeds an empty catalog.
|
||||
[Fact]
|
||||
public void AStoreRefusesAMissingCatalog()
|
||||
{
|
||||
var missing = Path.Combine(_root, "absent", "Aetheria.cc");
|
||||
|
||||
// FileNotFoundException, not the catalog-global check: that one also throws on a missing file whenever a global is registered.
|
||||
var error = Assert.Throws<FileNotFoundException>(() => AetheriaStores.Open(missing));
|
||||
Assert.Contains(missing, error.Message);
|
||||
Assert.False(File.Exists(missing));
|
||||
Assert.False(Directory.Exists(Path.GetDirectoryName(missing)));
|
||||
|
||||
using (var seeded = AetheriaStores.Open(missing, catalogWritable: true))
|
||||
Assert.Empty(seeded.GetAll<Faction>());
|
||||
}
|
||||
|
||||
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
|
||||
|
||||
private static string[] SchemaNames(string path)
|
||||
|
|
|
|||
|
|
@ -475,7 +475,7 @@ public sealed partial class FireControlCut124Tests : IDisposable
|
|||
public void AGrazingBlastAtAShippedHullsFarthestCornerLands(string hullName)
|
||||
{
|
||||
Shape shape;
|
||||
var cache = OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), out _);
|
||||
var cache = OpenReadOnlyRealCatalog(TestCatalog.Repo, out _);
|
||||
try { shape = cache.GetByName<HullData>(hullName).Shape; }
|
||||
finally { cache.Dispose(); }
|
||||
|
||||
|
|
@ -2573,9 +2573,7 @@ public sealed partial class FireControlCut124Tests : IDisposable
|
|||
|
||||
private static CultCache OpenReadOnlyRealCatalog(string catalogPath, out SingleFileMessagePackBackingStore store)
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
|
||||
.Where(t => t is { IsAbstract: false, IsInterface: false })
|
||||
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null));
|
||||
var registry = TestCatalog.Registry();
|
||||
var cache = new CultCache(registry);
|
||||
store = new SingleFileMessagePackBackingStore(catalogPath, true);
|
||||
cache.AddBackingStore(store, AetheriaStores.CatalogTypes);
|
||||
|
|
@ -2588,7 +2586,7 @@ public sealed partial class FireControlCut124Tests : IDisposable
|
|||
[Fact]
|
||||
public void ShippedCatalogIsNeutralOnBlastRadiusAndFuse()
|
||||
{
|
||||
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var gameData = TestCatalog.Repo;
|
||||
var cache = OpenReadOnlyRealCatalog(gameData, out var store);
|
||||
try
|
||||
{
|
||||
|
|
@ -2621,7 +2619,7 @@ public sealed partial class FireControlCut124Tests : IDisposable
|
|||
[Fact]
|
||||
public void WeaponFuseAndBlastRadiusRoundTripThroughAWrittenCatalog()
|
||||
{
|
||||
var src = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var src = TestCatalog.Repo;
|
||||
var fuseValues = new WeaponFuse?[] { null, WeaponFuse.Contact, WeaponFuse.Proximity, WeaponFuse.Delayed };
|
||||
var radiusValues = new float?[] { null, 0f, 12.5f };
|
||||
|
||||
|
|
@ -2636,9 +2634,7 @@ public sealed partial class FireControlCut124Tests : IDisposable
|
|||
var cache = OpenReadOnlyRealCatalog(tmp, out _);
|
||||
cache.Dispose();
|
||||
}
|
||||
var writeCache = new CultCache(CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
|
||||
.Where(t => t is { IsAbstract: false, IsInterface: false })
|
||||
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null)));
|
||||
var writeCache = new CultCache(TestCatalog.Registry());
|
||||
writeCache.AddBackingStore(new SingleFileMessagePackBackingStore(tmp, false), AetheriaStores.CatalogTypes);
|
||||
var w = writeCache.GetAll<WeaponItemData>().OrderBy(x => x.Name).First();
|
||||
w.Fuse = fuse;
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ public sealed class FireControlCut7Tests : IDisposable
|
|||
[Fact]
|
||||
public void ShippedCatalogOpensAndGeneratesAnArmedHull()
|
||||
{
|
||||
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var gameData = TestCatalog.Repo;
|
||||
using var cache = OpenReadOnlyRealCatalog(gameData);
|
||||
|
||||
// Force every EquippableItemData -- WeaponItemData included, the type the broken merge could not
|
||||
|
|
@ -255,9 +255,7 @@ public sealed class FireControlCut7Tests : IDisposable
|
|||
// validation loop -- without the test assembly's own fixture type poisoning it.
|
||||
private static CultCache OpenReadOnlyRealCatalog(string catalogPath)
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
|
||||
.Where(t => t is { IsAbstract: false, IsInterface: false })
|
||||
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null));
|
||||
var registry = TestCatalog.Registry();
|
||||
var cache = new CultCache(registry);
|
||||
try
|
||||
{
|
||||
|
|
|
|||
|
|
@ -761,9 +761,7 @@ public sealed partial class MiningCut3Tests : IDisposable
|
|||
// would otherwise be demanded of a catalog that was never authored with it.
|
||||
private static CultCache OpenShippedCatalogCopy(string catalogPath, string runPath)
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
|
||||
.Where(t => t is { IsAbstract: false, IsInterface: false })
|
||||
.Where(t => t.GetCustomAttributes(typeof(CultDocumentAttribute), false).Length > 0));
|
||||
var registry = TestCatalog.Registry();
|
||||
var cache = new CultCache(registry);
|
||||
cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(catalogPath, false), AetheriaStores.CatalogTypes);
|
||||
cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(runPath), AetheriaStores.RunTypes);
|
||||
|
|
@ -776,7 +774,7 @@ public sealed partial class MiningCut3Tests : IDisposable
|
|||
[Fact]
|
||||
public void GenerationChoosesTheKindByTheSameFunction()
|
||||
{
|
||||
var shipped = Path.Combine(RestoredHullsTests.FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var shipped = TestCatalog.Repo;
|
||||
var belts = 0;
|
||||
for (uint seed = 1; seed <= 25 && belts < 3; seed++)
|
||||
{
|
||||
|
|
@ -1043,7 +1041,7 @@ public sealed partial class MiningCut3Tests : IDisposable
|
|||
var dir = Path.Combine(_root, "shipped");
|
||||
Directory.CreateDirectory(dir);
|
||||
var catalog = Path.Combine(dir, "Aetheria.cc");
|
||||
File.Copy(Path.Combine(RestoredHullsTests.FindRepoRoot(), "GameData", "Aetheria.cc"), catalog);
|
||||
File.Copy(TestCatalog.Repo, catalog);
|
||||
using var cache = OpenShippedCatalogCopy(catalog, Path.Combine(dir, "run.cc"));
|
||||
Assert.Contains(cache.GetAll<FieldKindData>(), kind => kind.GenerationWeight > 0f);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,9 +25,7 @@ public sealed class PiratesFactionCommandTests : IDisposable
|
|||
// AetherDb.Open composes the process-wide registry, which in this test host also sees this assembly's TestCatalogGlobal
|
||||
// (see FireControlCut7Tests). So the copy is stripped through a registry scoped to the shipped assembly and then given
|
||||
// the record that registry's shared sibling demands, as AetheriaStoresTests does.
|
||||
var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
|
||||
.Where(t => t is { IsAbstract: false, IsInterface: false })
|
||||
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null));
|
||||
var registry = TestCatalog.Registry();
|
||||
using (var cache = new CultCache(registry))
|
||||
{
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
|
||||
|
|
|
|||
101
tests/Aetheria.Shared.Tests/PlayerStagingTests.cs
Normal file
101
tests/Aetheria.Shared.Tests/PlayerStagingTests.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
// A player directory's GameData is exactly the catalog, Narrative/** and (when the repo has them) Mods/**.
|
||||
public sealed class PlayerStagingTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-staging-" + Guid.NewGuid().ToString("N"));
|
||||
private string Repo => Path.Combine(_root, "repo");
|
||||
private string Player => Path.Combine(_root, "player");
|
||||
private string Source(string relative) => Path.Combine(Repo, "GameData", relative);
|
||||
private string Staged(string relative) => Path.Combine(Player, "GameData", relative);
|
||||
|
||||
public PlayerStagingTests()
|
||||
{
|
||||
Write(Source("Aetheria.cc"), "catalog-bytes");
|
||||
Write(Source("Narrative/Main.ink"), "main");
|
||||
Write(Source("Narrative/Quests/Deep/First.ink"), "first");
|
||||
Write(Source("SoundbanksInfo.json"), "{}");
|
||||
Write(Source("run.cc"), "a developer's run");
|
||||
Write(Source("player.cc"), "a developer's settings");
|
||||
Directory.CreateDirectory(Player);
|
||||
}
|
||||
|
||||
public void Dispose() => Directory.Delete(_root, true);
|
||||
|
||||
[Fact]
|
||||
public void StagesTheCatalogAndNarrativeFileForFile()
|
||||
{
|
||||
PlayerStaging.Stage(Repo, Player);
|
||||
|
||||
Assert.Equal("catalog-bytes", File.ReadAllText(Staged("Aetheria.cc")));
|
||||
Assert.Equal("main", File.ReadAllText(Staged("Narrative/Main.ink")));
|
||||
Assert.Equal("first", File.ReadAllText(Staged("Narrative/Quests/Deep/First.ink")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StagesModsWhenTheRepoHasThemAndNoModsFolderWhenItDoesNot()
|
||||
{
|
||||
PlayerStaging.Stage(Repo, Player);
|
||||
Assert.False(Directory.Exists(Staged("Mods")));
|
||||
|
||||
Write(Source("Mods/ship-a/ship.cc"), "ship");
|
||||
Write(Source("Mods/ship-a/mesh/hull.glb"), "hull");
|
||||
PlayerStaging.Stage(Repo, Player);
|
||||
|
||||
Assert.Equal("ship", File.ReadAllText(Staged("Mods/ship-a/ship.cc")));
|
||||
Assert.Equal("hull", File.ReadAllText(Staged("Mods/ship-a/mesh/hull.glb")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StagesNothingElseFromGameData()
|
||||
{
|
||||
PlayerStaging.Stage(Repo, Player);
|
||||
|
||||
var staged = Directory.GetFileSystemEntries(Path.Combine(Player, "GameData")).Select(Path.GetFileName).OrderBy(name => name).ToArray();
|
||||
Assert.Equal(new[] { "Aetheria.cc", "Narrative" }, staged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplacesWhatAnEarlierBuildLeftInGameData()
|
||||
{
|
||||
Write(Staged("Aetheria.cc"), "stale");
|
||||
Write(Staged("run.cc"), "a played run");
|
||||
Write(Staged("Mods/gone/ship.cc"), "removed mod");
|
||||
|
||||
PlayerStaging.Stage(Repo, Player);
|
||||
|
||||
Assert.Equal("catalog-bytes", File.ReadAllText(Staged("Aetheria.cc")));
|
||||
Assert.False(File.Exists(Staged("run.cc")));
|
||||
Assert.False(Directory.Exists(Staged("Mods")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesAMissingCatalogNamingItAndLeavesNoGameData()
|
||||
{
|
||||
File.Delete(Source("Aetheria.cc"));
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() => PlayerStaging.Stage(Repo, Player));
|
||||
|
||||
Assert.Contains("GameData/Aetheria.cc", error.Message);
|
||||
Assert.False(Directory.Exists(Path.Combine(Player, "GameData")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesMissingNarrative()
|
||||
{
|
||||
Directory.Delete(Source("Narrative"), true);
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() => PlayerStaging.Stage(Repo, Player));
|
||||
|
||||
Assert.Contains("GameData/Narrative", error.Message);
|
||||
}
|
||||
|
||||
private static void Write(string path, string text)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||
File.WriteAllText(path, text);
|
||||
}
|
||||
}
|
||||
|
|
@ -38,9 +38,7 @@ public sealed class RestoredHullsTests
|
|||
// real catalog's validation the way the shared, AppDomain-scanning CultCache() default would.
|
||||
private static CultCache OpenReadOnlyRealCatalog(string catalogPath)
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
|
||||
.Where(t => t is { IsAbstract: false, IsInterface: false })
|
||||
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null));
|
||||
var registry = TestCatalog.Registry();
|
||||
var cache = new CultCache(registry);
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, true), AetheriaStores.CatalogTypes);
|
||||
return cache;
|
||||
|
|
@ -54,7 +52,7 @@ public sealed class RestoredHullsTests
|
|||
throw new DirectoryNotFoundException("Run from inside the Aetheria repository.");
|
||||
}
|
||||
|
||||
internal static CultCache OpenCatalog() => OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"));
|
||||
internal static CultCache OpenCatalog() => OpenReadOnlyRealCatalog(TestCatalog.Repo);
|
||||
|
||||
// Shared tutorial-galaxy construction settings (Assets/Resources/Settings.asset's own values), factored out
|
||||
// so every test that needs a real tutorial Galaxy -- not just EntranceZoneAlwaysGetsADockedStationAcrossSeeds
|
||||
|
|
@ -140,7 +138,7 @@ public sealed class RestoredHullsTests
|
|||
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-nolagrange-scan-{Guid.NewGuid():N}.cc");
|
||||
try
|
||||
{
|
||||
using var cache = OpenReadOnlyRealCatalogWithScratchRun(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), scratchRun);
|
||||
using var cache = OpenReadOnlyRealCatalogWithScratchRun(TestCatalog.Repo, scratchRun);
|
||||
return ScanForNoLagrangeCandidateZone(cache);
|
||||
}
|
||||
finally
|
||||
|
|
@ -170,9 +168,7 @@ public sealed class RestoredHullsTests
|
|||
// catalog-only cache has no home for them. A scratch run file, deleted after the seed that used it.
|
||||
private static CultCache OpenReadOnlyRealCatalogWithScratchRun(string catalogPath, string runPath)
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
|
||||
.Where(t => t is { IsAbstract: false, IsInterface: false })
|
||||
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null));
|
||||
var registry = TestCatalog.Registry();
|
||||
var cache = new CultCache(registry);
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, true), AetheriaStores.CatalogTypes);
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(runPath), AetheriaStores.RunTypes);
|
||||
|
|
@ -357,7 +353,7 @@ public sealed class RestoredHullsTests
|
|||
var tutorialSettings = TutorialGalaxySettings();
|
||||
var background = TutorialBackgroundSettings();
|
||||
var names = TutorialNameSettings();
|
||||
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var gameData = TestCatalog.Repo;
|
||||
var sawGenuineLagrangeOrbit = false;
|
||||
for (uint seed = 1; seed <= 25; seed++)
|
||||
{
|
||||
|
|
@ -398,7 +394,7 @@ public sealed class RestoredHullsTests
|
|||
var tutorialSettings = TutorialGalaxySettings();
|
||||
var background = TutorialBackgroundSettings();
|
||||
var names = TutorialNameSettings();
|
||||
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var gameData = TestCatalog.Repo;
|
||||
var sawGenuineCandidate = false;
|
||||
for (uint seed = 1; seed <= 25; seed++)
|
||||
{
|
||||
|
|
@ -439,7 +435,7 @@ public sealed class RestoredHullsTests
|
|||
[Fact]
|
||||
public void ThrowReachableWithAuthoredSettings()
|
||||
{
|
||||
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var gameData = TestCatalog.Repo;
|
||||
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-throw-{Guid.NewGuid():N}.cc");
|
||||
try
|
||||
{
|
||||
|
|
@ -473,7 +469,7 @@ public sealed class RestoredHullsTests
|
|||
[Fact]
|
||||
public void MultiRootEntranceStationOrbitsAreAlwaysParentedWithPositiveDistance()
|
||||
{
|
||||
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var gameData = TestCatalog.Repo;
|
||||
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-multiroot-{Guid.NewGuid():N}.cc");
|
||||
try
|
||||
{
|
||||
|
|
@ -524,7 +520,7 @@ public sealed class RestoredHullsTests
|
|||
var tutorialSettings = TutorialGalaxySettings();
|
||||
var background = TutorialBackgroundSettings();
|
||||
var names = TutorialNameSettings();
|
||||
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var gameData = TestCatalog.Repo;
|
||||
|
||||
var checkedAny = false;
|
||||
for (uint seed = 1; seed <= 25; seed++)
|
||||
|
|
@ -723,7 +719,7 @@ public sealed class RestoredHullsTests
|
|||
[Fact]
|
||||
public void TutorialEntranceWithNoLagrangeCandidateStillSeatsAStationWithFiniteNearbyTurrets()
|
||||
{
|
||||
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var gameData = TestCatalog.Repo;
|
||||
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-widened-{Guid.NewGuid():N}.cc");
|
||||
try
|
||||
{
|
||||
|
|
@ -772,7 +768,7 @@ public sealed class RestoredHullsTests
|
|||
[Fact]
|
||||
public void EntranceOverrideDoesNotLeakToOtherTutorialZonesOrNonTutorialGames()
|
||||
{
|
||||
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var gameData = TestCatalog.Repo;
|
||||
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-leak-{Guid.NewGuid():N}.cc");
|
||||
try
|
||||
{
|
||||
|
|
@ -813,7 +809,7 @@ public sealed class RestoredHullsTests
|
|||
[Fact]
|
||||
public void ForcedTutorialEntranceStationDoesNotInflateEnemyCount()
|
||||
{
|
||||
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var gameData = TestCatalog.Repo;
|
||||
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-entrance-enemycount-{Guid.NewGuid():N}.cc");
|
||||
try
|
||||
{
|
||||
|
|
@ -895,7 +891,7 @@ public sealed class RestoredHullsTests
|
|||
[Fact]
|
||||
public void WideningDoesNotLeakToANonEntranceNoLagrangeZoneThatRollsAStation()
|
||||
{
|
||||
var gameData = Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc");
|
||||
var gameData = TestCatalog.Repo;
|
||||
var scratchRun = Path.Combine(Path.GetTempPath(), $"aetheria-widen-leak-{Guid.NewGuid():N}.cc");
|
||||
try
|
||||
{
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public sealed class RolesBackfillCommandTests : IDisposable
|
|||
{
|
||||
Directory.CreateDirectory(Path.Combine(_root, "GameData"));
|
||||
File.Copy(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), CatalogPath);
|
||||
using var cache = new CultCache(ScopedRegistry());
|
||||
using var cache = new CultCache(TestCatalog.Registry());
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
|
||||
var products = cache.GetAll<FactionProductData>().ToArray();
|
||||
foreach (var design in cache.GetAll<EquippableItemData>().Where(d => Flattened.Contains(d.Name) || Unnamed.Any(u => u.Design == d.Name)))
|
||||
|
|
@ -38,15 +38,11 @@ public sealed class RolesBackfillCommandTests : IDisposable
|
|||
}
|
||||
|
||||
// A registry scoped to the shipped assembly, so this test assembly's own documents never reach the real catalog.
|
||||
private static CultDocumentRegistry ScopedRegistry() => CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
|
||||
.Where(t => t is { IsAbstract: false, IsInterface: false })
|
||||
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null));
|
||||
|
||||
// Puts the named designs back to the state they shipped in before the backfill, through a registry scoped to the shipped
|
||||
// assembly (as PiratesFactionCommandTests strips its faction).
|
||||
private void Reset(bool flattened, bool unnamed)
|
||||
{
|
||||
using var cache = new CultCache(ScopedRegistry());
|
||||
using var cache = new CultCache(TestCatalog.Registry());
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
|
||||
var designs = cache.GetAll<EquippableItemData>().ToArray();
|
||||
var products = cache.GetAll<FactionProductData>().ToArray();
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ using float3 = CultMath.float3;
|
|||
|
||||
// RunStart owns what a new run starts with, and Zone.Admit is the one admission into a zone (docs/scenarios-cut.md,
|
||||
// 2.2 and R). Every test stages into a real arena: the entrance zone of a prelude galaxy at a fixed seed, built from a
|
||||
// scratch copy of the shipped catalog and the authored settings (Assets/Resources/Settings.asset).
|
||||
// scratch copy of the catalog the game boots (TestCatalog) and the authored settings (Assets/Resources/Settings.asset).
|
||||
public sealed partial class RunStartTests : IDisposable
|
||||
{
|
||||
private const uint GalaxySeed = 1;
|
||||
|
|
@ -38,11 +38,11 @@ public sealed partial class RunStartTests : IDisposable
|
|||
var repo = FindRepoRoot();
|
||||
Directory.CreateDirectory(_root);
|
||||
var catalog = Path.Combine(_root, "Aetheria.cc");
|
||||
File.Copy(Path.Combine(repo, "GameData", "Aetheria.cc"), catalog);
|
||||
File.Copy(TestCatalog.Repo, catalog);
|
||||
|
||||
// A registry scoped to the shipped assembly's own [CultDocument] types, as RestoredHullsTests composes it, so
|
||||
// this test assembly's own documents never reach the real catalog's validation.
|
||||
_cache = new CultCache(Registry());
|
||||
_cache = new CultCache(TestCatalog.Registry());
|
||||
_cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalog), AetheriaStores.CatalogTypes);
|
||||
_cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(_root, "run.cc")), AetheriaStores.RunTypes);
|
||||
|
||||
|
|
@ -68,11 +68,7 @@ public sealed partial class RunStartTests : IDisposable
|
|||
Directory.Delete(_root, true);
|
||||
}
|
||||
|
||||
private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
|
||||
.Where(t => t is { IsAbstract: false, IsInterface: false })
|
||||
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null));
|
||||
|
||||
private static string FindRepoRoot()
|
||||
internal static string FindRepoRoot()
|
||||
{
|
||||
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
|
||||
if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj")))
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ public sealed class ShipAuthoringTests
|
|||
|
||||
private static readonly string[] AllRoles =
|
||||
{
|
||||
"map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh", "articulation"
|
||||
"map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh"
|
||||
};
|
||||
|
||||
// The fixture with its thruster swapped for one hardpoint of the given type at mount id "mount", and no anchor for it.
|
||||
|
|
|
|||
|
|
@ -286,6 +286,18 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
Assert.Equal("ship ID collides with an existing catalog ship authoring record.", Assert.Single(composition.Excluded).Reason);
|
||||
}
|
||||
|
||||
// The loader and the LFS rule (GameData/Mods/**/*.glb) agree: only a lowercase .glb is a model.
|
||||
[Fact]
|
||||
public void AModelWithAnUppercaseExtensionIsRefused()
|
||||
{
|
||||
WritePackage("mod.skiff", modelAsset: "skiff.GLB");
|
||||
|
||||
var excluded = Assert.Single(ShipModCatalog.Compose(Shipped, Derived, Mods).Excluded);
|
||||
|
||||
Assert.Equal("mod.skiff", excluded.Package);
|
||||
Assert.Contains("GLB", excluded.Reason);
|
||||
}
|
||||
|
||||
// The game's boot: a bad package is named and the rest boot; with none left, the shipped catalog boots.
|
||||
[Fact]
|
||||
public void TheBootCatalogSurvivesBadPackages()
|
||||
|
|
@ -434,7 +446,7 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
{
|
||||
var path = Write("nodes.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[
|
||||
{""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""mesh"":0,""extras"":{""aetheria.id"":""b""}}]}"));
|
||||
Assert.Equal(new Dictionary<string, (uint, bool)> { ["a"] = (1, false), ["b"] = (4, true) }, ShipModCatalog.ReadNodeIds(path));
|
||||
Assert.Equal(new Dictionary<string, (uint, bool, int)> { ["a"] = (1, false, -1), ["b"] = (4, true, -1) }, ShipModCatalog.ReadNodeIds(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -598,7 +610,7 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
private void WritePackage(string id, string hullName = "Skiff", string[] nodes = null, string modelAsset = "skiff.glb",
|
||||
Action<ShipParts> tweak = null, string[] meshNodes = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak, meshNodes);
|
||||
|
||||
private static Dictionary<string, uint> Indices(Dictionary<string, (uint Index, bool HasMesh)> nodes) =>
|
||||
private static Dictionary<string, uint> Indices(Dictionary<string, (uint Index, bool HasMesh, int Parent)> nodes) =>
|
||||
nodes.ToDictionary(node => node.Key, node => node.Value.Index);
|
||||
|
||||
private static int Occurrences(byte[] haystack, byte[] needle)
|
||||
|
|
|
|||
384
tests/Aetheria.Shared.Tests/ShipRigTests.cs
Normal file
384
tests/Aetheria.Shared.Tests/ShipRigTests.cs
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using CultMath;
|
||||
using Xunit;
|
||||
|
||||
// The rig in the visual record: what the judge (Validate) refuses about joints and the guns that ride them, what Bind
|
||||
// refuses when the GLB disagrees with the record, and what ShipModPlan.Rigs hands the assembler. The rig is
|
||||
// presentation (ruling rig-is-presentation): arc stays the hardpoint's and traverse the weapon's, so the judge only
|
||||
// asks that the chain could sweep the arc the hardpoint states.
|
||||
public sealed class ShipRigTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _directory = new TempDirectory();
|
||||
public void Dispose() => _directory.Dispose();
|
||||
|
||||
private static readonly float[] Up = { 0f, 1f, 0f };
|
||||
private static readonly float[] Right = { 1f, 0f, 0f };
|
||||
|
||||
private static ShipJoint Joint(string id, string parent, params (float[] Axis, float Min, float Max)[] dofs) => new ShipJoint
|
||||
{
|
||||
Id = id, Parent = parent,
|
||||
Axes = dofs.SelectMany(dof => dof.Axis).ToArray(), Min = dofs.Select(dof => dof.Min).ToArray(), Max = dofs.Select(dof => dof.Max).ToArray()
|
||||
};
|
||||
|
||||
private static float[] Tilted(float degrees) => new[] { MathF.Sin(degrees * MathF.PI / 180f), MathF.Cos(degrees * MathF.PI / 180f), 0f };
|
||||
|
||||
// The fixture's skiff with energy guns "g0".."gN" side by side (one cell each) and the thruster after them. Each gun
|
||||
// is a weapon mount (node "gN") with one muzzle ("gN.muzzle", node "gN-muzzle"), fires 180 degrees, and rides the joint
|
||||
// its tuple names (null is a fixed mount).
|
||||
private static ShipParts Armed(params string[] jointOfGun)
|
||||
{
|
||||
var ship = ShipAuthoringTests.Fixture();
|
||||
var shape = new Shape(jointOfGun.Length + 1, 1);
|
||||
for (var x = 0; x <= jointOfGun.Length; x++) shape.Cells[x, 0] = true;
|
||||
ship.Hull.Shape = shape;
|
||||
ship.Hull.Hardpoints[0].Position = new int2(jointOfGun.Length, 0);
|
||||
for (var gun = 0; gun < jointOfGun.Length; gun++)
|
||||
{
|
||||
ship.Hull.Hardpoints.Add(new HardpointData
|
||||
{ Type = HardpointType.Energy, Position = new int2(gun, 0), Shape = new Shape(), Transform = $"g{gun}", FiringArc = 180 });
|
||||
ship.Visual.Anchors.Add(new ShipAnchor { Id = $"g{gun}", Role = "weapon-mount", ModelNodeId = $"g{gun}", Joint = jointOfGun[gun] });
|
||||
ship.Visual.Anchors.Add(new ShipAnchor { Id = $"g{gun}.muzzle", Role = "weapon-muzzle", ModelNodeId = $"g{gun}-muzzle", ParentId = $"g{gun}" });
|
||||
}
|
||||
return ship;
|
||||
}
|
||||
|
||||
private static string Refusal(ShipParts ship) => Assert.Throws<InvalidOperationException>(ship.Validate).Message;
|
||||
|
||||
// One gun, on joint "j" with the given degrees of freedom, firing the given arc.
|
||||
private static ShipParts OneGun(float arc, params (float[] Axis, float Min, float Max)[] dofs)
|
||||
{
|
||||
var ship = Armed("j");
|
||||
ship.Visual.Joints.Add(Joint("j", null, dofs));
|
||||
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g0").FiringArc = arc;
|
||||
return ship;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnArmedShipWithAJointIsValid() => OneGun(90, (Up, -45f, 45f)).Validate();
|
||||
|
||||
[Fact]
|
||||
public void ReachIsTheYawRangeOfTheChainAgainstTheArcTheHardpointStates()
|
||||
{
|
||||
OneGun(90, (Up, -45f, 45f)).Validate();
|
||||
var short1 = Refusal(OneGun(100, (Up, -45f, 45f)));
|
||||
Assert.Contains("joint j chain reaches 90 degrees of yaw", short1);
|
||||
Assert.Contains("100 degree firing arc", short1);
|
||||
|
||||
// Serial degrees of freedom add, whether they share a joint or sit on two.
|
||||
OneGun(120, (Up, -30f, 30f), (Up, -30f, 30f)).Validate();
|
||||
Assert.Contains("reaches 120", Refusal(OneGun(121, (Up, -30f, 30f), (Up, -30f, 30f))));
|
||||
var serial = Armed("child");
|
||||
serial.Visual.Joints.Add(Joint("base", null, (Up, -30f, 30f)));
|
||||
serial.Visual.Joints.Add(Joint("child", "base", (Up, -30f, 30f)));
|
||||
serial.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g0").FiringArc = 120;
|
||||
serial.Validate();
|
||||
serial.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g0").FiringArc = 121;
|
||||
Assert.Contains("reaches 120", Refusal(serial));
|
||||
|
||||
// A degree of freedom about +X is pitch: it adds nothing, however wide.
|
||||
Assert.Contains("reaches 0 degrees", Refusal(OneGun(10, (Right, -180f, 180f))));
|
||||
Assert.Contains("reaches 90", Refusal(OneGun(91, (Up, -45f, 45f), (Right, -180f, 180f))));
|
||||
|
||||
// A full turn covers every arc up to 360, and no reach is reported above it.
|
||||
OneGun(360, (Up, -180f, 180f)).Validate();
|
||||
Assert.Contains("reaches 360 degrees", Refusal(OneGun(400, (Up, -180f, 180f), (Up, -180f, 180f))));
|
||||
}
|
||||
|
||||
// Reach is Max - Min per degree of freedom, however the limits sit about zero: a sweep from 20 to 50 reaches 30, and one from
|
||||
// -10 to 50 reaches 60. Symmetric limits cannot tell that from |Min| + |Max| (70, 60) or twice the larger side (100, 100).
|
||||
[Fact]
|
||||
public void ReachIsMaxMinusMinForLimitsThatSitOffCentre()
|
||||
{
|
||||
OneGun(30, (Up, 20f, 50f)).Validate();
|
||||
Assert.Contains("reaches 30 degrees", Refusal(OneGun(31, (Up, 20f, 50f))));
|
||||
OneGun(60, (Up, -10f, 50f)).Validate();
|
||||
Assert.Contains("reaches 60 degrees", Refusal(OneGun(61, (Up, -10f, 50f))));
|
||||
OneGun(50, (Up, -50f, 0f)).Validate();
|
||||
Assert.Contains("reaches 50 degrees", Refusal(OneGun(51, (Up, -50f, 0f))));
|
||||
// Off-centre limits on two serial degrees of freedom add their own ranges: 30 + 60.
|
||||
OneGun(90, (Up, 20f, 50f), (Up, -10f, 50f)).Validate();
|
||||
Assert.Contains("reaches 90 degrees", Refusal(OneGun(91, (Up, 20f, 50f), (Up, -10f, 50f))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnAxisWithinFiveDegreesOfUpIsYawAndOneBeyondItIsNot()
|
||||
{
|
||||
OneGun(90, (Tilted(4f), -45f, 45f)).Validate();
|
||||
Assert.Contains("reaches 0 degrees", Refusal(OneGun(90, (Tilted(6f), -45f, 45f))));
|
||||
// An axis pointing down is as much yaw as one pointing up.
|
||||
OneGun(90, (new[] { 0f, -1f, 0f }, -45f, 45f)).Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AChainStopsAtADrivenAncestorSoItsYawDoesNotCountForTheChild()
|
||||
{
|
||||
var ship = Armed("outer", "inner");
|
||||
ship.Visual.Joints.Add(Joint("outer", null, (Up, -180f, 180f)));
|
||||
ship.Visual.Joints.Add(Joint("inner", "outer", (Up, -10f, 10f)));
|
||||
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").FiringArc = 20;
|
||||
ship.Validate();
|
||||
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").FiringArc = 100;
|
||||
Assert.Contains("joint inner chain reaches 20 degrees", Refusal(ship));
|
||||
|
||||
// With nothing riding the outer joint it is only a link in the inner chain, and counts.
|
||||
ship.Visual.Anchors.Single(anchor => anchor.Id == "g0").Joint = null;
|
||||
ship.Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AGunOnAJointStatesItsArc()
|
||||
{
|
||||
var ship = OneGun(0, (Up, -180f, 180f));
|
||||
Assert.Contains("must state its firing arc above 0", Refusal(ship));
|
||||
|
||||
// The same hardpoint on no joint is a fixed mount, and a fixed mount at the default arc is fine.
|
||||
ship.Visual.Anchors.Single(anchor => anchor.Id == "g0").Joint = null;
|
||||
ship.Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MountsOnOneJointShareTheirArcAndRotation()
|
||||
{
|
||||
var ship = Armed("j", "j");
|
||||
ship.Visual.Joints.Add(Joint("j", null, (Up, -180f, 180f)));
|
||||
ship.Validate();
|
||||
|
||||
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").FiringArc = 90;
|
||||
Assert.Contains("must share Rotation and FiringArc", Refusal(ship));
|
||||
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").FiringArc = 180;
|
||||
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1").Rotation = ItemRotation.Reversed;
|
||||
Assert.Contains("must share Rotation and FiringArc", Refusal(ship));
|
||||
|
||||
// Guns on different joints, or one on none, keep their own.
|
||||
ship.Visual.Joints.Add(Joint("k", null, (Up, -180f, 180f)));
|
||||
ship.Visual.Anchors.Single(anchor => anchor.Id == "g1").Joint = "k";
|
||||
ship.Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AJointRidesOnlyAWeaponMountThatNamesARealJoint()
|
||||
{
|
||||
var ship = Armed("ghost");
|
||||
Assert.Contains("names an unknown joint", Refusal(ship));
|
||||
|
||||
ship = Armed("j");
|
||||
ship.Visual.Joints.Add(Joint("j", null, (Up, -180f, 180f)));
|
||||
ship.Visual.Anchors.Single(anchor => anchor.Id == "g0.muzzle").Joint = "j";
|
||||
Assert.Contains("only a weapon mount rides one", Refusal(ship));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JointShapeAcceptsItsBoundariesAndTheUnitTolerance()
|
||||
{
|
||||
OneGun(1, (Up, -180f, 180f)).Validate();
|
||||
OneGun(1, (Up, 179f, 180f)).Validate();
|
||||
OneGun(1, (new[] { 0f, 1.0009f, 0f }, 0f, 5f)).Validate();
|
||||
OneGun(1, (new[] { 0f, .9991f, 0f }, 0f, 5f)).Validate();
|
||||
Assert.Contains("must be a unit vector", Refusal(OneGun(1, (new[] { 0f, 1.0015f, 0f }, 0f, 5f))));
|
||||
Assert.Contains("must be a unit vector", Refusal(OneGun(1, (new[] { 0f, .9985f, 0f }, 0f, 5f))));
|
||||
}
|
||||
|
||||
// ---- Bind: the GLB must have the rig the record states ----
|
||||
|
||||
// A model node: its glTF name (unique, what Parent names), its aetheria.id (null for a node the exporter left untagged),
|
||||
// its parent's name, and whether it carries a mesh.
|
||||
private record Node(string Name, string Id, string Parent, bool Mesh = false);
|
||||
|
||||
// arm > elbow > wrist > (an untagged bracket) > gun > gun-muzzle, with the fixture's other nodes at the root.
|
||||
private static readonly Node[] RigNodes =
|
||||
{
|
||||
new Node("map", "map", null), new Node("collider", "collider", null), new Node("shield", "shield", null),
|
||||
new Node("tractor", "tractor", null), new Node("thruster-port", "thruster-port", null, true),
|
||||
new Node("arm", "arm", null), new Node("elbow", "elbow", "arm"), new Node("wrist", "wrist", "elbow"),
|
||||
new Node("bracket", null, "wrist"), new Node("gun", "gun", "bracket"), new Node("gun-muzzle", "gun-muzzle", "gun")
|
||||
};
|
||||
|
||||
private static string GlbJson(Node[] nodes)
|
||||
{
|
||||
var text = new StringBuilder(@"{""asset"":{""version"":""2.0""},""meshes"":[{""primitives"":[]}],""nodes"":[");
|
||||
for (var index = 0; index < nodes.Length; index++)
|
||||
{
|
||||
var node = nodes[index];
|
||||
var kids = Enumerable.Range(0, nodes.Length).Where(child => nodes[child].Parent == node.Name).ToArray();
|
||||
text.Append(index > 0 ? "," : "").Append($@"{{""name"":""{node.Name}""");
|
||||
if (node.Mesh) text.Append(@",""mesh"":0");
|
||||
if (node.Id != null) text.Append($@",""extras"":{{""aetheria.id"":""{node.Id}""}}");
|
||||
if (kids.Length > 0) text.Append(@",""children"":[" + string.Join(",", kids) + "]");
|
||||
text.Append('}');
|
||||
}
|
||||
return text.Append("]}").ToString();
|
||||
}
|
||||
|
||||
// The fixture's package with the arm rig (arm, elbow, wrist; the gun on the wrist), then the tweak, written beside a
|
||||
// GLB of the given nodes.
|
||||
private string Package(Action<ShipParts> tweak, Node[] nodes = null)
|
||||
{
|
||||
var mods = Path.Combine(_directory.Path, "Mods");
|
||||
Directory.CreateDirectory(mods);
|
||||
var directory = ShipFixture.WritePackage(mods, "mod.skiff", tweak: ship =>
|
||||
{
|
||||
ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "gun").FiringArc = 90;
|
||||
ship.Visual.Joints.Add(Joint("arm", null, (Up, -45f, 45f)));
|
||||
ship.Visual.Joints.Add(Joint("elbow", "arm", (Right, -45f, 45f)));
|
||||
ship.Visual.Joints.Add(Joint("wrist", "elbow", (Up, -45f, 45f)));
|
||||
ship.Visual.Anchors.Single(anchor => anchor.Id == "gun").Joint = "wrist";
|
||||
tweak?.Invoke(ship);
|
||||
});
|
||||
File.WriteAllBytes(Path.Combine(directory, "skiff.glb"), ShipFixture.Glb(GlbJson(nodes ?? RigNodes)));
|
||||
return Path.Combine(directory, "ship.cc");
|
||||
}
|
||||
|
||||
private static string BindRefusal(string path) => Assert.Throws<InvalidOperationException>(() => ShipModCatalog.ReadPackage(path)).Message;
|
||||
|
||||
private static Node[] Reparent(string name, string parent) =>
|
||||
RigNodes.Select(node => node.Name == name ? node with { Parent = parent } : node).ToArray();
|
||||
|
||||
[Fact]
|
||||
public void BindAcceptsARigTheModelHasEvenWithAnUntaggedNodeBetween()
|
||||
{
|
||||
var package = ShipModCatalog.ReadPackage(Package(null));
|
||||
Assert.Equal(new[] { "arm", "elbow", "wrist" }, package.Visual.Joints.Select(joint => joint.Id));
|
||||
}
|
||||
|
||||
// The exporter tags nodes that are not joints (a bracket, a forearm mesh). They sit between a mount and its joint and
|
||||
// between a joint and its parent joint, and the nearest JOINT above is still what Bind compares.
|
||||
[Fact]
|
||||
public void BindWalksPastTaggedNodesThatAreNotJoints()
|
||||
{
|
||||
var nodes = RigNodes.Select(node => node.Name switch
|
||||
{
|
||||
"bracket" => node with { Id = "bracket" },
|
||||
"wrist" => node with { Parent = "forearm" },
|
||||
_ => node
|
||||
}).Append(new Node("forearm", "forearm", "elbow")).ToArray();
|
||||
|
||||
var package = ShipModCatalog.ReadPackage(Package(null, nodes));
|
||||
|
||||
Assert.Equal(new[] { "arm", "elbow", "wrist" }, package.Visual.Joints.Select(joint => joint.Id));
|
||||
Assert.Equal("wrist", package.Visual.Anchors.Single(anchor => anchor.Id == "gun").Joint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRefusesAMountWhoseJointIsNotTheNearestOneAboveItsNode()
|
||||
{
|
||||
Assert.Contains("weapon mount gun must name the nearest joint above its model node",
|
||||
BindRefusal(Package(ship => ship.Visual.Anchors.Single(anchor => anchor.Id == "gun").Joint = "elbow")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRefusesAMountThatTheRecordAndTheModelPlaceDifferently()
|
||||
{
|
||||
// The record says the gun is fixed; the model hangs it under the wrist.
|
||||
Assert.Contains("weapon mount gun must name the nearest joint above its model node",
|
||||
BindRefusal(Package(ship => ship.Visual.Anchors.Single(anchor => anchor.Id == "gun").Joint = null)));
|
||||
// The record rides the wrist; the model hangs the gun at the root.
|
||||
Assert.Contains("weapon mount gun must name the nearest joint above its model node", BindRefusal(Package(null, Reparent("gun", null))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRefusesAJointThatIsNoModelNode()
|
||||
{
|
||||
var path = Package(null, RigNodes.Where(node => node.Name != "elbow").Select(node => node.Name == "wrist" ? node with { Parent = null } : node).ToArray());
|
||||
Assert.Contains("model has no node with aetheria.id=elbow for a joint", BindRefusal(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRefusesAJointWhoseParentSkipsAJointNode()
|
||||
{
|
||||
Assert.Contains("joint wrist must name the nearest joint above its model node as its Parent",
|
||||
BindRefusal(Package(ship => ship.Visual.Joints.Single(joint => joint.Id == "wrist").Parent = "arm")));
|
||||
Assert.Contains("joint elbow must name the nearest joint above its model node as its Parent",
|
||||
BindRefusal(Package(ship => ship.Visual.Joints.Single(joint => joint.Id == "elbow").Parent = null)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindRefusesAThrusterMeshUnderAJointNamingTheFollowUp() =>
|
||||
Assert.Contains("gimballed-thrusters", BindRefusal(Package(null, Reparent("thruster-port", "wrist"))));
|
||||
|
||||
[Fact]
|
||||
public void NodeParentsAreTheNearestTaggedAncestorAndRootsHaveNone()
|
||||
{
|
||||
var path = Path.Combine(_directory.Path, "rig.glb");
|
||||
File.WriteAllBytes(path, ShipFixture.Glb(GlbJson(RigNodes)));
|
||||
var nodes = ShipModCatalog.ReadNodeIds(path);
|
||||
Assert.Equal(-1, nodes["arm"].Parent);
|
||||
Assert.Equal((int)nodes["arm"].Index, nodes["elbow"].Parent);
|
||||
Assert.Equal((int)nodes["wrist"].Index, nodes["gun"].Parent);
|
||||
Assert.Equal((int)nodes["gun"].Index, nodes["gun-muzzle"].Parent);
|
||||
}
|
||||
|
||||
// ---- ShipModPlan.Rigs ----
|
||||
|
||||
[Fact]
|
||||
public void RigsAreOnePerDrivenJointInHullOrderOfTheirFirstWeaponWithChainsThatStopAtDrivenAncestors()
|
||||
{
|
||||
// g0 on the inner joint, g1 and g2 on the outer one (g2 authored first among the anchors).
|
||||
var ship = Armed("barrel", "turret", "turret");
|
||||
ship.Visual.Joints.Add(Joint("turret", null, (Up, -180f, 180f)));
|
||||
ship.Visual.Joints.Add(Joint("barrel", "turret", (Up, -180f, 180f)));
|
||||
ship.Visual.Anchors.RemoveAll(anchor => anchor.Id == "g1.muzzle");
|
||||
ship.Visual.Anchors.Add(new ShipAnchor { Id = "g1.b", Role = "weapon-muzzle", ModelNodeId = "g1-b", ParentId = "g1", Order = 1 });
|
||||
ship.Visual.Anchors.Add(new ShipAnchor { Id = "g1.z", Role = "weapon-muzzle", ModelNodeId = "g1-z", ParentId = "g1", Order = 0 });
|
||||
ship.Visual.Anchors.Reverse();
|
||||
|
||||
var rigs = ShipModPlan.Build(ship.Hull, ship.Visual).Rigs;
|
||||
|
||||
Assert.Equal(2, rigs.Length);
|
||||
Assert.Equal("barrel", rigs[0].Joint);
|
||||
Assert.Equal(new[] { "barrel" }, rigs[0].Chain);
|
||||
Assert.Equal(("g0", "g0.muzzle"), (rigs[0].LeadMount, rigs[0].LeadMuzzle));
|
||||
Assert.Equal("turret", rigs[1].Joint);
|
||||
Assert.Equal(new[] { "turret" }, rigs[1].Chain);
|
||||
Assert.Equal(("g1", "g1.z"), (rigs[1].LeadMount, rigs[1].LeadMuzzle));
|
||||
}
|
||||
|
||||
// Hull order, not name order: the first gun rides "turret" and the second "barrel".
|
||||
[Fact]
|
||||
public void RigsFollowHullOrderAndNotTheOrderOfTheJointNames()
|
||||
{
|
||||
var ship = Armed("turret", "barrel");
|
||||
ship.Visual.Joints.Add(Joint("turret", null, (Up, -180f, 180f)));
|
||||
ship.Visual.Joints.Add(Joint("barrel", null, (Up, -180f, 180f)));
|
||||
|
||||
var rigs = ShipModPlan.Build(ship.Hull, ship.Visual).Rigs;
|
||||
|
||||
Assert.Equal(new[] { "turret", "barrel" }, rigs.Select(rig => rig.Joint));
|
||||
Assert.Equal(new[] { "g0", "g1" }, rigs.Select(rig => rig.LeadMount));
|
||||
}
|
||||
|
||||
// g1 is listed before g0 in the hull though its name sorts after it.
|
||||
[Fact]
|
||||
public void TheLeadMountOfAJointIsItsFirstWeaponInHullOrderWhateverTheNames()
|
||||
{
|
||||
var ship = Armed("j", "j");
|
||||
ship.Visual.Joints.Add(Joint("j", null, (Up, -180f, 180f)));
|
||||
var g1 = ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "g1");
|
||||
ship.Hull.Hardpoints.Remove(g1);
|
||||
ship.Hull.Hardpoints.Insert(ship.Hull.Hardpoints.FindIndex(hardpoint => hardpoint.Transform == "g0"), g1);
|
||||
|
||||
var rig = Assert.Single(ShipModPlan.Build(ship.Hull, ship.Visual).Rigs);
|
||||
|
||||
Assert.Equal(("g1", "g1.muzzle"), (rig.LeadMount, rig.LeadMuzzle));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUndrivenParentJointIsPartOfTheChainRootFirst()
|
||||
{
|
||||
var ship = Armed("wrist");
|
||||
ship.Visual.Joints.Add(Joint("arm", null, (Up, -90f, 90f)));
|
||||
ship.Visual.Joints.Add(Joint("elbow", "arm", (Right, -45f, 45f)));
|
||||
ship.Visual.Joints.Add(Joint("wrist", "elbow", (Up, -90f, 90f)));
|
||||
|
||||
var rig = Assert.Single(ShipModPlan.Build(ship.Hull, ship.Visual).Rigs);
|
||||
|
||||
Assert.Equal(new[] { "arm", "elbow", "wrist" }, rig.Chain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AShipWithNoGunOnAJointHasNoRigs() =>
|
||||
Assert.Empty(ShipModPlan.Build(Armed((string)null).Hull, Armed((string)null).Visual).Rigs);
|
||||
}
|
||||
|
|
@ -67,6 +67,15 @@ public sealed class ShipSchemaPinTests
|
|||
Assert.Equal(slot, KeyOf(typeof(ShipAnchor), members[slot]));
|
||||
}
|
||||
|
||||
// ship_cc.py edits slots 2 to 4 of a ship and the first five slots of an anchor row, and carries every later slot (replace_visual
|
||||
// by anchor Id). The rig lives in those later slots; their numbers are the contract that carry-through keeps.
|
||||
[Fact]
|
||||
public void TheRigLivesInTheSlotsPastTheOnesPythonEdits()
|
||||
{
|
||||
Assert.Equal(Integer("SCHEMATIC_LINES_SLOT") + 1, KeyOf(typeof(ShipAuthoring), nameof(ShipAuthoring.Joints)));
|
||||
Assert.Equal(Strings("ANCHOR_MEMBERS").Length, KeyOf(typeof(ShipAnchor), nameof(ShipAnchor.Joint)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PythonEnumNamesAreTheEnumsInValueOrder()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -172,17 +172,41 @@ public sealed class ShipValidationTests
|
|||
Add("anchors-null", s => s.Visual.Anchors = null, "anchors are required");
|
||||
Add("anchor-null", s => s.Visual.Anchors.Add(null), "anchor IDs must be present and unique");
|
||||
Add("anchor-id-null", s => s.Visual.Anchors[4].Id = null, "anchor IDs must be present and unique");
|
||||
Add("anchor-id-blank", s => s.Visual.Anchors.Add(new ShipAnchor { Id = " ", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique");
|
||||
Add("anchor-id-duplicate", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "map", Role = "articulation", ModelNodeId = "x" }), "anchor IDs must be present and unique");
|
||||
Add("anchor-id-blank", s => s.Visual.Anchors.Add(new ShipAnchor { Id = " ", Role = "weapon-muzzle", ModelNodeId = "x" }), "anchor IDs must be present and unique");
|
||||
Add("anchor-id-duplicate", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "map", Role = "weapon-muzzle", ModelNodeId = "x" }), "anchor IDs must be present and unique");
|
||||
Add("anchor-node-null", s => s.Visual.Anchors[4].ModelNodeId = null, "needs a model node ID");
|
||||
Add("anchor-node-blank", s => s.Visual.Anchors[4].ModelNodeId = " ", "needs a model node ID");
|
||||
Add("anchor-node-shared", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "other", Role = "articulation", ModelNodeId = "map" }), "claimed by more than one anchor");
|
||||
Add("anchor-node-shared", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "other", Role = "weapon-muzzle", ModelNodeId = "map" }), "claimed by more than one anchor");
|
||||
foreach (var role in new[] { "map-icon", "hull-collider", "shield", "tractor" })
|
||||
{
|
||||
Add("role-missing-" + role, s => s.Visual.Anchors.RemoveAll(a => a.Role == role), $"exactly one {role} anchor is required");
|
||||
Add("role-doubled-" + role, s => s.Visual.Anchors.Add(new ShipAnchor { Id = "extra", Role = role, ModelNodeId = "extra" }), $"exactly one {role} anchor is required");
|
||||
}
|
||||
|
||||
void Joint(ShipParts s, string id, string parent, float[] axes, float[] min, float[] max) =>
|
||||
s.Visual.Joints.Add(new ShipJoint { Id = id, Parent = parent, Axes = axes, Min = min, Max = max });
|
||||
var yaw = new[] { 0f, 1f, 0f };
|
||||
Add("joint-id-blank", s => Joint(s, " ", null, yaw, new[] { -1f }, new[] { 1f }), "joint IDs must be present and unique");
|
||||
Add("joint-null", s => s.Visual.Joints.Add(null), "joint IDs must be present and unique");
|
||||
Add("joint-id-duplicate", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); }, "joint IDs must be present and unique");
|
||||
Add("joint-id-is-an-anchor-node", s => Joint(s, "map", null, yaw, new[] { -1f }, new[] { 1f }), "is also an anchor's model node");
|
||||
Add("joint-axes-length-four", s => Joint(s, "j", null, new[] { 0f, 1f, 0f, 0f }, new[] { -1f }, new[] { 1f }), "needs one or more degrees of freedom");
|
||||
Add("joint-no-degrees-of-freedom", s => Joint(s, "j", null, new float[0], new float[0], new float[0]), "needs one or more degrees of freedom");
|
||||
Add("joint-null-limits", s => Joint(s, "j", null, yaw, null, null), "needs one or more degrees of freedom");
|
||||
Add("joint-max-count-differs", s => Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f, 2f }), "needs one or more degrees of freedom");
|
||||
Add("joint-axis-not-unit", s => Joint(s, "j", null, new[] { 0f, 2f, 0f }, new[] { -1f }, new[] { 1f }), "must be a unit vector");
|
||||
Add("joint-axis-not-finite", s => Joint(s, "j", null, new[] { 0f, float.NaN, 0f }, new[] { -1f }, new[] { 1f }), "axes and limits must be finite");
|
||||
Add("joint-limit-not-finite", s => Joint(s, "j", null, yaw, new[] { -1f }, new[] { float.PositiveInfinity }), "axes and limits must be finite");
|
||||
Add("joint-min-not-finite", s => Joint(s, "j", null, yaw, new[] { float.NaN }, new[] { 1f }), "axes and limits must be finite");
|
||||
Add("joint-max-not-a-number", s => Joint(s, "j", null, yaw, new[] { -1f }, new[] { float.NaN }), "axes and limits must be finite");
|
||||
Add("joint-min-above-max", s => Joint(s, "j", null, yaw, new[] { 10f }, new[] { 5f }), "must satisfy -180 <= Min <= Max <= 180");
|
||||
Add("joint-max-181", s => Joint(s, "j", null, yaw, new[] { 0f }, new[] { 181f }), "must satisfy -180 <= Min <= Max <= 180");
|
||||
Add("joint-min-minus-181", s => Joint(s, "j", null, yaw, new[] { -181f }, new[] { 0f }), "must satisfy -180 <= Min <= Max <= 180");
|
||||
Add("joint-parent-dangling", s => Joint(s, "j", "ghost", yaw, new[] { -1f }, new[] { 1f }), "has an unknown parent joint");
|
||||
Add("joint-cycle", s => { Joint(s, "a", "b", yaw, new[] { -1f }, new[] { 1f }); Joint(s, "b", "a", yaw, new[] { -1f }, new[] { 1f }); }, "is on a parent cycle");
|
||||
Add("joint-on-a-thruster-mount", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); s.Visual.Anchors[4].Joint = "j"; }, "gimballed-thrusters");
|
||||
Add("joint-on-a-radiator-mount", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); s.Visual.Anchors[4].Role = "radiator-mesh"; s.Visual.Anchors[4].Joint = "j"; s.Hull.Hardpoints[0].Type = HardpointType.Radiator; }, "gimballed-thrusters");
|
||||
Add("joint-on-a-map-icon", s => { Joint(s, "j", null, yaw, new[] { -1f }, new[] { 1f }); s.Visual.Anchors[0].Joint = "j"; }, "may not name a joint");
|
||||
Add("anchor-role-unknown", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "x", Role = "engine", ModelNodeId = "x" }), "has unknown role 'engine'");
|
||||
Add("anchor-role-null", s => s.Visual.Anchors.Add(new ShipAnchor { Id = "x", Role = null, ModelNodeId = "x" }), "has unknown role ''");
|
||||
Add("anchor-role-cased", s => s.Visual.Anchors[0].Role = "Map-Icon", "has unknown role 'Map-Icon'");
|
||||
|
|
|
|||
|
|
@ -452,4 +452,132 @@ public sealed class StatResolverTests : IDisposable
|
|||
Assert.Equal(baselineCache, resolver.CacheEntryCount);
|
||||
Assert.Equal(baselineModifiers, resolver.ModifierEntryCount);
|
||||
}
|
||||
|
||||
// Ruling boost-stacking-multiply: every multiplier on one stat multiplies, with no stacking penalty, and every
|
||||
// constant adds. The rig is the production path end to end: gear carrying StatModifierData is equipped on a ship
|
||||
// beside a thruster, a consumable is minted through ItemManager.CreateInstance and activated on the ship, and
|
||||
// the resolved value is read through EquippedItem.Evaluate. Nothing here calls AttachModifier by hand.
|
||||
private sealed class ThrustRig
|
||||
{
|
||||
public CultCache Cache;
|
||||
public ItemManager Items;
|
||||
public Ship Ship;
|
||||
public EquippedItem Engine;
|
||||
public PerformanceStat Thrust;
|
||||
public Func<ConsumableItem> Consumable; // null when the rig was built without one
|
||||
|
||||
public float Resolved => Engine.Evaluate(Thrust);
|
||||
// Two ticks: the first executes every modifier, the second applies it (StatModifier.Update).
|
||||
public void Tick(float dt) { for (var i = 0; i < 2; i++) Ship.Update(dt); }
|
||||
}
|
||||
|
||||
private static StatModifierData ThrustModifier(StatModifierType type, float value) => new StatModifierData
|
||||
{
|
||||
Stat = new StatReference { Target = nameof(ThrusterData), Stat = nameof(ThrusterData.Thrust) },
|
||||
Modifier = new PerformanceStat { Min = value, Max = value },
|
||||
Type = type
|
||||
};
|
||||
|
||||
// A Thrust-10 thruster and one gear per entry of `gear`, in that attach order, plus (optionally) a consumable
|
||||
// whose single modifier is `consumable`. The hull has four hardpoints, so at most three gear modifiers.
|
||||
private ThrustRig BuildThrustRig((StatModifierType Type, float Value)[] gear, (StatModifierType Type, float Value)? consumable = null)
|
||||
{
|
||||
var thrust = new PerformanceStat { Min = 10, Max = 10 };
|
||||
var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
||||
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
||||
var hullShape = new Shape(3, 3);
|
||||
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
|
||||
var cells = new[] { HardpointCell, SecondHardpointCell, FirstGunCell, SecondGunCell };
|
||||
cache.Upsert(new HullData
|
||||
{
|
||||
Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10,
|
||||
Hardpoints = cells.Select(c => new HardpointData { Type = HardpointType.Sensors, Position = c, Shape = new Shape() }).ToList()
|
||||
});
|
||||
cache.Upsert(new GearData { Name = "Engine", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, Behaviors = { new ThrusterData { Thrust = thrust } } });
|
||||
for (var i = 0; i < gear.Length; i++)
|
||||
cache.Upsert(new GearData { Name = "Boost" + i, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 10, Behaviors = { ThrustModifier(gear[i].Type, gear[i].Value) } });
|
||||
if (consumable != null)
|
||||
cache.Upsert(new ConsumableItemData { Name = "Stim", Duration = 1f, Shape = new Shape(), Behaviors = { ThrustModifier(consumable.Value.Type, consumable.Value.Value) } });
|
||||
cache.FlushAsync().Wait();
|
||||
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
|
||||
var ship = new Ship(items, zone, Mint(cache, items, cache.GetByName<HullData>("Skiff")), new EntitySettings());
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName<GearData>("Engine")), cells[0]));
|
||||
for (var i = 0; i < gear.Length; i++)
|
||||
{
|
||||
// A test gear has no authored heat response, so its thermal performance reads 0 and it would sit offline;
|
||||
// the override is the entity's own switch for keeping a part running, not a stand-in for the rule.
|
||||
var boost = Mint(cache, items, cache.GetByName<GearData>("Boost" + i));
|
||||
boost.OverrideShutdown = true;
|
||||
Assert.True(ship.TryEquip(boost, cells[i + 1]));
|
||||
}
|
||||
ship.OverrideShutdown = true;
|
||||
zone.Entities.Add(ship);
|
||||
ship.Activate();
|
||||
|
||||
Func<ConsumableItem> mint = null;
|
||||
if (consumable != null)
|
||||
{
|
||||
var design = cache.GetByName<ConsumableItemData>("Stim");
|
||||
mint = () => Assert.IsType<ConsumableItem>(items.CreateInstance(items.Lots.Add(
|
||||
new Lot { Design = cache.RefOf<ItemData>(design), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })));
|
||||
}
|
||||
return new ThrustRig { Cache = cache, Items = items, Ship = ship, Engine = ship.Equipment.Single(e => e.Data.Name == "Engine"), Thrust = thrust, Consumable = mint };
|
||||
}
|
||||
|
||||
// Two scales multiply: x1.2 and x1.25 on one stat resolve to base x1.5, and x1.1, x1.2, x1.25 to base x1.65,
|
||||
// whatever order they attach in. Mutations at StatResolver.ScaleModifier: a sum of excesses reads 14.5 and
|
||||
// 15.5, the strongest-only rule 12.5, a diminishing curve 14.67, and `result *= value * value` or
|
||||
// `result *= value + 0.01f` (functions of the scale itself) read off 15.
|
||||
[Theory]
|
||||
[InlineData(new[] { 1.2f, 1.25f }, 15f)]
|
||||
[InlineData(new[] { 1.25f, 1.2f }, 15f)]
|
||||
[InlineData(new[] { 1.1f, 1.2f, 1.25f }, 16.5f)]
|
||||
[InlineData(new[] { 1.25f, 1.1f, 1.2f }, 16.5f)]
|
||||
[InlineData(new[] { 1.2f, 1.25f, 1.1f }, 16.5f)]
|
||||
public void TwoScalesOnOneStatMultiply(float[] scales, float expected)
|
||||
{
|
||||
var rig = BuildThrustRig(scales.Select(s => (StatModifierType.Multiplier, s)).ToArray());
|
||||
using var _ = rig.Cache;
|
||||
Assert.Equal(10f, rig.Resolved, 3); // modifiers not yet applied
|
||||
rig.Tick(.1f);
|
||||
Assert.Equal(expected, rig.Resolved, 3);
|
||||
}
|
||||
|
||||
// A gear modifier and a consumable modifier on one drive's Thrust share one entry and multiply: x1.2 gear and
|
||||
// x1.5 consumable read x1.8, and when the consumable expires only the gear's x1.2 remains.
|
||||
[Fact]
|
||||
public void AConsumableBoostMultipliesWithGear()
|
||||
{
|
||||
var rig = BuildThrustRig(new[] { (StatModifierType.Multiplier, 1.2f) }, (StatModifierType.Multiplier, 1.5f));
|
||||
using var _ = rig.Cache;
|
||||
rig.Tick(.1f);
|
||||
Assert.Equal(12f, rig.Resolved, 3); // gear alone
|
||||
|
||||
rig.Ship.ActivateConsumable(rig.Consumable());
|
||||
rig.Tick(.1f); // 0.8s of the 1s remain
|
||||
Assert.Equal(18f, rig.Resolved, 3);
|
||||
|
||||
rig.Tick(.5f); // the consumable's duration has run out
|
||||
Assert.Equal(12f, rig.Resolved, 3);
|
||||
}
|
||||
|
||||
// Constants add, and compose with scales through PerformanceStat.Evaluate: (base x scale) + constant. +3 and +4
|
||||
// resolve as a constant of 7; with a x2 scale beside them the value is Evaluate's for scale 2 and constant 7,
|
||||
// (10 x 2) + 7. Mutations at ConstantModifier: `result = Math.Max(result, value)` reads constant 4;
|
||||
// `result += value * 2` reads constant 14.
|
||||
[Theory]
|
||||
[InlineData(new[] { 3f, 4f }, new float[0], 1f, 7f, 17f)]
|
||||
[InlineData(new[] { 3f, 4f }, new[] { 2f }, 2f, 7f, 27f)]
|
||||
public void TwoConstantsOnOneStatAdd(float[] constants, float[] scales, float expectedScale, float expectedConstant, float expected)
|
||||
{
|
||||
var rig = BuildThrustRig(constants.Select(c => (StatModifierType.Constant, c))
|
||||
.Concat(scales.Select(s => (StatModifierType.Multiplier, s))).ToArray());
|
||||
using var _ = rig.Cache;
|
||||
rig.Tick(.1f);
|
||||
Assert.Equal(expectedScale, rig.Ship.Resolver.ScaleModifier(rig.Engine, rig.Thrust), 3);
|
||||
Assert.Equal(expectedConstant, rig.Ship.Resolver.ConstantModifier(rig.Engine, rig.Thrust), 3);
|
||||
Assert.Equal(expected, rig.Resolved, 3);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
tests/Aetheria.Shared.Tests/TestCatalog.cs
Normal file
53
tests/Aetheria.Shared.Tests/TestCatalog.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/* 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.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using GameCult.Caching;
|
||||
|
||||
// The catalog the game boots, for tests: ShipModCatalog.ResolveCatalog over GameData/Mods, the call ActionGameManager
|
||||
// makes. The game skips a broken package so a player still boots; a test never does, so an exclusion throws here and
|
||||
// a broken first-party package fails the suite instead of vanishing from it.
|
||||
internal static class TestCatalog
|
||||
{
|
||||
private static readonly Lazy<string> Shared = new Lazy<string>(() => ForRepo(RunStartTests.FindRepoRoot()));
|
||||
|
||||
// The repo's catalog with its first-party packages composed in, resolved once per test process.
|
||||
public static string Repo => Shared.Value;
|
||||
|
||||
// The shipped assembly's own [CultDocument] types and nothing the test assembly registers (CultDocumentRegistry.ForTypes
|
||||
// does not re-scan the AppDomain), so this assembly's fixture documents never become catalog globals the real catalog
|
||||
// must hold. The one registry every test that opens a real catalog, and the composition behind Repo, uses.
|
||||
public static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(typeof(ItemData).Assembly.GetTypes()
|
||||
.Where(t => t is { IsAbstract: false, IsInterface: false })
|
||||
.Where(t => t.GetCustomAttribute<CultDocumentAttribute>() != null));
|
||||
|
||||
// The catalog of the repository at repoRoot: <root>/GameData/Aetheria.cc plus <root>/GameData/Mods, composed into a
|
||||
// per-call temp directory that goes away with the process.
|
||||
public static string ForRepo(string repoRoot)
|
||||
{
|
||||
var work = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "aetheria-testcatalog-" + Guid.NewGuid().ToString("N"))).FullName;
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => { try { Directory.Delete(work, true); } catch (IOException) { } };
|
||||
return Resolve(Path.Combine(repoRoot, "GameData", "Aetheria.cc"), Path.Combine(repoRoot, "GameData", "Mods"), work);
|
||||
}
|
||||
|
||||
public static string Resolve(string shippedCatalog, string modsRoot, string workDir)
|
||||
{
|
||||
// Every directory under the first-party tree is a package. The game treats a directory without a ship.cc as not a
|
||||
// package (a stray folder is not an error for a player), so the compose never names it; a first-party package whose
|
||||
// ship.cc is gone must not vanish from the catalog the suite stages from.
|
||||
var lost = Directory.Exists(modsRoot)
|
||||
? Directory.GetDirectories(modsRoot).Where(directory => !File.Exists(Path.Combine(directory, "ship.cc"))).Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal).ToArray()
|
||||
: Array.Empty<string>();
|
||||
if (lost.Length > 0)
|
||||
throw new InvalidOperationException("First-party packages hold no ship.cc: " + string.Join(", ", lost));
|
||||
var (catalog, excluded) = ShipModCatalog.ResolveCatalog(shippedCatalog, Path.Combine(workDir, "derived.cc"), modsRoot, Registry());
|
||||
if (excluded.Length > 0)
|
||||
throw new InvalidOperationException("First-party packages were excluded from the catalog: " +
|
||||
string.Join("; ", excluded.Select(entry => entry.Package + ": " + entry.Reason)));
|
||||
return catalog;
|
||||
}
|
||||
}
|
||||
95
tests/Aetheria.Shared.Tests/TestCatalogTests.cs
Normal file
95
tests/Aetheria.Shared.Tests/TestCatalogTests.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
|
||||
// TestCatalog.Resolve: the catalog every RunStartTests partial stages from is the one the game boots.
|
||||
public sealed class TestCatalogTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _directory = new TempDirectory();
|
||||
private string Shipped => Path.Combine(_directory.Path, "Aetheria.cc");
|
||||
private string Mods => Path.Combine(_directory.Path, "Mods");
|
||||
private string Work => Path.Combine(_directory.Path, "Work");
|
||||
|
||||
public TestCatalogTests()
|
||||
{
|
||||
Directory.CreateDirectory(Mods);
|
||||
using var cache = AetheriaStores.Open(Shipped, catalogWritable: true, registry: TestCatalog.Registry());
|
||||
cache.Upsert(new HullData { Name = "Wasp", Shape = ShipAuthoringTests.Fixture().Hull.Shape });
|
||||
cache.FlushAsync().Wait();
|
||||
}
|
||||
|
||||
public void Dispose() => _directory.Dispose();
|
||||
|
||||
[Fact]
|
||||
public void NoModsMeansTheShippedCatalog()
|
||||
{
|
||||
var before = File.ReadAllBytes(Shipped);
|
||||
|
||||
Assert.Equal(Shipped, TestCatalog.Resolve(Shipped, Path.Combine(_directory.Path, "absent"), Work));
|
||||
Assert.Equal(Shipped, TestCatalog.Resolve(Shipped, Mods, Work));
|
||||
|
||||
Assert.Equal(SHA256.HashData(before), SHA256.HashData(File.ReadAllBytes(Shipped)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APackageReachesTheFixture()
|
||||
{
|
||||
ShipFixture.WritePackage(Mods, "mod.skiff");
|
||||
|
||||
var catalog = TestCatalog.Resolve(Shipped, Mods, Work);
|
||||
|
||||
Assert.NotEqual(Shipped, catalog);
|
||||
using var cache = AetheriaStores.Open(catalog, registry: TestCatalog.Registry());
|
||||
Assert.Equal("Skiff", cache.Get<HullData>(ShipModCatalog.HullKey("mod.skiff")).Name);
|
||||
Assert.Contains("Wasp", cache.GetAll<HullData>().Select(hull => hull.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ABrokenPackageFailsLoudly()
|
||||
{
|
||||
ShipFixture.WritePackage(Mods, "mod.good", hullName: "Good");
|
||||
ShipFixture.WritePackage(Mods, "mod.broken", hullName: "Broken");
|
||||
File.Delete(Path.Combine(Mods, "mod.broken", "skiff.glb"));
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() => TestCatalog.Resolve(Shipped, Mods, Work));
|
||||
|
||||
Assert.Contains("mod.broken", error.Message);
|
||||
Assert.DoesNotContain("mod.good", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AFirstPartyPackageComposesOverTheRealCatalog()
|
||||
{
|
||||
// A repository root holding the real shipped catalog (which has no TestCatalogGlobal record) and one valid package
|
||||
// under GameData/Mods, the layout TestCatalog.Repo resolves from.
|
||||
var root = Path.Combine(_directory.Path, "repo");
|
||||
Directory.CreateDirectory(Path.Combine(root, "GameData"));
|
||||
File.Copy(Path.Combine(RunStartTests.FindRepoRoot(), "GameData", "Aetheria.cc"), Path.Combine(root, "GameData", "Aetheria.cc"));
|
||||
ShipFixture.WritePackage(Path.Combine(root, "GameData", "Mods"), "mod.skiff", hullName: "FixtureSkiff");
|
||||
|
||||
var catalog = TestCatalog.ForRepo(root);
|
||||
|
||||
Assert.NotEqual(Path.Combine(root, "GameData", "Aetheria.cc"), catalog);
|
||||
// Opened as RunStartTests opens it: the shipped assembly's registry, the game's own global check.
|
||||
using var cache = AetheriaStores.Open(catalog, registry: TestCatalog.Registry());
|
||||
Assert.Equal("FixtureSkiff", cache.Get<HullData>(ShipModCatalog.HullKey("mod.skiff")).Name);
|
||||
Assert.NotEmpty(cache.GetAll<HullData>().Where(hull => hull.Name != "FixtureSkiff"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APackageWithoutItsShipFileFailsLoudly()
|
||||
{
|
||||
ShipFixture.WritePackage(Mods, "mod.good", hullName: "Good");
|
||||
ShipFixture.WritePackage(Mods, "mod.lost", hullName: "Lost");
|
||||
File.Delete(Path.Combine(Mods, "mod.lost", "ship.cc"));
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() => TestCatalog.Resolve(Shipped, Mods, Work));
|
||||
|
||||
Assert.Contains("mod.lost", error.Message);
|
||||
Assert.Contains("ship.cc", error.Message);
|
||||
Assert.DoesNotContain("mod.good", error.Message);
|
||||
}
|
||||
}
|
||||
274
tests/Aetheria.Shared.Tests/ThrottleLockTests.cs
Normal file
274
tests/Aetheria.Shared.Tests/ThrottleLockTests.cs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
using static CultMath.math;
|
||||
|
||||
// A ThrottleLock on an active consumable replaces the pilot's movement intent with full forward, read once at
|
||||
// the top of Ship.Update's active branch. Turn is untouched. Every observation is a thruster's Axis after a real
|
||||
// Ship.Update, with the effect started through Entity.ActivateConsumable.
|
||||
public sealed class ThrottleLockTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-throttlelock-" + Guid.NewGuid().ToString("N"));
|
||||
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
||||
|
||||
public ThrottleLockTests() => Directory.CreateDirectory(_root);
|
||||
public void Dispose() => Directory.Delete(_root, true);
|
||||
|
||||
private static GameplaySettings Settings() => new GameplaySettings
|
||||
{
|
||||
DefaultEntitySettings = new EntitySettings(),
|
||||
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
|
||||
QualityPriceModifier = new ExponentialLerp()
|
||||
};
|
||||
|
||||
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
|
||||
|
||||
private static EquippableItem Mint(CultCache cache, ItemManager items, ItemData design, ItemRotation rotation = ItemRotation.None) => new EquippableItem
|
||||
{
|
||||
Data = cache.RefOf<ItemData>(design),
|
||||
Durability = design is EquippableItemData equippable ? equippable.Durability : 0,
|
||||
Rotation = rotation,
|
||||
Lot = items.Lots.Add(new Lot { Design = cache.RefOf<ItemData>(design), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
|
||||
};
|
||||
|
||||
private sealed class Fixture
|
||||
{
|
||||
public CultCache Cache;
|
||||
public ItemManager Items;
|
||||
public Ship Ship;
|
||||
public ConsumableItemData Lock;
|
||||
public Thruster Forward, Reverse, Clockwise;
|
||||
public Thruster[] Right, Left; // pairs with opposing torque: a lone off-axis strafe thruster cancels its own torque
|
||||
|
||||
public void ActivateLock() => Ship.ActivateConsumable(new ConsumableItem
|
||||
{
|
||||
Data = Cache.RefOf<ItemData>(Lock),
|
||||
Lot = Items.Lots.Add(new Lot { Design = Cache.RefOf<ItemData>(Lock), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
|
||||
});
|
||||
}
|
||||
|
||||
// One thruster per intent direction, plus two reverse thrusters either side of the centre line, one of
|
||||
// which has clockwise torque (the one Turn reaches). Thrusters are found by their item's rotation, as Ship
|
||||
// sorts them.
|
||||
private Fixture Build(float lockDuration)
|
||||
{
|
||||
var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
||||
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
||||
var hullShape = new Shape(5, 5);
|
||||
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
|
||||
cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 });
|
||||
cache.Upsert(new GearData
|
||||
{
|
||||
Name = "Thruster", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
||||
MinimumTemperature = 0, MaximumTemperature = 1000, OptimalTemperature = 280, PlateauWidth = 400,
|
||||
Behaviors = { new ThrusterData { Thrust = Constant(100), Visibility = Constant(0), Heat = Constant(0), EnergyUsage = Constant(0) } }
|
||||
});
|
||||
var lockData = new ConsumableItemData { Name = "Overdrive", Duration = lockDuration, Behaviors = { new ThrottleLockData() } };
|
||||
cache.Upsert(lockData);
|
||||
cache.FlushAsync().Wait();
|
||||
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
|
||||
var ship = new Ship(items, zone, Mint(cache, items, cache.GetByName<HullData>("Skiff")), new EntitySettings());
|
||||
var gear = cache.GetByName<GearData>("Thruster");
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Reversed), new int2(2, 1)));
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.None), new int2(1, 2)));
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.None), new int2(3, 2)));
|
||||
// Strafe pairs sit either side of the centre line across the ship's length, so their torques oppose.
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.CounterClockwise), new int2(1, 1)));
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.CounterClockwise), new int2(1, 3)));
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Clockwise), new int2(3, 1)));
|
||||
Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Clockwise), new int2(3, 3)));
|
||||
zone.Entities.Add(ship);
|
||||
ship.Aim = float3(0, 0, 1);
|
||||
ship.Activate();
|
||||
|
||||
var thrusters = ship.GetBehaviors<Thruster>().ToArray();
|
||||
return new Fixture
|
||||
{
|
||||
Cache = cache, Items = items, Ship = ship, Lock = cache.GetByName<ConsumableItemData>("Overdrive"),
|
||||
Forward = thrusters.Single(t => t.Item.EquippableItem.Rotation == ItemRotation.Reversed),
|
||||
Right = thrusters.Where(t => t.Item.EquippableItem.Rotation == ItemRotation.CounterClockwise).ToArray(),
|
||||
Left = thrusters.Where(t => t.Item.EquippableItem.Rotation == ItemRotation.Clockwise).ToArray(),
|
||||
Reverse = thrusters.First(t => t.Item.EquippableItem.Rotation == ItemRotation.None && t.Torque <= 0),
|
||||
Clockwise = thrusters.First(t => t.Item.EquippableItem.Rotation == ItemRotation.None && t.Torque > 0)
|
||||
};
|
||||
}
|
||||
|
||||
// Spec ALockedThrottleIgnoresIntent. Mutation: read MovementDirection in place of move at any of the six
|
||||
// sites in Ship.Update and the matching assertion goes red.
|
||||
[Fact]
|
||||
public void ALockedThrottleIgnoresIntent()
|
||||
{
|
||||
var f = Build(100f);
|
||||
using var _ = f.Cache;
|
||||
f.ActivateLock();
|
||||
Assert.True(f.Ship.ThrottleLocked);
|
||||
|
||||
foreach (var intent in new[] { float2(0, -1), float2(1, 0), float2(-1, 0) })
|
||||
{
|
||||
f.Ship.MovementDirection = intent;
|
||||
f.Ship.Turn = 0;
|
||||
f.Ship.Update(0.01f);
|
||||
Assert.Equal(1f, f.Forward.Axis);
|
||||
Assert.Equal(0f, f.Reverse.Axis);
|
||||
Assert.All(f.Right, t => Assert.Equal(0f, t.Axis));
|
||||
Assert.All(f.Left, t => Assert.Equal(0f, t.Axis));
|
||||
}
|
||||
|
||||
// Turning stays with the pilot: the clockwise-torque thruster follows Turn while the lock holds.
|
||||
f.Ship.MovementDirection = float2(0, -1);
|
||||
f.Ship.Turn = 1;
|
||||
f.Ship.Update(0.01f);
|
||||
Assert.Equal(1f, f.Clockwise.Axis);
|
||||
f.Ship.Turn = 0;
|
||||
f.Ship.Update(0.01f);
|
||||
Assert.Equal(0f, f.Clockwise.Axis);
|
||||
}
|
||||
|
||||
// Spec TheLockEndsWithItsEffect. Mutation: make ThrottleLocked a latch (set on activation, never cleared)
|
||||
// and the post-expiry reverse axis stays 0.
|
||||
[Fact]
|
||||
public void TheLockEndsWithItsEffect()
|
||||
{
|
||||
var f = Build(1f);
|
||||
using var _ = f.Cache;
|
||||
f.ActivateLock();
|
||||
f.Ship.MovementDirection = float2(0, -1);
|
||||
f.Ship.Update(0.5f);
|
||||
Assert.Equal(0f, f.Reverse.Axis);
|
||||
|
||||
f.Ship.Update(2f); // runs past the Duration; the effect leaves at the end of this tick
|
||||
Assert.False(f.Ship.ThrottleLocked);
|
||||
|
||||
f.Ship.Update(0.01f);
|
||||
Assert.Equal(1f, f.Reverse.Axis);
|
||||
Assert.Equal(0f, f.Forward.Axis);
|
||||
}
|
||||
|
||||
// Spec AnUnlockedShipFollowsIntent. Mutation: replace the conditional by an unconditional float2(0, 1) and
|
||||
// the reverse and strafe assertions go red.
|
||||
[Fact]
|
||||
public void AnUnlockedShipFollowsIntent()
|
||||
{
|
||||
var f = Build(1f);
|
||||
using var _ = f.Cache;
|
||||
Assert.False(f.Ship.ThrottleLocked);
|
||||
|
||||
f.Ship.MovementDirection = float2(0, -1);
|
||||
f.Ship.Update(0.01f);
|
||||
Assert.Equal(1f, f.Reverse.Axis);
|
||||
Assert.Equal(0f, f.Forward.Axis);
|
||||
|
||||
f.Ship.MovementDirection = float2(1, 0);
|
||||
f.Ship.Update(0.01f);
|
||||
Assert.All(f.Right, t => Assert.Equal(1f, t.Axis));
|
||||
Assert.All(f.Left, t => Assert.Equal(0f, t.Axis));
|
||||
Assert.Equal(0f, f.Forward.Axis);
|
||||
|
||||
f.Ship.MovementDirection = float2(-1, 0);
|
||||
f.Ship.Update(0.01f);
|
||||
Assert.All(f.Left, t => Assert.Equal(1f, t.Axis));
|
||||
Assert.All(f.Right, t => Assert.Equal(0f, t.Axis));
|
||||
|
||||
f.Ship.MovementDirection = float2(0, 1);
|
||||
f.Ship.Update(0.01f);
|
||||
Assert.Equal(1f, f.Forward.Axis);
|
||||
Assert.Equal(0f, f.Reverse.Axis);
|
||||
}
|
||||
|
||||
// Finding zero-stick-unpinned. Mutation: float2(0, length(MovementDirection)) as the locked intent. The lock
|
||||
// supplies full forward whatever the stick's magnitude, including none and a short reverse push.
|
||||
[Fact]
|
||||
public void ALockedShipGetsFullForwardWhateverTheStickLength()
|
||||
{
|
||||
var f = Build(100f);
|
||||
using var _ = f.Cache;
|
||||
f.ActivateLock();
|
||||
foreach (var intent in new[] { float2(0, 0), float2(.3f, .2f), float2(-.5f, -.5f) })
|
||||
{
|
||||
f.Ship.MovementDirection = intent;
|
||||
f.Ship.Turn = 0;
|
||||
f.Ship.Update(0.01f);
|
||||
Assert.Equal(1f, f.Forward.Axis);
|
||||
Assert.Equal(0f, f.Reverse.Axis);
|
||||
Assert.All(f.Right, t => Assert.Equal(0f, t.Axis));
|
||||
Assert.All(f.Left, t => Assert.Equal(0f, t.Axis));
|
||||
}
|
||||
}
|
||||
|
||||
// Finding ccw-turn-unpinned. Mutation: drop the counter-clockwise Turn. Turn in either sign reaches only the
|
||||
// thrusters with torque of that sign, at full, while the lock holds forward.
|
||||
[Fact]
|
||||
public void ALockedShipTurnsBothWays()
|
||||
{
|
||||
var f = Build(100f);
|
||||
using var _ = f.Cache;
|
||||
f.ActivateLock();
|
||||
var all = f.Ship.GetBehaviors<Thruster>().ToArray();
|
||||
var cw = all.Where(t => t.Torque > 0 && t.Item.EquippableItem.Rotation == ItemRotation.None).ToArray();
|
||||
var ccw = all.Where(t => t.Torque < 0 && t.Item.EquippableItem.Rotation == ItemRotation.None).ToArray();
|
||||
Assert.NotEmpty(cw);
|
||||
Assert.NotEmpty(ccw);
|
||||
f.Ship.MovementDirection = float2(1, -1);
|
||||
foreach (var turn in new[] { 1f, -1f })
|
||||
{
|
||||
f.Ship.Turn = turn;
|
||||
f.Ship.Update(0.01f);
|
||||
Assert.All(turn > 0 ? cw : ccw, t => Assert.Equal(1f, t.Axis));
|
||||
Assert.All(turn > 0 ? ccw : cw, t => Assert.Equal(0f, t.Axis));
|
||||
Assert.Equal(1f, f.Forward.Axis);
|
||||
}
|
||||
}
|
||||
|
||||
// Finding non-lock-consumable-unpinned. Mutation: any active consumable locks. A consumable without
|
||||
// ThrottleLockData leaves the pilot's intent in force.
|
||||
[Fact]
|
||||
public void AConsumableWithoutTheLockDoesNotLock()
|
||||
{
|
||||
var f = Build(100f);
|
||||
using var _ = f.Cache;
|
||||
var plain = new ConsumableItemData { Name = "Plain", Duration = 100f };
|
||||
f.Cache.Upsert(plain);
|
||||
f.Ship.ActivateConsumable(new ConsumableItem
|
||||
{
|
||||
Data = f.Cache.RefOf<ItemData>(plain),
|
||||
Lot = f.Items.Lots.Add(new Lot { Design = f.Cache.RefOf<ItemData>(plain), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
|
||||
});
|
||||
Assert.False(f.Ship.ThrottleLocked);
|
||||
f.Ship.MovementDirection = float2(0, -1);
|
||||
f.Ship.Update(0.01f);
|
||||
Assert.Equal(1f, f.Reverse.Axis);
|
||||
Assert.Equal(0f, f.Forward.Axis);
|
||||
}
|
||||
|
||||
// Finding lock-end-boundary-unpinned, finding lock-end-at-90pct-unpinned. Mutations: the lock ends at half
|
||||
// Duration, at 0.8 or 0.9 of Duration, or one tick late. Duration 1 stepped in 0.25 s ticks (exact in
|
||||
// floats) locks the axes on exactly four ticks (reverse stays 0), the effect is gone when the fourth ends,
|
||||
// and the fifth tick follows the stick. The count scales with Duration: 2 s locks eight ticks.
|
||||
[Theory]
|
||||
[InlineData(1f, 4)]
|
||||
[InlineData(2f, 8)]
|
||||
public void TheLockEndsAtTheExactTickOfItsDuration(float duration, int lockedTicks)
|
||||
{
|
||||
var f = Build(duration);
|
||||
using var _ = f.Cache;
|
||||
f.ActivateLock();
|
||||
f.Ship.MovementDirection = float2(0, -1);
|
||||
for (var i = 0; i < lockedTicks; i++)
|
||||
{
|
||||
Assert.True(f.Ship.ThrottleLocked);
|
||||
f.Ship.Update(.25f);
|
||||
Assert.Equal(0f, f.Reverse.Axis);
|
||||
Assert.Equal(1f, f.Forward.Axis);
|
||||
}
|
||||
Assert.False(f.Ship.ThrottleLocked);
|
||||
f.Ship.Update(.25f);
|
||||
Assert.Equal(1f, f.Reverse.Axis);
|
||||
Assert.Equal(0f, f.Forward.Axis);
|
||||
}
|
||||
}
|
||||
423
tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs
Normal file
423
tests/Aetheria.Shared.Tests/ThrustAllocatorTests.cs
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using CultMath;
|
||||
using Xunit;
|
||||
|
||||
namespace Aetheria.Shared.Tests;
|
||||
|
||||
// ThrustAllocator on synthetic columns. A column is (starboard m/s^2, forward m/s^2, clockwise yaw rad/s) per unit
|
||||
// throttle; the fixtures are written in deg/s the way the probes measured them (map TA3, TA12).
|
||||
public class ThrustAllocatorTests
|
||||
{
|
||||
const float Deg = MathF.PI / 180f;
|
||||
|
||||
// The drive carries a constant throttle of about 1.2e-4 (1.6e-4 on the halved hull) from the first touch of Turn, the
|
||||
// optimum of the ridge against the cost rows; recruitment past the onset is 1e-3 and more within .005 of Turn.
|
||||
const float LeakBound = 3e-4f;
|
||||
|
||||
static float3 Col(float starboard, float forward, float yawDeg) => new(starboard, forward, yawDeg * Deg);
|
||||
|
||||
// TA3, the Duel Longinus: two mismatched drives (0, 1) and two Talarias (2 clockwise, 3 counter-clockwise).
|
||||
static float3[] Duel() => new[]
|
||||
{
|
||||
Col(0, 53.94f, 49.24f), Col(0, 61.19f, -55.86f), Col(29.08f, 0, 166.26f), Col(-21.80f, 0, -124.63f),
|
||||
};
|
||||
|
||||
// The clockwise Talaria lost.
|
||||
static float3[] DamagedDuel() => new[]
|
||||
{
|
||||
Col(0, 53.94f, 49.24f), Col(0, 61.19f, -55.86f), default, Col(-21.80f, 0, -124.63f),
|
||||
};
|
||||
|
||||
// TA12's symmetric hull: mains, fore and aft laterals in torque-free pairs, a bow thruster.
|
||||
static float3[] Symmetric() => new[]
|
||||
{
|
||||
Col(0, 50, 20), Col(0, 50, -20), Col(20, 0, 80), Col(-20, 0, -80), Col(20, 0, -60), Col(-20, 0, 60), Col(0, -30, 0),
|
||||
};
|
||||
|
||||
// The hull's half-axis extremes, from the definition: the sum of a row's positive entries, or of the negated
|
||||
// negative ones. Axis 0 starboard, 1 forward, 2 clockwise yaw.
|
||||
static (float plus, float minus) Extremes(float3[] columns, int axis)
|
||||
{
|
||||
float plus = 0, minus = 0;
|
||||
foreach (var c in columns)
|
||||
{
|
||||
var v = axis == 0 ? c.x : axis == 1 ? c.y : c.z;
|
||||
if (v > 0) plus += v; else minus -= v;
|
||||
}
|
||||
return (plus, minus);
|
||||
}
|
||||
|
||||
static float3 Net(float3[] columns, float[] throttle)
|
||||
{
|
||||
float3 net = default;
|
||||
for (var i = 0; i < columns.Length; i++) net += columns[i] * throttle[i];
|
||||
return net;
|
||||
}
|
||||
|
||||
static float[] Solve(ThrustAllocator allocator, float3[] columns, float moveX, float moveY, float turn)
|
||||
{
|
||||
var throttle = new float[columns.Length];
|
||||
allocator.Allocate(columns, new float2(moveX, moveY), turn, throttle);
|
||||
return throttle;
|
||||
}
|
||||
|
||||
static float[] Solve(float3[] columns, float moveX, float moveY, float turn) => Solve(new ThrustAllocator(), columns, moveX, moveY, turn);
|
||||
|
||||
static float YawDeg(float3[] columns, float[] throttle) => Net(columns, throttle).z / Deg;
|
||||
|
||||
[Fact]
|
||||
public void AHoldIsExactWithMismatchedDrives()
|
||||
{
|
||||
var duel = Duel();
|
||||
var throttle = Solve(duel, 0, 1, 0);
|
||||
var clockwise = Extremes(duel, 2).plus;
|
||||
var net = Net(duel, throttle);
|
||||
Assert.True(MathF.Abs(net.z) <= 1e-5f * clockwise, $"yaw {net.z / Deg} deg/s");
|
||||
Assert.True(net.y >= .99f * Extremes(duel, 1).plus, $"forward {net.y}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
public void ALostDriveIsBalancedByWhatIsLeft(int zeroed)
|
||||
{
|
||||
var columns = Duel();
|
||||
var extreme = Extremes(columns, 2).plus;
|
||||
columns[zeroed] = default;
|
||||
var throttle = Solve(columns, 0, 1, 0);
|
||||
var net = Net(columns, throttle);
|
||||
Assert.True(MathF.Abs(net.z) <= 1e-5f * extreme, $"yaw {net.z / Deg} deg/s");
|
||||
Assert.Equal(0f, throttle[zeroed]);
|
||||
Assert.True(net.y > 0, $"forward {net.y}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1f)]
|
||||
[InlineData(.5f)]
|
||||
[InlineData(-.5f)]
|
||||
[InlineData(-1f)]
|
||||
public void ATurnIsThatFractionOfTheBoxExtreme(float turn)
|
||||
{
|
||||
var duel = Duel();
|
||||
var (plus, minus) = Extremes(duel, 2);
|
||||
var expected = turn * (turn > 0 ? plus : minus);
|
||||
var net = Net(duel, Solve(duel, 0, 0, turn));
|
||||
Assert.True(MathF.Abs(net.z - expected) <= 1e-4f * MathF.Abs(expected), $"yaw {net.z / Deg} expected {expected / Deg}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttitudeServesFirstAndTheOnsetIsTheColumnsRatio()
|
||||
{
|
||||
var duel = Duel();
|
||||
// The onset is the attitude thruster's yaw over the hull's extreme in that direction.
|
||||
var clockwiseOnset = duel[2].z / Extremes(duel, 2).plus;
|
||||
var counterOnset = -duel[3].z / Extremes(duel, 2).minus;
|
||||
Assert.InRange(clockwiseOnset, .7714f, .7716f);
|
||||
Assert.InRange(counterOnset, .6904f, .6906f);
|
||||
var allocator = new ThrustAllocator();
|
||||
for (var step = 0; step <= 1000; step++)
|
||||
{
|
||||
var turn = step * .001f;
|
||||
var drive = Solve(allocator, duel, 0, 0, turn)[0];
|
||||
if (turn <= clockwiseOnset - .005f) Assert.True(drive <= LeakBound, $"clockwise turn {turn}: drive {drive}");
|
||||
if (turn >= clockwiseOnset + .005f) Assert.True(drive > 1e-3f, $"clockwise turn {turn}: drive {drive}");
|
||||
}
|
||||
allocator = new ThrustAllocator();
|
||||
for (var step = 0; step <= 1000; step++)
|
||||
{
|
||||
var turn = step * .001f;
|
||||
var drive = Solve(allocator, duel, 0, 0, -turn)[1];
|
||||
if (turn <= counterOnset - .005f) Assert.True(drive <= LeakBound, $"counter-clockwise turn {turn}: drive {drive}");
|
||||
if (turn >= counterOnset + .005f) Assert.True(drive > 1e-3f, $"counter-clockwise turn {turn}: drive {drive}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheOnsetMovesWithTheColumns()
|
||||
{
|
||||
// Clockwise Talaria lost: the drive serves a clockwise turn from the first touch.
|
||||
var damaged = DamagedDuel();
|
||||
var small = Solve(damaged, 0, 0, .01f);
|
||||
Assert.True(small[0] > 0, $"drive {small[0]}");
|
||||
Assert.True(MathF.Abs(YawDeg(damaged, small) - .01f * 49.24f) <= .01f * 49.24f * 1e-3f, $"yaw {YawDeg(damaged, small)}");
|
||||
var full = Solve(damaged, 0, 0, 1f);
|
||||
Assert.True(MathF.Abs(YawDeg(damaged, full) - 49.24f) <= 49.24f * 1e-3f, $"yaw {YawDeg(damaged, full)}");
|
||||
|
||||
// Clockwise Talaria halved: the onset is its new share of the clockwise extreme.
|
||||
var halved = Duel();
|
||||
halved[2] = halved[2] * .5f;
|
||||
var onset = halved[2].z / Extremes(halved, 2).plus;
|
||||
Assert.InRange(onset, .627f, .629f);
|
||||
var allocator = new ThrustAllocator();
|
||||
for (var step = 0; step <= 1000; step++)
|
||||
{
|
||||
var turn = step * .001f;
|
||||
var drive = Solve(allocator, halved, 0, 0, turn)[0];
|
||||
if (turn <= onset - .005f) Assert.True(drive <= LeakBound, $"turn {turn}: drive {drive}");
|
||||
if (turn >= onset + .005f) Assert.True(drive > 1e-3f, $"turn {turn}: drive {drive}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryThrottleIsContinuousInTurn()
|
||||
{
|
||||
foreach (var columns in new[] { Duel(), DamagedDuel(), Symmetric() })
|
||||
{
|
||||
var allocator = new ThrustAllocator();
|
||||
var previous = Solve(allocator, columns, 0, 0, -1f);
|
||||
for (var step = -999; step <= 1000; step++)
|
||||
{
|
||||
var current = Solve(allocator, columns, 0, 0, step * .001f);
|
||||
for (var i = 0; i < columns.Length; i++)
|
||||
Assert.True(MathF.Abs(current[i] - previous[i]) <= .02f, $"turn {step * .001f}: throttle {i} {previous[i]} -> {current[i]}");
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PureTorqueNeedsNoTranslation()
|
||||
{
|
||||
var hull = Symmetric();
|
||||
var allocator = new ThrustAllocator();
|
||||
var starboard = Extremes(hull, 0).plus;
|
||||
var forward = Extremes(hull, 1).plus;
|
||||
for (var step = -87; step <= 87; step++)
|
||||
{
|
||||
var net = Net(hull, Solve(allocator, hull, 0, 0, step * .01f));
|
||||
Assert.True(MathF.Abs(net.x) <= 1e-3f * starboard, $"turn {step * .01f}: starboard {net.x}");
|
||||
Assert.True(MathF.Abs(net.y) <= 1e-3f * forward, $"turn {step * .01f}: forward {net.y}");
|
||||
}
|
||||
var strafe = Net(hull, Solve(hull, 1, 0, 0));
|
||||
Assert.True(strafe.x >= .9f * starboard, $"strafe {strafe.x} of {starboard}");
|
||||
Assert.True(MathF.Abs(strafe.z) <= 1e-3f * Extremes(hull, 2).plus, $"yaw {strafe.z / Deg}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TurnComesBeforeTranslation()
|
||||
{
|
||||
var duel = Duel();
|
||||
var throttle = Solve(duel, 0, 1, 1f);
|
||||
var net = Net(duel, throttle);
|
||||
var extreme = Extremes(duel, 2).plus;
|
||||
Assert.True(MathF.Abs(net.z - extreme) <= 1e-4f * extreme, $"yaw {net.z / Deg} of {extreme / Deg}");
|
||||
Assert.True(throttle[1] < .01f, $"counter-turning drive {throttle[1]}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MovementIsThatFractionOfTheHalfAxis()
|
||||
{
|
||||
// A torque-free strafe pair of unequal strength and a torque-free main.
|
||||
var columns = new[] { Col(30, 0, 0), Col(-20, 0, 0), Col(0, 60, 0) };
|
||||
const float yaw = 1e-4f;
|
||||
var right = Net(columns, Solve(columns, .5f, 0, 0));
|
||||
Assert.True(MathF.Abs(right.x - 15f) <= 15e-3f && MathF.Abs(right.z) <= yaw, $"right {right.x}");
|
||||
var left = Net(columns, Solve(columns, -.5f, 0, 0));
|
||||
Assert.True(MathF.Abs(left.x + 10f) <= 10e-3f && MathF.Abs(left.z) <= yaw, $"left {left.x}");
|
||||
var ahead = Net(columns, Solve(columns, 0, .5f, 0));
|
||||
Assert.True(MathF.Abs(ahead.y - 30f) <= 30e-3f && MathF.Abs(ahead.z) <= yaw, $"ahead {ahead.y}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntentBeyondOneIsClamped()
|
||||
{
|
||||
var duel = Duel();
|
||||
Assert.Equal(Solve(duel, 0, 0, 1f), Solve(duel, 0, 0, 3f));
|
||||
Assert.Equal(Solve(duel, 1, -1, 0), Solve(duel, 3, -3, 0));
|
||||
Assert.Equal(Solve(duel, 0, 0, -1f), Solve(duel, 0, 0, -3f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NothingAskedNothingFired()
|
||||
{
|
||||
Assert.All(Solve(Duel(), 0, 0, 0), t => Assert.Equal(0f, t));
|
||||
Assert.All(Solve(new float3[4], 1, 1, 1), t => Assert.Equal(0f, t));
|
||||
new ThrustAllocator().Allocate(ReadOnlySpan<float3>.Empty, new float2(1, 1), 1, Span<float>.Empty);
|
||||
var withNaN = Duel();
|
||||
withNaN[2] = new float3(float.NaN, 0, 0);
|
||||
Assert.All(Solve(withNaN, 0, 1, 1), t => Assert.Equal(0f, t));
|
||||
var nanYaw = Duel();
|
||||
nanYaw[0] = new float3(0, 1, float.NaN);
|
||||
Assert.All(Solve(nanYaw, 0, 1, 0), t => Assert.Equal(0f, t));
|
||||
|
||||
// A bad column zeroes the answer even after a good call left throttles in the warm start and in the caller's buffer.
|
||||
var allocator = new ThrustAllocator();
|
||||
var buffer = new float[4];
|
||||
allocator.Allocate(Duel(), new float2(0, 1), .5f, buffer);
|
||||
Assert.Contains(buffer, t => t > .1f);
|
||||
allocator.Allocate(withNaN, new float2(0, 1), .5f, buffer);
|
||||
Assert.All(buffer, t => Assert.Equal(0f, t));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheStartDoesNotChangeTheAnswer()
|
||||
{
|
||||
var duel = Duel();
|
||||
var cold = Solve(duel, .3f, .8f, .6f);
|
||||
foreach (var previous in new[] { (1f, 1f, 1f), (-1f, -.5f, -1f), (0f, 0f, 0f) })
|
||||
{
|
||||
var allocator = new ThrustAllocator();
|
||||
Solve(allocator, duel, previous.Item1, previous.Item2, previous.Item3);
|
||||
var warm = Solve(allocator, duel, .3f, .8f, .6f);
|
||||
for (var i = 0; i < duel.Length; i++) Assert.True(MathF.Abs(warm[i] - cold[i]) <= 1e-5f, $"previous {previous}: throttle {i} {cold[i]} vs {warm[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualThrustersShareTheDemand()
|
||||
{
|
||||
var columns = new[] { Col(0, 50, 0), Col(0, 50, 0) };
|
||||
var throttle = Solve(columns, 0, .5f, 0);
|
||||
Assert.True(MathF.Abs(throttle[0] - throttle[1]) <= 1e-4f, $"{throttle[0]} vs {throttle[1]}");
|
||||
Assert.True(throttle[0] > .1f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AColumnCountChangeResets()
|
||||
{
|
||||
var four = Duel();
|
||||
var three = new[] { four[0], four[2], four[3] };
|
||||
var allocator = new ThrustAllocator();
|
||||
var steps = new List<(float3[] columns, float x, float y, float turn)>
|
||||
{
|
||||
(four, .2f, .9f, -.7f), (three, -.4f, 1f, .9f), (four, .2f, .9f, -.7f),
|
||||
};
|
||||
foreach (var (columns, x, y, turn) in steps)
|
||||
{
|
||||
var warm = Solve(allocator, columns, x, y, turn);
|
||||
var fresh = Solve(columns, x, y, turn);
|
||||
for (var i = 0; i < columns.Length; i++) Assert.True(MathF.Abs(warm[i] - fresh[i]) <= 1e-5f, $"{columns.Length} columns: throttle {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AShipWithOneThrusterIsFlownFromItsFirstCall()
|
||||
{
|
||||
var throttle = Solve(new[] { Col(0, 50, 0) }, 0, 1, 0);
|
||||
Assert.True(throttle[0] > .99f, $"{throttle[0]}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedColumnsOfTheSameCountLeaveNothingBehind()
|
||||
{
|
||||
// Thruster 1 first strafes, then only pushes forward: the strafe row it left must not shape the second answer.
|
||||
var strafing = new[] { Col(0, 60, 0), Col(30, 0, 0), Col(-20, 0, 0) };
|
||||
var forwardOnly = new[] { Col(0, 60, 0), Col(0, 40, 0), Col(0, -20, 0) };
|
||||
var allocator = new ThrustAllocator();
|
||||
Solve(allocator, strafing, 1, 1, .5f);
|
||||
var warm = Solve(allocator, forwardOnly, 0, 1, 0);
|
||||
var fresh = Solve(forwardOnly, 0, 1, 0);
|
||||
for (var i = 0; i < warm.Length; i++) Assert.True(MathF.Abs(warm[i] - fresh[i]) <= 1e-5f, $"throttle {i}: {warm[i]} vs {fresh[i]}");
|
||||
Assert.True(warm[1] > .9f, $"{warm[1]}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AtTheIterationCapTheIterateIsKept()
|
||||
{
|
||||
// 128 torque-free forward thrusters from a cold start: the active set frees about one throttle per iteration, so
|
||||
// the solver stops at its iteration cap (measured: IterationLimit at 100). TA-R4 keeps the iterate it reached,
|
||||
// which already carries a share of the forward demand; a call that zeroed it would give nothing.
|
||||
var forward = new float3[128];
|
||||
for (var i = 0; i < forward.Length; i++) forward[i] = Col(0, 30f + i % 7, 0);
|
||||
var throttle = Solve(forward, 0, 1, 0);
|
||||
var reached = Net(forward, throttle).y;
|
||||
var extreme = Extremes(forward, 1).plus;
|
||||
Assert.True(reached > .1f * extreme, $"{reached} of {extreme}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AFullTurnAgainstAFullReverseKeepsItsWholeYaw()
|
||||
{
|
||||
// The only clockwise actuator also pushes forward and the only reverse actuator turns the other way. Delivering
|
||||
// a full turn against a full reverse leaves a forward error of 2, the top of TA-R2's error box: an error bound
|
||||
// below 2 would take yaw back to pay for it, and turn-authority-full says the turn is whole.
|
||||
foreach (var (columns, x, y) in new[]
|
||||
{
|
||||
(new[] { Col(0, 50, 100), Col(0, -50, -100) }, 0f, -1f),
|
||||
(new[] { Col(50, 0, 100), Col(-50, 0, -100) }, -1f, 0f),
|
||||
(new[] { Col(0, -50, 100), Col(0, 50, -100) }, 0f, 1f),
|
||||
(new[] { Col(-50, 0, 100), Col(50, 0, -100) }, 1f, 0f),
|
||||
})
|
||||
{
|
||||
var yaw = Net(columns, Solve(columns, x, y, 1)).z;
|
||||
var extreme = Extremes(columns, 2).plus;
|
||||
Assert.True(MathF.Abs(yaw - extreme) <= 1e-4f * extreme, $"move ({x},{y}): yaw {yaw / Deg} of {extreme / Deg}");
|
||||
}
|
||||
}
|
||||
|
||||
// Forty thrusters of 20..60 m/s^2 on random half-axes with random yaw, the shape of a hull far larger than any demo
|
||||
// ship. Deterministic from the seed.
|
||||
static float3[] LargeHull(int n, int seed)
|
||||
{
|
||||
var rng = new System.Random(seed);
|
||||
var columns = new float3[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var a = 20f + 40f * (float)rng.NextDouble();
|
||||
var yaw = (float)(rng.NextDouble() * 2 - 1) * 120f;
|
||||
var dir = rng.Next(10);
|
||||
columns[i] = dir < 4 ? Col(0, a, yaw) : dir < 6 ? Col(0, -a, yaw) : dir < 8 ? Col(a, 0, yaw) : Col(-a, 0, yaw);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NothingAskedAfterAnIterationCapCallFiresNothing()
|
||||
{
|
||||
// On this hull the warm solve of a zero-intent call needs more than the solver's 100 iterations, and the iterate it
|
||||
// stopped at fired throttles up to 1.0 with nothing asked. Whether nothing is asked is decided from the intent, not
|
||||
// by how far the solver gets.
|
||||
var histories = new[]
|
||||
{
|
||||
(Seed: 9039, History: new[] { (0.0112729073f, -0.170811713f, 0.809294581f), (-0.474286854f, 0.703138232f, 0f), (0.294080615f, 0.934491992f, -0.776120305f) }),
|
||||
(Seed: 9079, History: new[] { (0.607828975f, 1f, 0.202640772f), (-0.620587587f, 0.0162956715f, -0.898228765f), (0.0739500523f, 1f, -0.903907537f) }),
|
||||
};
|
||||
foreach (var (seed, history) in histories)
|
||||
{
|
||||
var hull = LargeHull(40, seed);
|
||||
var allocator = new ThrustAllocator();
|
||||
foreach (var (x, y, turn) in history) Solve(allocator, hull, x, y, turn);
|
||||
Assert.All(Solve(allocator, hull, 0, 0, 0), t => Assert.Equal(0f, t));
|
||||
Assert.All(Solve(allocator, hull, 0, 0, 0), t => Assert.Equal(0f, t));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASmallIntentIsStillAnsweredWhateverTheColumnScale()
|
||||
{
|
||||
// Zero is a fact about the intent alone: an intent of 1e-3 of the half-axis is served at any scale of thrust.
|
||||
foreach (var scale in new[] { 1e-3f, 1f, 1e3f })
|
||||
{
|
||||
var hull = Duel();
|
||||
for (var i = 0; i < hull.Length; i++) hull[i] *= scale;
|
||||
var extreme = Extremes(hull, 2).plus;
|
||||
var yaw = Net(hull, Solve(hull, 0, 0, 1e-3f)).z;
|
||||
Assert.True(yaw > .5e-3f * extreme, $"scale {scale}: yaw {yaw} of {extreme}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NothingAskedAfterACallFiresNothing()
|
||||
{
|
||||
// The warm start left by a call must not leak into a hold of zero intent: exactly zero, not solver residue.
|
||||
foreach (var hull in new[] { Duel(), Symmetric() })
|
||||
foreach (var previous in new[] { (.5f, .5f, .5f), (1f, 1f, 1f), (-.3f, .8f, .6f), (0f, 0f, -1f), (0f, 1f, 0f) })
|
||||
{
|
||||
var allocator = new ThrustAllocator();
|
||||
Solve(allocator, hull, previous.Item1, previous.Item2, previous.Item3);
|
||||
Assert.All(Solve(allocator, hull, 0, 0, 0), t => Assert.Equal(0f, t));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheDriveCarriesTheLinearCostsLeakBeforeTheOnset()
|
||||
{
|
||||
// The error variables are paid linearly toward zero (TA-R2): that cost, against the ridge, holds the drive at a
|
||||
// small constant throttle before the onset, 1.30e-4 at turn .5 and 1.14e-4 at turn -.5 on the Duel (a cost row
|
||||
// pulling the errors the other way gives 1.06e-4 and 0.93e-4). The bounds are the measured value less 8%.
|
||||
var duel = Duel();
|
||||
Assert.InRange(Solve(duel, 0, 0, .5f)[0], 1.2e-4f, LeakBound);
|
||||
Assert.InRange(Solve(duel, 0, 0, -.5f)[1], 1.05e-4f, LeakBound);
|
||||
}
|
||||
}
|
||||
431
tests/Aetheria.Shared.Tests/VapourCloudTests.cs
Normal file
431
tests/Aetheria.Shared.Tests/VapourCloudTests.cs
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using CultMath;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using UniRx;
|
||||
using Xunit;
|
||||
using static CultMath.math;
|
||||
using float2 = CultMath.float2;
|
||||
using float3 = CultMath.float3;
|
||||
using int2 = CultMath.int2;
|
||||
|
||||
// Vapour clouds in the sim (aetheria-release cut vapour-cloud, ruling vapour-cloud-obscuration): a VapourDump
|
||||
// behaviour vents a zone cloud that thins every sensor sight line through it, both ways, through Sensor.Gain's
|
||||
// one detection rule; a lock falls by the existing detection-threshold rule and nothing else. The scene is two
|
||||
// ships facing each other along z with a fixed visibility, so what a sensor gathers is a number the clouds
|
||||
// alone change: steady info is visibility x sensitivity / (distance x TargetInfoDecay), and visibility 100,
|
||||
// sensitivity 1, decay .5 puts it at .5 at range 400 (above the .1 threshold) and saturated at range 40.
|
||||
public sealed class VapourCloudTests : IDisposable
|
||||
{
|
||||
private const float Dt = .05f;
|
||||
private const float Threshold = .1f;
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-vapour-" + Guid.NewGuid().ToString("N"));
|
||||
private readonly List<CultCache> _caches = new List<CultCache>();
|
||||
private static readonly object Glow = new object();
|
||||
|
||||
public VapourCloudTests() => Directory.CreateDirectory(_root);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var c in _caches) c.Dispose();
|
||||
Directory.Delete(_root, true);
|
||||
}
|
||||
|
||||
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
|
||||
|
||||
private static Shape Solid(int w, int h)
|
||||
{
|
||||
var shape = new Shape(w, h);
|
||||
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
||||
return shape;
|
||||
}
|
||||
|
||||
private sealed class Lab
|
||||
{
|
||||
public ItemManager Items;
|
||||
public Zone Zone;
|
||||
public Ship Observer;
|
||||
public Ship Target;
|
||||
public EquippedItem Dump;
|
||||
public LockWeapon Lock;
|
||||
public int Lot = 1;
|
||||
public bool HoldTarget = true;
|
||||
}
|
||||
|
||||
private Lab Build(float distance, float radius = 20f, float opacity = .9f, float lifetime = 1000f)
|
||||
{
|
||||
var settings = new GameplaySettings
|
||||
{
|
||||
DefaultEntitySettings = new EntitySettings(),
|
||||
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
|
||||
QualityPriceModifier = new ExponentialLerp(),
|
||||
TargetDetectionInfoThreshold = Threshold, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f,
|
||||
TargetInfoDecay = .5f, VisibilityDecay = 0f,
|
||||
FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 1f
|
||||
};
|
||||
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true);
|
||||
_caches.Add(cache);
|
||||
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
||||
cache.Upsert(new HullData
|
||||
{
|
||||
Name = "Hull", HullType = HullType.Ship, Shape = Solid(5, 5), Durability = 1000000, Mass = 1000,
|
||||
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
|
||||
});
|
||||
GearData Gear(string name, HardpointType hardpoint, BehaviorData behavior) => new GearData
|
||||
{
|
||||
Name = name, Hardpoint = hardpoint, Shape = new Shape(), Durability = 1,
|
||||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||||
Behaviors = { behavior }
|
||||
};
|
||||
cache.Upsert(Gear("Eye", HardpointType.Tool, new SensorData
|
||||
{
|
||||
Sensitivity = Constant(1f),
|
||||
SensitivityCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } },
|
||||
PingBoost = Constant(.000004f), PingEnergy = Constant(0f), PingVisibility = Constant(5f),
|
||||
PingRange = Constant(300f), PingCooldown = Constant(4f)
|
||||
}));
|
||||
cache.Upsert(Gear("Vent", HardpointType.Tool, new VapourDumpData
|
||||
{
|
||||
Radius = Constant(radius), Opacity = Constant(opacity), Lifetime = Constant(lifetime)
|
||||
}));
|
||||
cache.Upsert(Gear("LockGun", HardpointType.Sensors, new LockWeaponData
|
||||
{
|
||||
Damage = Constant(0), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0),
|
||||
Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1),
|
||||
BurstTime = Constant(0), Cooldown = Constant(1000),
|
||||
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } },
|
||||
LockSpeed = Constant(1), SensorImpact = Constant(1), LockAngle = Constant(170),
|
||||
DirectionImpact = Constant(1), Decay = Constant(1)
|
||||
}));
|
||||
cache.FlushAsync().Wait();
|
||||
|
||||
var ledger = new ProvenanceLedger();
|
||||
for (var i = 1; i <= 50; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
|
||||
var items = new ItemManager(cache, ledger, settings, _ => { });
|
||||
var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 5000 }, new GalaxyZone { Name = "Vapour", Owner = null }, null);
|
||||
var lab = new Lab { Items = items, Zone = zone };
|
||||
|
||||
EquippableItem Make(string name) => new EquippableItem
|
||||
{
|
||||
Data = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>(name)), Durability = 1, Lot = lab.Lot++
|
||||
};
|
||||
Ship NewShip()
|
||||
{
|
||||
var hull = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
|
||||
var ship = new Ship(items, zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = lab.Lot++ }, new EntitySettings());
|
||||
Assert.True(ship.TryEquip(Make("Eye")));
|
||||
return ship;
|
||||
}
|
||||
|
||||
lab.Observer = NewShip();
|
||||
Assert.True(lab.Observer.TryEquip(Make("LockGun"), new int2(0, 0)));
|
||||
lab.Target = NewShip();
|
||||
var dump = Make("Vent");
|
||||
Assert.True(lab.Target.TryEquip(dump));
|
||||
lab.Dump = lab.Target.Equipment.Single(x => x.EquippableItem == dump);
|
||||
// Held off until a test pulls the trigger: the dump vents on its first run.
|
||||
lab.Dump.Enabled.Value = false;
|
||||
|
||||
zone.Entities.Add(lab.Observer);
|
||||
zone.Entities.Add(lab.Target);
|
||||
lab.Observer.Activate();
|
||||
lab.Target.Activate();
|
||||
lab.Observer.Position = float3(0, 0, 0);
|
||||
lab.Observer.Aim = float3(0, 0, 1);
|
||||
lab.Target.Position = float3(0, 0, distance);
|
||||
lab.Target.Aim = float3(0, 0, -1);
|
||||
lab.Observer.SetIff(lab.Target, true);
|
||||
lab.Target.SetIff(lab.Observer, true);
|
||||
zone.Update(0f);
|
||||
lab.Lock = lab.Observer.Weapons.OfType<LockWeapon>().Single();
|
||||
return lab;
|
||||
}
|
||||
|
||||
// One tick: both ships stay where they were put, facing each other, each a fixed visibility to the other's sensor.
|
||||
private static void Tick(Lab lab)
|
||||
{
|
||||
foreach (var ship in new[] { lab.Observer, lab.Target })
|
||||
{
|
||||
ship.VisibilitySources[Glow] = 100f;
|
||||
if (ship != lab.Target || lab.HoldTarget) ship.Velocity = float2.zero;
|
||||
}
|
||||
lab.Observer.Direction = float2(0, 1);
|
||||
lab.Target.Direction = float2(0, -1);
|
||||
lab.Zone.Update(Dt);
|
||||
}
|
||||
|
||||
private static void Run(Lab lab, int ticks)
|
||||
{
|
||||
for (var i = 0; i < ticks; i++) Tick(lab);
|
||||
}
|
||||
|
||||
private static float Info(Ship watcher, Ship watched) => watcher.EntityInfoGathered[watched];
|
||||
|
||||
// The observer has settled on the target and holds a full lock on it: the state a cloud has to break.
|
||||
private static void Establish(Lab lab)
|
||||
{
|
||||
Run(lab, 100);
|
||||
Assert.True(Info(lab.Observer, lab.Target) > Threshold, "the observer never gathered the target");
|
||||
Assert.Contains(lab.Target, lab.Observer.VisibleEnemies);
|
||||
lab.Observer.SetTarget(lab.Target);
|
||||
Run(lab, 100);
|
||||
Assert.True(lab.Lock.IsLocked, "the observer never locked the target");
|
||||
Assert.Empty(lab.Zone.Clouds);
|
||||
}
|
||||
|
||||
private static void Vent(Lab lab)
|
||||
{
|
||||
lab.Dump.Enabled.Value = true;
|
||||
for (var i = 0; i < 5 && lab.Zone.Clouds.Count == 0; i++) Tick(lab);
|
||||
Assert.Single(lab.Zone.Clouds);
|
||||
}
|
||||
|
||||
private static VapourCloud Hand(Zone zone, float2 at, float radius, float opacity, float lifetime = 1000f)
|
||||
{
|
||||
var cloud = new VapourCloud { Body = new KinematicBody { Position = at }, Radius = radius, Opacity = opacity, Lifetime = lifetime };
|
||||
zone.Vent(cloud);
|
||||
return cloud;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACloudBreaksALockAtRange()
|
||||
{
|
||||
var lab = Build(400f);
|
||||
Establish(lab);
|
||||
|
||||
Vent(lab);
|
||||
var ticks = 0;
|
||||
while (ticks++ < 600 && !lab.Observer.Target.Value.IsNone) Tick(lab);
|
||||
|
||||
Assert.True(lab.Observer.Target.Value.IsNone, "the cloud never broke the target");
|
||||
Assert.True(Info(lab.Observer, lab.Target) < Threshold, $"info {Info(lab.Observer, lab.Target)} is above the threshold");
|
||||
Run(lab, 3);
|
||||
Assert.Equal(0f, lab.Lock.Lock);
|
||||
Assert.DoesNotContain(lab.Target, lab.Observer.VisibleEnemies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AStrongCloseSensorBurnsThrough()
|
||||
{
|
||||
var lab = Build(40f);
|
||||
Establish(lab);
|
||||
var before = Info(lab.Observer, lab.Target);
|
||||
|
||||
Vent(lab);
|
||||
Run(lab, 600);
|
||||
|
||||
var after = Info(lab.Observer, lab.Target);
|
||||
Assert.True(after < before - .2f, $"the cloud thinned nothing: {before} -> {after}");
|
||||
Assert.True(after > Threshold, $"info {after} fell below the threshold");
|
||||
Assert.Equal(lab.Target, lab.Observer.Target.Value.Entity);
|
||||
Assert.True(lab.Lock.IsLocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheCloudBlindsItsVenter()
|
||||
{
|
||||
var lab = Build(400f);
|
||||
Run(lab, 100);
|
||||
Assert.True(Info(lab.Target, lab.Observer) > Threshold, "the venter never gathered the observer");
|
||||
Assert.Contains(lab.Observer, lab.Target.VisibleEnemies);
|
||||
|
||||
Vent(lab);
|
||||
Run(lab, 300);
|
||||
|
||||
Assert.True(Info(lab.Target, lab.Observer) < Threshold);
|
||||
Assert.DoesNotContain(lab.Observer, lab.Target.VisibleEnemies);
|
||||
// (The observer updates before the venter in the tick that vents, so it is one tick behind.)
|
||||
Assert.Equal(Info(lab.Observer, lab.Target), Info(lab.Target, lab.Observer), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACloudFadesAndIsRemoved()
|
||||
{
|
||||
var lab = Build(400f);
|
||||
// Vented 3s into the zone's life: the fade runs from the vent, not from the zone's start.
|
||||
for (var i = 0; i < 3; i++) lab.Zone.Update(1f);
|
||||
var cloud = Hand(lab.Zone, float2(0, 0), 10f, .8f, lifetime: 10f);
|
||||
var a = float2(-5, 0);
|
||||
var b = float2(5, 0);
|
||||
Assert.Equal(.2f, lab.Zone.Obscuration(a, b), 5);
|
||||
|
||||
for (var i = 0; i < 5; i++) lab.Zone.Update(1f);
|
||||
Assert.Equal(.4f, cloud.OpacityAt(lab.Zone.Time), 5);
|
||||
Assert.Equal(.6f, lab.Zone.Obscuration(a, b), 5);
|
||||
Assert.Single(lab.Zone.Clouds);
|
||||
|
||||
for (var i = 0; i < 5; i++) lab.Zone.Update(1f);
|
||||
Assert.Empty(lab.Zone.Clouds);
|
||||
Assert.Equal(1f, lab.Zone.Obscuration(a, b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACloudDriftsWithItsVelocityUntilItFades()
|
||||
{
|
||||
var lab = Build(400f);
|
||||
var cloud = new VapourCloud { Body = new KinematicBody { Position = float2(0, 0), Velocity = float2(10, 0) }, Radius = 5f, Opacity = .5f, Lifetime = 1000f };
|
||||
lab.Zone.Vent(cloud);
|
||||
Assert.Equal(.5f, lab.Zone.Obscuration(float2(0, -20), float2(0, 20)), 5);
|
||||
|
||||
lab.Zone.Update(1f);
|
||||
|
||||
// The launch velocity decays on the zone's launch drag, so it has moved most of the way and no further.
|
||||
Assert.InRange(cloud.Body.Position.x, 5f, 9.9f);
|
||||
Assert.Equal(1f, lab.Zone.Obscuration(float2(0, -20), float2(0, 20)));
|
||||
Assert.Equal(.5f, lab.Zone.Obscuration(float2(cloud.Body.Position.x, -20), float2(cloud.Body.Position.x, 20)), 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACloudWithNoLifetimeObscuresNothing()
|
||||
{
|
||||
var lab = Build(400f);
|
||||
var cloud = Hand(lab.Zone, float2(0, 0), 10f, .8f, lifetime: 0f);
|
||||
Assert.Equal(0f, cloud.OpacityAt(lab.Zone.Time));
|
||||
Assert.Equal(1f, lab.Zone.Obscuration(float2(-5, 0), float2(5, 0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnEffectVentsOnce()
|
||||
{
|
||||
var lab = Build(400f, radius: 17f, opacity: .7f, lifetime: 123f);
|
||||
lab.HoldTarget = false;
|
||||
Run(lab, 2);
|
||||
lab.Target.Velocity = float2(3, 0);
|
||||
VapourCloud vented = null;
|
||||
float2 venterVelocity = float2.zero;
|
||||
float2 venterAt = float2.zero;
|
||||
float2 cloudVelocity = float2.zero;
|
||||
float2 cloudAt = float2.zero;
|
||||
using var sub = lab.Zone.Clouds.ObserveAdd().Subscribe(add =>
|
||||
{
|
||||
vented = add.Value;
|
||||
cloudVelocity = add.Value.Body.Velocity;
|
||||
cloudAt = add.Value.Body.Position;
|
||||
venterVelocity = lab.Target.Velocity;
|
||||
venterAt = lab.Target.Position.xz;
|
||||
});
|
||||
|
||||
lab.Dump.Enabled.Value = true;
|
||||
Run(lab, 100);
|
||||
|
||||
Assert.Single(lab.Zone.Clouds);
|
||||
Assert.Same(vented, lab.Zone.Clouds.Single());
|
||||
Assert.True(length(venterVelocity) > 0f, "the venter was not moving");
|
||||
Assert.Equal(venterVelocity, cloudVelocity);
|
||||
Assert.Equal(venterAt, cloudAt);
|
||||
Assert.Same(lab.Target, vented.Venter);
|
||||
// Each stat is the authored one, evaluated through the host.
|
||||
Assert.Equal(17f, vented.Radius);
|
||||
Assert.Equal(.7f, vented.Opacity);
|
||||
Assert.Equal(123f, vented.Lifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACloudThinsOnlyTheSightLinesItTouches()
|
||||
{
|
||||
var lab = Build(400f);
|
||||
var first = Hand(lab.Zone, float2(0, 0), 10f, .6f);
|
||||
float Through(float2 a, float2 b) => lab.Zone.Obscuration(a, b);
|
||||
|
||||
Assert.Equal(.4f, Through(float2(-50, 0), float2(50, 0)), 5);
|
||||
Assert.Equal(.4f, Through(float2(50, 0), float2(-50, 0)), 5);
|
||||
// Passing wide of the centre but within the radius, neither end inside the disc.
|
||||
Assert.Equal(.4f, Through(float2(-50, 9.9f), float2(50, 9.9f)), 5);
|
||||
Assert.Equal(1f, Through(float2(-50, 10.1f), float2(50, 10.1f)));
|
||||
// Ending inside the disc, and a point inside it (the observer inside its own cloud).
|
||||
Assert.Equal(.4f, Through(float2(-50, 0), float2(5, 0)), 5);
|
||||
Assert.Equal(.4f, Through(float2(3, 0), float2(3, 0)), 5);
|
||||
Assert.Equal(1f, Through(float2(30, 0), float2(30, 0)));
|
||||
// A segment that stops short of the disc, or starts past it, touches nothing even on the same line.
|
||||
Assert.Equal(1f, Through(float2(-50, 0), float2(-20, 0)));
|
||||
Assert.Equal(1f, Through(float2(20, 0), float2(50, 0)));
|
||||
|
||||
// Two clouds on one sight line multiply.
|
||||
var second = Hand(lab.Zone, float2(30, 0), 5f, .5f);
|
||||
Assert.NotEqual(first.Id, second.Id);
|
||||
Assert.Equal(.2f, Through(float2(-50, 0), float2(50, 0)), 5);
|
||||
Assert.Equal(.4f, Through(float2(-50, 0), float2(15, 0)), 5);
|
||||
}
|
||||
|
||||
// Where the rule is decided: once the start-up seed has decayed away, the info a sensor settles at is what
|
||||
// survives the cloud times what the open sight line settles at, in both directions.
|
||||
[Fact]
|
||||
public void TheGainIsScaledByWhatSurvivesTheCloud()
|
||||
{
|
||||
var clear = Build(400f);
|
||||
var cloudy = Build(400f);
|
||||
// Mid-way between them and clear of both: only the segment, not either end, touches it.
|
||||
Hand(cloudy.Zone, float2(0, 200), 10f, .6f, lifetime: 1e6f);
|
||||
Run(clear, 400);
|
||||
Run(cloudy, 400);
|
||||
|
||||
Assert.True(Info(clear.Observer, clear.Target) > Threshold);
|
||||
Assert.Equal(.4f, Info(cloudy.Observer, cloudy.Target) / Info(clear.Observer, clear.Target), 2);
|
||||
Assert.Equal(.4f, Info(cloudy.Target, cloudy.Observer) / Info(clear.Target, clear.Observer), 2);
|
||||
}
|
||||
|
||||
// A ping is gathered through the same rule. Settled, the tick that a ping reaches the target adds the ping gain
|
||||
// in place of the passive one; that extra, in a lab with a cloud on the sight line, is what survives the
|
||||
// cloud times the extra in the open (info is linear in the gain while nothing saturates).
|
||||
[Fact]
|
||||
public void ThePingGainIsScaledToo()
|
||||
{
|
||||
float PingExtra(Lab lab)
|
||||
{
|
||||
Run(lab, 400);
|
||||
lab.Observer.Sensor.Ping();
|
||||
var biggest = 0f;
|
||||
var last = Info(lab.Observer, lab.Target);
|
||||
for (var i = 0; i < 38; i++)
|
||||
{
|
||||
Tick(lab);
|
||||
biggest = max(biggest, Info(lab.Observer, lab.Target) - last);
|
||||
last = Info(lab.Observer, lab.Target);
|
||||
}
|
||||
return biggest;
|
||||
}
|
||||
|
||||
var clear = Build(250f);
|
||||
var cloudy = Build(250f);
|
||||
Hand(cloudy.Zone, float2(0, 125), 10f, .6f, lifetime: 1e6f);
|
||||
|
||||
var open = PingExtra(clear);
|
||||
Assert.True(open > .05f, $"the ping left no mark on the open trace ({open})");
|
||||
Assert.Equal(.4f, PingExtra(cloudy) / open, 2);
|
||||
}
|
||||
|
||||
// The chunk path reads the same rule: the passive rate toward a point is scaled by the same survivor fraction.
|
||||
[Fact]
|
||||
public void TheChunkRateIsScaledByTheSameFactor()
|
||||
{
|
||||
var lab = Build(100f);
|
||||
var at = float2(0, 100);
|
||||
var open = lab.Observer.Sensor.PassiveRate(100f, at);
|
||||
Assert.True(open > 0f);
|
||||
|
||||
Hand(lab.Zone, float2(0, 50), 10f, .6f);
|
||||
Assert.Equal(.4f, lab.Observer.Sensor.PassiveRate(100f, at) / open, 5);
|
||||
}
|
||||
|
||||
// The dump's wire shape: BehaviorData union 42, its radius, opacity and lifetime at keys 1, 2 and 3.
|
||||
[Fact]
|
||||
public void TheDumpIsUnion42WithRadiusOpacityLifetimeAtKeys1To3()
|
||||
{
|
||||
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly);
|
||||
var data = new VapourDumpData { Radius = Constant(1f), Opacity = Constant(2f), Lifetime = Constant(3f) };
|
||||
var bytes = MessagePackSerializer.Serialize<BehaviorData>(data, options);
|
||||
|
||||
var reader = new MessagePackReader(bytes);
|
||||
Assert.Equal(2, reader.ReadArrayHeader());
|
||||
Assert.Equal(42, reader.ReadInt32());
|
||||
Assert.Equal(4, reader.ReadArrayHeader());
|
||||
reader.Skip();
|
||||
Assert.Equal(1f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min);
|
||||
Assert.Equal(2f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min);
|
||||
Assert.Equal(3f, MessagePackSerializer.Deserialize<PerformanceStat>(ref reader, options).Min);
|
||||
}
|
||||
}
|
||||
|
|
@ -190,6 +190,34 @@ class ReadTests(ShipFileCase):
|
|||
ship_cc.read(self.path, PACKAGES)
|
||||
|
||||
|
||||
class RigCarryThroughTests(ShipFileCase):
|
||||
"""The rig (ShipAuthoring slot 5 Joints, ShipAnchor slot 5 Joint) lies past every slot this module edits, so a save keeps it."""
|
||||
|
||||
JOINTS = [["turret", None, [0.0, 1.0, 0.0], [-30.0], [30.0]]]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
ship = ship_body()
|
||||
ship.append(self.JOINTS)
|
||||
ship[ship_cc.ANCHORS_SLOT] = [["gun", "weapon-mount", "gun", None, 0, "turret"], ["map", "map-icon", "map", None, 0]]
|
||||
self.write(ship, hull_body())
|
||||
|
||||
def test_replace_visual_keeps_the_joints_and_each_mounts_joint_by_anchor_id(self):
|
||||
ship_cc.replace_visual(self.path, PACKAGES, "mod.skiff", "ship.glb",
|
||||
[["map", "map-icon", "map", None, 0], ["gun", "weapon-mount", "gun", None, 0]])
|
||||
ship = self.ship()
|
||||
self.assertEqual(self.JOINTS, ship[5])
|
||||
self.assertEqual([["map", "map-icon", "map", None, 0], ["gun", "weapon-mount", "gun", None, 0, "turret"]],
|
||||
ship[ship_cc.ANCHORS_SLOT])
|
||||
|
||||
def test_replace_lines_keeps_the_joints_and_the_anchor_rows(self):
|
||||
before = self.ship()
|
||||
ship_cc.replace_lines(self.path, PACKAGES, LINES)
|
||||
after = self.ship()
|
||||
self.assertEqual(self.JOINTS, after[5])
|
||||
self.assertEqual(before[ship_cc.ANCHORS_SLOT], after[ship_cc.ANCHORS_SLOT])
|
||||
|
||||
|
||||
class ReplaceLinesTests(ShipFileCase):
|
||||
def test_replaces_only_the_line_slot(self):
|
||||
new_lines = [["Detail", "Red", [0.0] * 6, [0.1, 0.1], [1.0, 1.0], True, [1.0, 0.0, 0.0, 1.0]]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue