Make the suite honest: digest repin, Crossing/MiningCut3 causes, authored cargo for Arcs and Pursuit, ConstantWeapon HUD, station descriptions #10
19 changed files with 286 additions and 55 deletions
|
|
@ -46,6 +46,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC
|
|||
{
|
||||
get => _ammo;
|
||||
}
|
||||
public override bool HasMagazine => false;
|
||||
|
||||
public float Progress
|
||||
{
|
||||
|
|
|
|||
|
|
@ -104,6 +104,12 @@ public abstract class Weapon : ActivatedBehavior
|
|||
public abstract int Ammo { get; }
|
||||
// Rounds in the ship's cargo that reload this weapon's magazine: crates of AmmoType, 0 when the weapon draws none.
|
||||
public int Reserve => _data.AmmoType.IsSet() ? Entity.CountItemsInCargo(_data.AmmoType.Key) : 0;
|
||||
// Whether Ammo counts a magazine. A weapon that names an AmmoType and fires from one (InstantWeapon) does; a beam (ConstantWeapon) draws one
|
||||
// crate per AmmoInterval straight from the cargo and has none.
|
||||
public virtual bool HasMagazine => true;
|
||||
// What the HUD's ammunition label shows: "magazine/reserve" for a magazine weapon, the reserve alone for one without a magazine, empty when
|
||||
// the weapon draws no ammunition. The one rule the HUD reads, so the label cannot show a magazine a weapon does not have.
|
||||
public string AmmoReadout => !_data.AmmoType.IsSet() ? "" : HasMagazine ? $"{Ammo}/{Reserve}" : Reserve.ToString();
|
||||
public WeaponData WeaponData => _data;
|
||||
public float RangeFactor(float at) => WeaponData.RangeFactor(MinRange, EffectiveRange, Range, _data.DamageAtMaxRange, at);
|
||||
|
||||
|
|
|
|||
|
|
@ -32,16 +32,13 @@ public static class Extensions
|
|||
|
||||
// Every loadable type assignable to this one. An assembly whose dependencies are missing (a Unity plugin
|
||||
// referencing UnityEngine outside Unity) still yields the types that do load.
|
||||
private static readonly Dictionary<Type, Type[]> ChildClasses = new Dictionary<Type, Type[]>();
|
||||
public static Type[] GetAllChildClasses(this Type type)
|
||||
// Read from parallel loaders (catalog validation, parallel tests), so the memo is concurrent.
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Type, Type[]> ChildClasses = new System.Collections.Concurrent.ConcurrentDictionary<Type, Type[]>();
|
||||
public static Type[] GetAllChildClasses(this Type type) => ChildClasses.GetOrAdd(type, t => AppDomain.CurrentDomain.GetAssemblies().SelectMany(assembly =>
|
||||
{
|
||||
if (ChildClasses.TryGetValue(type, out var children)) return children;
|
||||
return ChildClasses[type] = AppDomain.CurrentDomain.GetAssemblies().SelectMany(assembly =>
|
||||
{
|
||||
try { return assembly.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException e) { return e.Types.Where(t => t != null); }
|
||||
}).Where(type.IsAssignableFrom).ToArray();
|
||||
}
|
||||
try { return assembly.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException e) { return e.Types.Where(x => x != null); }
|
||||
}).Where(t.IsAssignableFrom).ToArray());
|
||||
|
||||
public static string SplitCamelCase(this string str) =>
|
||||
Regex.Replace(Regex.Replace(str, @"(\P{Ll})(\P{Ll}\p{Ll})", "$1 $2"), @"(\p{Ll})(\P{Ll})", "$1 $2");
|
||||
|
|
|
|||
|
|
@ -168,7 +168,8 @@ public sealed class ScenarioStage
|
|||
}
|
||||
|
||||
// Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage
|
||||
// chooses its product) or a product of one (pinned); a name that is neither is a failure.
|
||||
// chooses its product) or a product of one (pinned), or a commodity (one unit: the crates an AmmoType weapon reloads
|
||||
// from); a name that is none of these is a failure.
|
||||
public void Cargo(Entity entity, params string[] names)
|
||||
{
|
||||
if (entity == null) return;
|
||||
|
|
@ -177,6 +178,13 @@ public sealed class ScenarioStage
|
|||
var bay = entity.CargoBays.FirstOrDefault();
|
||||
foreach (var name in names)
|
||||
{
|
||||
var commodity = _items.ItemData.GetAll<SimpleCommodityData>().FirstOrDefault(data => data.Name == name);
|
||||
if (commodity != null)
|
||||
{
|
||||
if (bay == null || !bay.TryStore(new SimpleCommodity { Data = new CultRecordRef<ItemData>(_items.ItemData.RefOf<ItemData>(commodity).Key), Quantity = 1 }))
|
||||
_failures.Add($"{where}: no room for {name}");
|
||||
continue;
|
||||
}
|
||||
var (data, product) = NamedOrProduct<CraftedItemData>(name, where, found => found is EquippableItemData || found is ConsumableItemData);
|
||||
if (data == null) continue;
|
||||
var build = Loadouts.Resolve(_items, _items.ItemData.RefOf(data),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Linq;
|
||||
using static CultMath.math;
|
||||
using static ItemRotation;
|
||||
|
||||
|
|
@ -20,6 +21,8 @@ public sealed class Arcs : Scenario
|
|||
public static readonly CultMath.float2 Bow = float2(0, 400), Stern = float2(0, -400), Turret = float2(600, 300),
|
||||
Port = float2(-400, 0), Starboard = float2(400, 0);
|
||||
|
||||
public const int TurretAmmoCrates = 30;
|
||||
|
||||
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude();
|
||||
|
||||
public override void Stage(ScenarioStage stage)
|
||||
|
|
@ -49,12 +52,14 @@ public sealed class Arcs : Scenario
|
|||
("Compact Reactor", int2(3, 3), None),
|
||||
("Turret Control Module", int2(3, 5), None),
|
||||
("not if i see you first", int2(4, 5), None), // Compact Sensor
|
||||
("Panopticon Prime", int2(3, 6), None)); // Fire Control Array
|
||||
("Panopticon Prime", int2(3, 6), None), // Fire Control Array
|
||||
("Store-All Plus", int2(3, 1), None)); // Cargo Bay: the only 2x2 the turret hull has free
|
||||
|
||||
stage.Player(fighter, float2(0, 0), facing: float2(0, 1));
|
||||
stage.Place(stage.Bare("Longinus"), Bow);
|
||||
stage.Place(stage.Bare("Longinus"), Stern);
|
||||
stage.Place(turret, Turret, stance: ScenarioStance.Hostile);
|
||||
// The turret's guns reload from its hold, a crate of Ammo per 100-round magazine: both guns firing without pause spend about 3.5 crates a minute, so 30 outlast any session.
|
||||
stage.Cargo(stage.Place(turret, Turret, stance: ScenarioStance.Hostile), Enumerable.Repeat("Ammo", TurretAmmoCrates).ToArray());
|
||||
stage.Place(stage.Bare("Longinus"), Port);
|
||||
stage.Place(stage.Bare("Longinus"), Starboard);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ public sealed class Crossing : Scenario
|
|||
public override string Name => "Crossing";
|
||||
public override string Id => "crossing";
|
||||
public override string Brief => "A Lightning Projector at the starboard gun and a Cell Blaster at the port gun, an unpiloted Djinni coasting across the bow " +
|
||||
"close ahead and an unpiloted one waiting at range. The crosser is launched to port at 50 m/s and only coasts: nothing steers it, " +
|
||||
"its hull drag slows it to about 25 m/s at the bow line, and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " +
|
||||
"close ahead and an unpiloted one waiting at range. The crosser is launched to port at 25 m/s and only coasts: nothing steers it, " +
|
||||
"a hull coasts at its launch speed below its top speed, and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " +
|
||||
"(thrusting from the start carries you past it). Verify: as the Djinni crosses close, FastBlast lands some and projector " +
|
||||
"almost none; against the Djinni at range both land. Fly it again without the Fire Control Array: " +
|
||||
"with it the small gun lands more.";
|
||||
|
|
@ -16,13 +16,15 @@ public sealed class Crossing : Scenario
|
|||
|
||||
// Where the Djinni sit: the player is at Origin facing +z. Origin is 250 m or more from every body, where the zone's
|
||||
// pull is weakest (about 1.4 u/s^2; the sum of the wells never drops to nothing, and at the old origin the player sat on the star).
|
||||
// The crosser is unpiloted and placed moving at the Djinni's 50 m/s cap, so its pass is ballistic: bent by the field and slowed
|
||||
// by hull drag (it reaches the bow line at about 25 m/s relative to the player, 3.6 s in), and nothing steers it.
|
||||
// The crosser is unpiloted and placed moving at 25 m/s, well under the Djinni's 50 m/s top speed, so its pass is ballistic: a hull
|
||||
// coasts below its top speed (aetheria-gravity:ruling:no-drag-below-top-speed) and only the field bends it, so it reaches the
|
||||
// bow line at 25 m/s relative to the player, 3.6 s in, and nothing steers it. (Launched at the cap with drag slowing it to 25 m/s,
|
||||
// as this scenario was first tuned, it would now hold 50 m/s and cross in 2.6 s.)
|
||||
// CrosserAt is the centre of the start window in which the pass holds: shifting it by 10 m along either axis keeps the pass
|
||||
// inside the test's time and distance windows (CrossingHoldsAcrossTheStartWindow).
|
||||
// The waiting Djinni sits inside the Lightning Projector's arc at 80% of its Range.
|
||||
public static readonly CultMath.float2 Origin = float2(724, 124);
|
||||
public static readonly CultMath.float2 CrosserAt = Origin + float2(-128, 45), CrosserFacing = float2(1, 0), CrosserVelocity = float2(50, 0);
|
||||
public static readonly CultMath.float2 CrosserAt = Origin + float2(-90, 45), CrosserFacing = float2(1, 0), CrosserVelocity = float2(25, 0);
|
||||
// The fighter is staged at this quality, so its gear and the range below do not ride the item stream.
|
||||
public const float StagedQuality = .6f;
|
||||
// The Lightning Projector's Range in metres at the staged fit: 996.99 after the three seconds the test settles for
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Linq;
|
||||
using static CultMath.math;
|
||||
using static ItemRotation;
|
||||
|
||||
|
|
@ -7,6 +8,7 @@ public sealed class Pursuit : Scenario
|
|||
// cloud breaking a sensor's lock, and well outside the 40 at which AStrongCloseSensorBurnsThrough shows a close
|
||||
// sensor burning through.
|
||||
private const float Astern = 300;
|
||||
public const int MineCrates = 10;
|
||||
|
||||
public override string Name => "Pursuit";
|
||||
public override string Id => "pursuit";
|
||||
|
|
@ -55,7 +57,8 @@ public sealed class Pursuit : Scenario
|
|||
("Store-All Plus", int2(2, 8), None)); // Cargo Bay
|
||||
|
||||
var player = stage.Player(layer, float2(0, 0), facing: float2(0, 1));
|
||||
stage.Cargo(player, "Morning Fog", "Morning Fog", "Morning Fog", "send it", "send it");
|
||||
// The Mine Launcher reloads a magazine of six from a crate of Mines: ten crates are sixty-six mines, minutes of laying at its cooldown, more than a chase lasts.
|
||||
stage.Cargo(player, new[] { "Morning Fog", "Morning Fog", "Morning Fog", "send it", "send it" }.Concat(Enumerable.Repeat("Mines", MineCrates)).ToArray());
|
||||
stage.Place(pursuer, float2(0, -Astern), facing: float2(0, 1), stance: ScenarioStance.Hostile, piloted: true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ public class SchematicDisplay : MonoBehaviour
|
|||
x.ListElement.CooldownFill.anchorMax = new Vector2(x.Cooldown.Progress, 1);
|
||||
x.ListElement.DurabilityLabel.text = $"{(int)(x.Row.Item.EquippableItem.Durability / itemData.Durability * 100)}%";
|
||||
if (x.Weapon.WeaponData.AmmoType.IsSet())
|
||||
x.ListElement.AmmoLabel.text = $"{x.Weapon.Ammo}/{x.Weapon.Reserve}";
|
||||
x.ListElement.AmmoLabel.text = x.Weapon.AmmoReadout;
|
||||
}
|
||||
|
||||
if (x.ListElement.RangeLabel)
|
||||
|
|
|
|||
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
Binary file not shown.
|
|
@ -166,4 +166,32 @@ public sealed partial class RunStartTests
|
|||
}
|
||||
Assert.True(fed > 0, "fixture: some new-run player ship carries an ammunition-fed weapon");
|
||||
}
|
||||
|
||||
// The HUD's ammunition label (Weapon.AmmoReadout): a magazine gun reads "magazine/reserve", a beam that names an AmmoType has no magazine (it
|
||||
// draws a crate per interval) and reads its reserve alone, a weapon that draws nothing reads empty. Kills: a label that prints the beam's
|
||||
// never-filled magazine as "0/N", or one that drops the magazine from a gun's.
|
||||
[Fact]
|
||||
public void TheAmmoLabelShowsAMagazineOnlyForAWeaponThatHasOne()
|
||||
{
|
||||
var (hull, _) = FedFit(bay: true);
|
||||
var rounds = _cache.GetAll<WeaponItemData>().Single(item => item.Name == "Fed Gun").Behaviors.OfType<WeaponData>().Single().AmmoType;
|
||||
_cache.Upsert(new WeaponItemData
|
||||
{
|
||||
Name = "Fed Beam", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
|
||||
Behaviors = { new ConstantWeaponData { AmmoType = rounds } }
|
||||
});
|
||||
EquippableItemData Design(string name) => _cache.GetAll<EquippableItemData>().Single(item => item.Name == name);
|
||||
var loadout = new Loadout { Name = "label", Hull = _cache.RefOf(_cache.GetAll<HullData>().Single(h => h.Name == hull)), WeaponGroups = new int[0][] };
|
||||
foreach (var (name, x) in new[] { ("Fed Gun", 3), ("Fed Beam", 2), ("Plain Gun", 1), ("Fed Bay", 4) })
|
||||
loadout.Slots.Add(new LoadoutSlot { Position = int2(x, 3), Rotation = ItemRotation.None, Design = _cache.RefOf(Design(name)) });
|
||||
var failures = new List<string>();
|
||||
var ship = Loadouts.Materialize(_items, null, loadout, _ => null, failures);
|
||||
Assert.True(failures.Count == 0, string.Join("; ", failures));
|
||||
|
||||
string Label(string name) => ship.Weapons.Single(weapon => _items.GetData(weapon.Item.EquippableItem).Name == name).AmmoReadout;
|
||||
var reserve = LoadoutGenerator.AmmunitionCrates;
|
||||
Assert.Equal(reserve.ToString(), Label("Fed Beam"));
|
||||
Assert.Equal($"{ship.Weapons.Single(w => _items.GetData(w.Item.EquippableItem).Name == "Fed Gun").Ammo}/{reserve}", Label("Fed Gun"));
|
||||
Assert.Equal("", Label("Plain Gun"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ using float3 = CultMath.float3;
|
|||
public sealed partial class RunStartTests
|
||||
{
|
||||
// Crossing: the demo's cast; projector and Cell Blaster fitted in separate weapon groups with the Fire Control Array
|
||||
// aboard; an unpiloted hostile Djinni, coasting at the Djinni's speed cap, that crosses the bow inside 400 m; and an unpiloted neutral one inside the Lightning Projector's
|
||||
// aboard; an unpiloted hostile Djinni, coasting at 25 m/s, that crosses the bow inside 400 m; and an unpiloted neutral one inside the Lightning Projector's
|
||||
// arc, short of its Range and beyond three quarters of it.
|
||||
[Fact]
|
||||
public void CrossingConditions()
|
||||
|
|
@ -43,12 +43,12 @@ public sealed partial class RunStartTests
|
|||
Assert.True(toCrosser.z > 0, "the crosser starts ahead");
|
||||
Assert.True(abs(dot(crosser.Direction, player.Direction)) <= sin(radians(30f)) + 1e-4f,
|
||||
"the crosser faces within 30 degrees of perpendicular to the player's bow");
|
||||
// It is placed moving: toward the bow line at most of the Djinni's 50 m/s cap, so its run is its own and not only the
|
||||
// It is placed moving: toward the bow line at the scenario's 25 m/s, so its run is its own and not only the
|
||||
// arena's pull on the player.
|
||||
var crosserVelocity = ((Ship) crosser).Velocity;
|
||||
Assert.True(crosserVelocity.x * toCrosser.x < 0, "the crosser starts moving toward the bow line");
|
||||
Assert.True(abs(crosserVelocity.x) >= 40f, "the crosser's speed toward the bow line is most of the Djinni's speed cap");
|
||||
Assert.True(length(crosserVelocity) >= 40f, "the crosser starts at most of the Djinni's speed cap");
|
||||
Assert.Equal(25f, abs(crosserVelocity.x), 3);
|
||||
Assert.Equal(25f, length(crosserVelocity), 3);
|
||||
|
||||
Assert.False(player.IsHostileTo(waiting) || waiting.IsHostileTo(player), "the waiting Djinni is neutral");
|
||||
Assert.Equal(0, AgentsOf(arena, waiting));
|
||||
|
|
@ -71,7 +71,7 @@ public sealed partial class RunStartTests
|
|||
Assert.True(length(pass.Ahead) < CrossingMetres, "the crosser passes inside the stated distance");
|
||||
Assert.True(FireControl.InArc(small, float3(pass.Ahead.x, 0, pass.Ahead.y)), "Cell Blaster bears on the crosser as it crosses");
|
||||
Assert.True(dot(FireControl.MountDirection(small), float3(player.Direction.x, 0, player.Direction.y)) > 0, "Cell Blaster mounts forward");
|
||||
// At seed 112 with no input the pass is at about 3.6 s and 45 m; a seed, field or staging change that loses it moves these.
|
||||
// At seed 112 with no input the pass is at about 3.6 s and 45 m, at 25 m/s relative (a hull under its top speed coasts, so the launch speed holds); a seed, field or staging change that loses it moves these.
|
||||
Assert.InRange(pass.Seconds, CrossingEarliest, CrossingLatest);
|
||||
Assert.InRange(length(pass.Ahead), 30f, 60f);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,13 @@ public sealed partial class RunStartTests
|
|||
// go of a held weapon it no longer commands (target gone, untracked, another group the pick, Combat ended), where a held gun fired on
|
||||
// until a later step selected its group (the candidate change reverted: d9493bbe). Both together: the digest below. Releasing a beam or a
|
||||
// lock the same way (h7) moved nothing.
|
||||
private const string TwoAiPinnedDigest = "85e85c46cfc66693c819dd294ad02f3cd0eae5486c8f152a97e2e2787f854274";
|
||||
// Repinned an eighth time, once (suite-honest), for the flight-model changes that landed after h6 and never repinned this
|
||||
// entry (bisected on master, each intended): e0c1daa4e made ThrustScale default 0.1, 845f6c3aa removed drag below top speed,
|
||||
// ffdba39ac made ThrustScale scale the push only, and 8c5a904cb pinned the fixtures to ThrustScale=1. Pull requests 8 and 9
|
||||
// moved the digest and returned it to 52fbb10d, which master already measured before this cut. The authored-cargo and
|
||||
// ammunition-data blob of this cut moved nothing (52fbb10d measured with it merged in). Fixtures run ThrustScale=1 while play
|
||||
// uses 0.1, so this digest pins the fight's logic, not the shipped feel.
|
||||
private const string TwoAiPinnedDigest = "52fbb10df2559829a11f3a38aae0783f3f1858b0499f6f46794a267a747adacb";
|
||||
private const float TwoAiSeconds = 60f;
|
||||
|
||||
[Fact]
|
||||
|
|
@ -255,7 +261,7 @@ public sealed partial class RunStartTests
|
|||
Assert.Equal(first.digest, second.digest);
|
||||
}
|
||||
|
||||
private static string HostClass() =>
|
||||
internal static string HostClass() =>
|
||||
(RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux"
|
||||
: RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows"
|
||||
: RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" : "other")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,38 @@ using System.Linq;
|
|||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
|
||||
// What the calling thread writes to Console while an action runs. Console is process-global and tests run in parallel, so a plain
|
||||
// SetOut capture also collects other tests' diagnostic prints (the race that failed these tests in full-suite runs): the probe
|
||||
// keeps only writes made on the thread that runs the action, and the gate keeps two probes from restoring each other's writer.
|
||||
internal static class ConsoleProbe
|
||||
{
|
||||
private static readonly object Gate = new object();
|
||||
|
||||
public static string WrittenBy(Action act)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
var previous = Console.Out;
|
||||
var probe = new ThreadWriter(Environment.CurrentManagedThreadId);
|
||||
Console.SetOut(probe);
|
||||
try { act(); }
|
||||
finally { Console.SetOut(previous); }
|
||||
return probe.Mine.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThreadWriter : TextWriter
|
||||
{
|
||||
private readonly int _thread;
|
||||
public readonly System.Text.StringBuilder Mine = new System.Text.StringBuilder();
|
||||
public ThreadWriter(int thread) => _thread = thread;
|
||||
public override System.Text.Encoding Encoding => System.Text.Encoding.UTF8;
|
||||
public override void Write(char value) { if (Environment.CurrentManagedThreadId == _thread) lock (Mine) Mine.Append(value); }
|
||||
public override void Write(string value) { if (Environment.CurrentManagedThreadId == _thread) lock (Mine) Mine.Append(value); }
|
||||
public override void Write(char[] buffer, int index, int count) { if (Environment.CurrentManagedThreadId == _thread) lock (Mine) Mine.Append(buffer, index, count); }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EconomyCommandTests
|
||||
{
|
||||
private sealed class Lambda : EconomyBatch
|
||||
|
|
@ -146,10 +178,19 @@ public sealed class EconomyCommandTests
|
|||
public void TheTrialCopyIsRemoved()
|
||||
{
|
||||
using var f = EconomyFixture.Healthy();
|
||||
var before = Directory.GetDirectories(Path.GetTempPath(), "aetherdb-economy-*").Length;
|
||||
Author(f, false, out _, a => Good(f, a, "coal"));
|
||||
Author(f, true, out _, a => Good(f, a, "tar", lore: ""));
|
||||
Assert.Equal(before, Directory.GetDirectories(Path.GetTempPath(), "aetherdb-economy-*").Length);
|
||||
// A private scratch root: the temp folder is shared with every parallel test that runs the command, so counting it races.
|
||||
var scratchRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "aetherdb-economy-audit-" + Guid.NewGuid().ToString("N"))).FullName;
|
||||
try
|
||||
{
|
||||
foreach (var apply in new[] { false, true })
|
||||
{
|
||||
Flush(f);
|
||||
var batch = new Lambda(a => Good(f, a, apply ? "tar" : "coal", lore: apply ? "" : "a good"));
|
||||
EconomyCommand.Author(f.CatalogPath, new EconomyBatch[] { batch }, apply, new StringWriter(), TestCatalog.Registry(), scratchRoot);
|
||||
}
|
||||
Assert.Empty(Directory.GetFileSystemEntries(scratchRoot));
|
||||
}
|
||||
finally { Directory.Delete(scratchRoot, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -286,17 +327,13 @@ public sealed class EconomyCommandTests
|
|||
using var db = AetherDb.Open();
|
||||
var expected = new StringWriter();
|
||||
var expectedCode = EconomyCommand.Report(db.Cache, expected);
|
||||
var console = new StringWriter();
|
||||
var previous = Console.Out;
|
||||
Console.SetOut(console);
|
||||
var output = new StringWriter();
|
||||
int code;
|
||||
try { code = EconomyCommand.Run(new[] { "report" }, output); }
|
||||
finally { Console.SetOut(previous); }
|
||||
int code = 0;
|
||||
var console = ConsoleProbe.WrittenBy(() => code = EconomyCommand.Run(new[] { "report" }, output));
|
||||
Assert.Equal(expectedCode, code);
|
||||
Assert.NotEmpty(expected.ToString());
|
||||
Assert.Equal(expected.ToString(), output.ToString());
|
||||
Assert.Empty(console.ToString());
|
||||
Assert.Empty(console);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -306,17 +343,13 @@ public sealed class EconomyCommandTests
|
|||
var before = File.ReadAllBytes(path);
|
||||
var expected = new StringWriter();
|
||||
var expectedCode = EconomyCommand.Author(path, new EconomyBatch[] { new Munitions() }, false, expected);
|
||||
var console = new StringWriter();
|
||||
var previous = Console.Out;
|
||||
Console.SetOut(console);
|
||||
var output = new StringWriter();
|
||||
int code;
|
||||
try { code = EconomyCommand.Run(new[] { "author", "Munitions" }, output); }
|
||||
finally { Console.SetOut(previous); }
|
||||
int code = 0;
|
||||
var console = ConsoleProbe.WrittenBy(() => code = EconomyCommand.Run(new[] { "author", "Munitions" }, output));
|
||||
Assert.Equal(expectedCode, code);
|
||||
Assert.NotEmpty(expected.ToString());
|
||||
Assert.Equal(expected.ToString(), output.ToString());
|
||||
Assert.Empty(console.ToString());
|
||||
Assert.Empty(console);
|
||||
Assert.Equal(before, File.ReadAllBytes(path));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -308,9 +308,17 @@ public sealed partial class MiningCut3Tests : IDisposable
|
|||
|
||||
// ChunkInfo's value is not changed by moving its arithmetic under PassiveInfo: six observers at different
|
||||
// places and bearings, one with a second sensor so the average is live, read the same chunk, and every value
|
||||
// matches the bits recorded at base 12dfcef5, before the extraction (a different order of the multiplication
|
||||
// by the chunk's brightness moves them in the last bits).
|
||||
private static readonly uint[] ChunkInfoAtBase = new uint[] { 1045120030u, 1039274086u, 1030543422u, 1029120970u, 1036363351u, 1035122917u };
|
||||
// matches the bits the code before the extraction (base 12dfcef5) gives on the same host class (a different order of
|
||||
// the multiplication by the chunk's brightness moves them in the last bits). The bits are per host class because
|
||||
// the sensor's bearing term (acos, curve) is libm arithmetic, which rounds differently on Windows and Linux; replays
|
||||
// are trusted only on the host class that recorded them (ruling replays-per-host-class). windows-x64 is the recording the
|
||||
// test was written with; linux-x64 is base 12dfcef5 run on Linux, which gives 1039274088 where Windows gave 1039274086 for
|
||||
// the second observer, and the current code gives the same bits as that base.
|
||||
private static readonly System.Collections.Generic.Dictionary<string, uint[]> ChunkInfoAtBase = new()
|
||||
{
|
||||
["windows-x64"] = new uint[] { 1045120030u, 1039274086u, 1030543422u, 1029120970u, 1036363351u, 1035122917u },
|
||||
["linux-x64"] = new uint[] { 1045120030u, 1039274088u, 1030543422u, 1029120970u, 1036363351u, 1035122917u },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ChunkInfoIsBitIdenticalToItsValueBeforeTheExtraction()
|
||||
|
|
@ -329,7 +337,9 @@ public sealed partial class MiningCut3Tests : IDisposable
|
|||
|
||||
Assert.All(values, v => Assert.InRange(v, 1e-4f, .99f)); // a live, unsaturated reading at every place
|
||||
var actual = values.Select(Bits).ToArray();
|
||||
Assert.True(actual.SequenceEqual(ChunkInfoAtBase), "ChunkInfo bits: " + string.Join(",", actual));
|
||||
var host = RunStartTests.HostClass();
|
||||
Assert.True(ChunkInfoAtBase.TryGetValue(host, out var pinned), $"ChunkInfo bits: host class {host} has no recording; measured " + string.Join(",", actual));
|
||||
Assert.True(actual.SequenceEqual(pinned), $"ChunkInfo bits on {host}: " + string.Join(",", actual) + " pinned " + string.Join(",", pinned));
|
||||
}
|
||||
|
||||
// A chunk outside every sun's light is dark: no visibility, no info, and it cannot be picked, while a lit
|
||||
|
|
|
|||
67
tests/Aetheria.Shared.Tests/ScenarioAmmunitionTests.cs
Normal file
67
tests/Aetheria.Shared.Tests/ScenarioAmmunitionTests.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UniRx;
|
||||
using Xunit;
|
||||
|
||||
// Scenarios that author a weapon's ammunition-fed crates in a hold (ruling ballistics-real-ammo): the staged hold is what the weapon reloads from, so a
|
||||
// scenario whose check rests on a weapon firing past its first magazine states the crates it needs. Each test launches through the production path.
|
||||
public sealed partial class RunStartTests
|
||||
{
|
||||
// The Arcs turret fits two Light Machine Guns of a hundred rounds and a bay of Ammo: its hold holds what the scenario states, and across a minute
|
||||
// in range of the player it fires past both first magazines, each reload taking a crate. Kills: a turret fit without a bay or with no authored
|
||||
// crates (silent after one magazine), and Cargo failing to admit a commodity.
|
||||
[Fact]
|
||||
public void TheArcsTurretKeepsFiringPastItsFirstMagazine()
|
||||
{
|
||||
var (_, arena, staged, failures) = Launch(new Arcs(), Inputs(() => GalaxySeed));
|
||||
Assert.Empty(failures);
|
||||
var player = staged.Player;
|
||||
var turret = Assert.IsType<OrbitalEntity>(staged.Entities[2]);
|
||||
var guns = turret.Weapons.ToList();
|
||||
Assert.Equal(2, guns.Count);
|
||||
var ammo = guns[0].WeaponData.AmmoType.Key;
|
||||
Assert.Equal(Arcs.TurretAmmoCrates, turret.CountItemsInCargo(ammo));
|
||||
|
||||
// The guns are Auto weapons: they fire on a hostile inside their range (400 m) with no pilot. The player starts 775 m off, so it flies up to 200 m.
|
||||
arena.Update(0);
|
||||
player.Position = turret.Position + new CultMath.float3(-200, 0, 0);
|
||||
var shots = 0;
|
||||
using var sub = arena.ShotCommitted.Subscribe(outcome => { if (ReferenceEquals(outcome.Source, turret)) shots++; });
|
||||
for (var tick = 0; tick < System.MathF.Round(60f / SimClock.GameStep); tick++) arena.Update(SimClock.GameStep);
|
||||
|
||||
var firstMagazines = guns.Sum(gun => gun.WeaponData.MagazineSize);
|
||||
Assert.True(shots > firstMagazines, $"the turret fired {shots} rounds, no more than its two first magazines of {firstMagazines}");
|
||||
Assert.True(turret.CountItemsInCargo(ammo) < Arcs.TurretAmmoCrates, "the turret reloaded without taking a crate");
|
||||
}
|
||||
|
||||
// The Pursuit player's Mine Launcher draws its magazine of six from crates of Mines it carries beside the consumables: it lays past six, and the
|
||||
// crate count falls by one per reload. Kills: authored cargo with no Mines (at most six mines), a reload that takes no crate.
|
||||
[Fact]
|
||||
public void ThePursuitPlayerLaysMoreThanOneMagazineOfMinesFromItsCrates()
|
||||
{
|
||||
var (_, arena, staged, failures) = Launch(new Pursuit(), Inputs(() => GalaxySeed));
|
||||
Assert.Empty(failures);
|
||||
var player = staged.Player;
|
||||
var launcher = player.Weapons.Single(weapon => weapon.WeaponData is MineLayerData);
|
||||
var mines = launcher.WeaponData.AmmoType.Key;
|
||||
Assert.Equal(Pursuit.MineCrates, player.CountItemsInCargo(mines));
|
||||
Assert.Equal(3, HeldOf(player, "Coolant Vent"));
|
||||
|
||||
arena.Update(0);
|
||||
var laid = 0;
|
||||
using var sub = arena.Mines.ObserveAdd().Subscribe(_ => laid++);
|
||||
var reloaded = new List<int>();
|
||||
var last = Pursuit.MineCrates;
|
||||
for (var tick = 0; tick < System.MathF.Round(120f / SimClock.GameStep) && laid <= 18; tick++)
|
||||
{
|
||||
launcher.Activate();
|
||||
arena.Update(SimClock.GameStep);
|
||||
var crates = player.CountItemsInCargo(mines);
|
||||
if (crates != last) { reloaded.Add(last - crates); last = crates; }
|
||||
}
|
||||
|
||||
Assert.True(laid > launcher.WeaponData.MagazineSize, $"the player laid {laid} mines, no more than one magazine of {launcher.WeaponData.MagazineSize}");
|
||||
Assert.NotEmpty(reloaded);
|
||||
Assert.All(reloaded, step => Assert.Equal(1, step));
|
||||
}
|
||||
}
|
||||
37
tests/Aetheria.Shared.Tests/StationKindDescriptionTests.cs
Normal file
37
tests/Aetheria.Shared.Tests/StationKindDescriptionTests.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using System.Linq;
|
||||
using GameCult.Caching;
|
||||
using Xunit;
|
||||
|
||||
// A station kind's description is what a player and a planner read of what it does; the recipes at the kind are what it does.
|
||||
public sealed class StationKindDescriptionTests
|
||||
{
|
||||
// Every good the shipped catalog extracts at a station kind (a commodity recipe with no inputs; a gear recipe is a design on offer, not raw) is named in that kind's description.
|
||||
// Kills: a kind that gains an extraction recipe (Diamond at mining, Ammonia at volatiles) while its description still lists the old set.
|
||||
[Fact]
|
||||
public void EveryRawGoodAKindExtractsIsNamedInItsDescription()
|
||||
{
|
||||
using var db = AetherDb.Open();
|
||||
var cache = db.Cache;
|
||||
var unnamed = cache.GetAll<RecipeData>().Where(recipe => recipe.Inputs.Count == 0 && recipe.StationKind.IsSet())
|
||||
.Select(recipe => (Kind: cache.Get(recipe.StationKind), Output: cache.Get(recipe.Output)))
|
||||
.Where(pair => pair.Kind != null && (pair.Output is SimpleCommodityData || pair.Output is CompoundCommodityData) && !pair.Kind.Description.Contains(pair.Output.Name))
|
||||
.Select(pair => $"{pair.Kind.Name} extracts {pair.Output.Name}, which its description omits")
|
||||
.Distinct().OrderBy(line => line).ToList();
|
||||
Assert.True(unnamed.Count == 0, string.Join("\n", unnamed));
|
||||
}
|
||||
|
||||
// The two kinds the rule was cut for, by name, so the rule cannot pass by the catalog having lost the recipes.
|
||||
[Fact]
|
||||
public void MiningExtractsDiamondAndVolatilesAmmonia()
|
||||
{
|
||||
using var db = AetherDb.Open();
|
||||
var cache = db.Cache;
|
||||
string Extracted(string kindId) => string.Join(",", cache.GetAll<RecipeData>()
|
||||
.Where(recipe => recipe.Inputs.Count == 0 && recipe.StationKind.IsSet() && recipe.StationKind.Key.Equals(StationKindData.KeyOf(kindId)))
|
||||
.Select(recipe => cache.Get(recipe.Output).Name));
|
||||
Assert.Contains("Diamond", Extracted("mining"));
|
||||
Assert.Contains("Ammonia", Extracted("volatiles"));
|
||||
Assert.Contains("Diamond", cache.Get<StationKindData>(StationKindData.KeyOf("mining")).Description);
|
||||
Assert.Contains("Ammonia", cache.Get<StationKindData>(StationKindData.KeyOf("volatiles")).Description);
|
||||
}
|
||||
}
|
||||
24
tools/AetherDb/Economy/ExtractionKindDescriptions.cs
Normal file
24
tools/AetherDb/Economy/ExtractionKindDescriptions.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using System;
|
||||
using GameCult.Caching;
|
||||
|
||||
// Economy content, batch 1c: the mining and volatiles station-kind descriptions name every good their kind now extracts.
|
||||
// MunitionsFeed gave mining a Diamond recipe and volatiles an Ammonia recipe; the descriptions the station-round bench
|
||||
// wrote predate them. The same text is StationKindsCatalog.Table's, so a catalog authored fresh says the same thing.
|
||||
// Sets the description whatever it was, so a rerun converges.
|
||||
public sealed class ExtractionKindDescriptions : EconomyBatch
|
||||
{
|
||||
public override void Author(EconomyAuthor author)
|
||||
{
|
||||
foreach (var (id, description) in new[]
|
||||
{
|
||||
("mining", StationKindsCatalog.DescriptionOf("mining")),
|
||||
("volatiles", StationKindsCatalog.DescriptionOf("volatiles")),
|
||||
})
|
||||
{
|
||||
var key = StationKindData.KeyOf(id);
|
||||
var kind = author.Catalog.Get<StationKindData>(key) ?? throw new InvalidOperationException($"The catalog holds no station kind '{id}'.");
|
||||
kind.Description = description;
|
||||
author.Put(kind, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,15 +107,16 @@ public static class EconomyCommand
|
|||
// atomically, so a refusal leaves Aetheria.cc byte-identical. A batch is compiled code and is reviewed as code: it is trusted not
|
||||
// to touch the real file, and the command refuses by name if the file changed under it. Added, changed and unchanged are read by
|
||||
// comparing the original and the copy on disk. A shape fault the batches introduce (present after, absent before, by rule and
|
||||
// record) refuses; faults the catalog already held print and do not.
|
||||
public static int Author(string catalogPath, IReadOnlyList<EconomyBatch> batches, bool apply, TextWriter output, CultDocumentRegistry registry = null)
|
||||
// record) refuses; faults the catalog already held print and do not. The trial copy lives under scratchRoot (the temp folder unless a caller
|
||||
// that audits it names a private one).
|
||||
public static int Author(string catalogPath, IReadOnlyList<EconomyBatch> batches, bool apply, TextWriter output, CultDocumentRegistry registry = null, string scratchRoot = null)
|
||||
{
|
||||
if (apply && new FileInfo(catalogPath).IsReadOnly)
|
||||
{
|
||||
output.WriteLine("The batches are refused: Aetheria.cc is read-only. Clear the read-only flag, or check it out, and run again. The catalog is unchanged.");
|
||||
return 1;
|
||||
}
|
||||
var scratch = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "aetherdb-economy-" + Guid.NewGuid().ToString("N"))).FullName;
|
||||
var scratch = Directory.CreateDirectory(Path.Combine(scratchRoot ?? Path.GetTempPath(), "aetherdb-economy-" + Guid.NewGuid().ToString("N"))).FullName;
|
||||
try
|
||||
{
|
||||
var trial = Path.Combine(scratch, "Aetheria.cc");
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ public static class StationKindsCatalog
|
|||
|
||||
private static readonly (string id, string name, string description)[] Table =
|
||||
{
|
||||
("mining", "Mining Station", "Extracts ore (Rock, New Metals, Gold, Uranium, Potassium) from bodies and belts; consumes tools and food."),
|
||||
("volatiles", "Volatiles Station", "Skims Hydrogen, Methane and Water from gas giants and ice; consumes tools."),
|
||||
("mining", "Mining Station", "Extracts ore (Rock, New Metals, Gold, Uranium, Potassium) and Diamond from bodies and belts; consumes tools and food."),
|
||||
("volatiles", "Volatiles Station", "Skims Hydrogen, Methane, Ammonia and Water from gas giants and ice; consumes tools."),
|
||||
("agriculture", "Agriculture Station", "Grows Organics (Algae, Fungi, Bacteria, Animal Products) from Water and Potassium; feeds residential."),
|
||||
("processing", "Processing Station", "Refines ore and volatiles into Metals, Alloys and Compounds."),
|
||||
("fabrication", "Fabrication Station", "Turns Alloys and Compounds into Manufacturing compounds, Assemblies, gear, Ammo and consumer goods."),
|
||||
|
|
@ -33,6 +33,9 @@ public static class StationKindsCatalog
|
|||
("waystation", "Waystation", "Fuel, repair and medicine along a corridor."),
|
||||
};
|
||||
|
||||
// A kind's authored description: the one text a fresh catalog gets and an economy batch restates.
|
||||
public static string DescriptionOf(string id) => Table.Single(row => row.id == id).description;
|
||||
|
||||
public static int Run(CultCache cache, bool apply, TextWriter output)
|
||||
{
|
||||
var held = cache.GetAll<StationKindData>().Select(kind => cache.RefOf(kind).Key.Value).ToHashSet(StringComparer.Ordinal);
|
||||
|
|
|
|||
Loading…
Reference in a new issue