Pursuit scenario: Cargo stages consumables; a hostile on your tail, a Mine Launcher fitted, vents and overdrives in the hold

ScenarioStage.Cargo resolves one name across equippable and consumable
designs; a consumable is built from its first available product in key
order. Pursuit (seed 111, demo cast) lists last in Scenarios.Tests.
All commits build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 18:41:48 +02:00
commit 9ccc846c1a
6 changed files with 192 additions and 8 deletions

View file

@ -190,7 +190,7 @@ public static class Loadouts
return () => (EquippableItem) itemManager.CreateInstance(product);
}
private static FactionProductData[] ProductsInKeyOrder(CultCache cache) =>
internal static FactionProductData[] ProductsInKeyOrder(CultCache cache) =>
cache.GetAll<FactionProductData>().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();
private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}";

View file

@ -116,7 +116,9 @@ public sealed class ScenarioStage
return entity;
}
// Items in the entity's first cargo bay, one per design named.
// Items in the entity's first cargo bay, one per design named. A name is an equippable design (built as a fit
// builds it) or a consumable design (built from its first available product in key order, as a generated hold is
// stocked); a name that is neither is a failure.
public void Cargo(Entity entity, params string[] designs)
{
if (entity == null) return;
@ -124,10 +126,27 @@ public sealed class ScenarioStage
var bay = entity.CargoBays.FirstOrDefault();
foreach (var design in designs)
{
var data = Named<EquippableItemData>(design, where);
var data = Named<CraftedItemData>(design, where, found => found is EquippableItemData || found is ConsumableItemData);
if (data == null) continue;
var build = Loadouts.Resolve(_items, _items.ItemData.RefOf(data), _available, where, _failures);
if (build != null && (bay == null || !bay.TryStore(build())))
Func<CraftedItemInstance> build;
if (data is ConsumableItemData)
{
var key = _items.ItemData.RefOf(data).Key;
var product = Loadouts.ProductsInKeyOrder(_items.ItemData).Where(p => p.Design.Key.Equals(key)).FirstOrDefault(p => _available(p));
if (product == null)
{
_failures.Add($"{where}: no product offers {design}");
continue;
}
build = () => _items.CreateInstance(product);
}
else
{
var equippable = Loadouts.Resolve(_items, _items.ItemData.RefOf((EquippableItemData) data), _available, where, _failures);
if (equippable == null) continue;
build = equippable;
}
if (bay == null || !bay.TryStore(build()))
_failures.Add($"{where}: no room for {design}");
}
}
@ -151,9 +170,9 @@ public sealed class ScenarioStage
return entity;
}
private T Named<T>(string name, string where) where T : EquippableItemData
private T Named<T>(string name, string where, Func<T, bool> among = null) where T : CraftedItemData
{
var named = _items.ItemData.GetAll<T>().Where(data => data.Name == name).Take(2).ToArray();
var named = _items.ItemData.GetAll<T>().Where(data => data.Name == name && (among == null || among(data))).Take(2).ToArray();
if (named.Length == 1) return named[0];
_failures.Add(named.Length == 0 ? $"{where}: no design named {name}" : $"{where}: more than one design is named {name}");
return null;

View file

@ -0,0 +1,60 @@
using static CultMath.math;
using static ItemRotation;
public sealed class Pursuit : Scenario
{
// The pursuer starts astern, inside the 400 at which VapourCloudTests.ACloudBreaksALockAtRange shows a fresh vent
// 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 override string Name => "Pursuit";
public override string Brief => "A hostile Longinus on your tail; a Mine Launcher fitted, three Coolant Vents and two Thruster " +
"Overdrives aboard. Verify: lay mines in its path, the mine pulses slowly after 2 s, fast when " +
"the hostile is near, explodes 2 s later; vent and coast to lose it; overdrive away.";
public override uint Seed => 111;
public override bool Ambient => false;
public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(DemoTerminus.Cast);
public override void Stage(ScenarioStage stage)
{
var layer = stage.Fit("Longinus",
("Cockpit 2x2", int2(2, 6), None),
("Large Drive", int2(1, 0), Reversed),
("Large Drive", int2(3, 0), Reversed),
("Talaria", int2(2, 14), CounterClockwise),
("Talaria", int2(3, 14), Clockwise),
("Core Power", int2(2, 4), None),
("Mine Launcher", int2(0, 5), None),
("pswarm", int2(5, 5), None),
("FastBlast+-", int2(1, 8), None),
("FastBlast+-", int2(4, 8), None),
("Iapyx", int2(2, 2), CounterClockwise),
("Iapyx", int2(3, 2), Clockwise),
("not if i see you first", int2(3, 10), None),
("Fire Control Array", int2(1, 6), Clockwise),
("Store-All Plus", int2(2, 8), None));
var pursuer = stage.Fit("Longinus",
("Cockpit 2x2", int2(2, 6), None),
("Large Drive", int2(1, 0), Reversed),
("Large Drive", int2(3, 0), Reversed),
("Talaria", int2(2, 14), CounterClockwise),
("Talaria", int2(3, 14), Clockwise),
("Core Power", int2(2, 4), None),
("GT 3K", int2(0, 5), None),
("GT 3K", int2(5, 5), None),
("FastBlast+-", int2(1, 8), None),
("FastBlast+-", int2(4, 8), None),
("Iapyx", int2(2, 2), CounterClockwise),
("Iapyx", int2(3, 2), Clockwise),
("not if i see you first", int2(3, 10), None),
("PotaT+-", int2(1, 5), None),
("Fire Control Array", int2(1, 6), Clockwise),
("Store-All Plus", int2(2, 8), None));
var player = stage.Player(layer, float2(0, 0), facing: float2(0, 1));
stage.Cargo(player, "Coolant Vent", "Coolant Vent", "Coolant Vent", "Thruster Overdrive", "Thruster Overdrive");
stage.Place(pursuer, float2(0, -Astern), facing: float2(0, 1), stance: ScenarioStance.Hostile, piloted: true);
}
}

View file

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

View file

@ -9,6 +9,6 @@ public static class Scenarios
public static readonly Scenario[] Tests =
{
new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new LongHaul()
new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new LongHaul(), new Pursuit()
};
}

View file

@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using int2 = CultMath.int2;
// The Pursuit scenario (aetheria-release cut scenarios-pursuit): a Mine Launcher fitted, consumables in the hold, and
// a hostile on the player's tail. Each test launches or stages through the production path, so what it observes is what
// the menu's button builds. The fixture is RunStartTests'.
public sealed partial class RunStartTests
{
private IEnumerable<CraftedItemInstance> Held(Entity entity) => entity.CargoBays.SelectMany(bay => bay.Cargo.Keys).OfType<CraftedItemInstance>();
private int HeldOf(Entity entity, string design) => Held(entity).Count(item => _items.GetData(item).Name == design);
private static float2 Ground(Entity entity) => float2(entity.Position.x, entity.Position.z);
// The arena the Pursuit button builds stages what the scenario claims: the cast is the demo's, the Mine Launcher is
// fitted on a Launcher cell and sits in a weapon group, the hold has the consumables, and the pursuer is piloted,
// hostile both ways, astern and facing the player inside the range a fresh vent cloud breaks a sight line.
[Fact]
public void PursuitStages()
{
var (galaxy, arena, staged, failures) = Launch(new Pursuit(), Inputs(() => GalaxySeed));
Assert.Empty(failures);
var cast = DemoTerminus.Cast;
var named = new[] { cast.ProtagonistFaction, cast.AntagonistFaction, cast.BufferFaction }.Concat(cast.NeutralFactions);
Assert.Equal(named.Select(galaxy.ResolveFaction), galaxy.Factions);
Assert.Equal(cast.ZoneCount, galaxy.Zones.Length);
var player = staged.Player;
arena.Update(0); // weapon groups assemble on the first update
var mines = player.Equipment.Single(item => _items.GetData(item.EquippableItem).Name == "Mine Launcher");
Assert.Equal(HardpointType.Launcher, player.Hardpoints[mines.Position.x, mines.Position.y].Type);
Assert.Contains(player.WeaponGroups, group => group.items.Contains(mines));
Assert.Equal(3, HeldOf(player, "Coolant Vent"));
Assert.Equal(2, HeldOf(player, "Thruster Overdrive"));
Assert.All(Held(player), item => Assert.IsType<ConsumableItem>(item));
var pursuer = Assert.Single(staged.Entities);
Assert.Equal(1, AgentsOf(arena, pursuer));
Assert.True(player.IsHostileTo(pursuer) && pursuer.IsHostileTo(player), "hostile both ways");
var offset = Ground(pursuer) - Ground(player);
Assert.True(dot(player.Direction, offset) < 0, "the pursuer is not astern");
Assert.True(dot(pursuer.Direction, -offset) > 0, "the pursuer is not facing the player");
// VapourCloudTests: a fresh vent breaks a lock at 400 and a close sensor burns through at 40.
Assert.InRange(length(offset), 40f, 400f);
}
// Cargo names a consumable design as well as an equippable one; a name that is neither fails by name and admits
// nothing.
[Fact]
public void ACargoConsumableIsStaged()
{
var oneCell = _cache.GetAll<GearData>()
.Where(design => design.Shape.Coordinates.Length == 1)
.Where(design => _cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(_cache.RefOf(design).Key)))
.OrderBy(design => design.Name, StringComparer.Ordinal)
.First();
var player = Stage(Arena(true), new Scripted(true, stage =>
stage.Cargo(stage.Player(stage.Generated("Djinni"), float2(0, 0)), "Coolant Vent", oneCell.Name))).Player;
var vents = Held(player).Where(item => _items.GetData(item).Name == "Coolant Vent").ToList();
Assert.IsType<ConsumableItem>(Assert.Single(vents));
Assert.IsType<EquippableItem>(Assert.Single(Held(player), item => _items.GetData(item).Name == oneCell.Name));
var zone = Arena(true);
var failures = Refused(zone, new Scripted(true, stage =>
stage.Cargo(stage.Player(stage.Generated("Djinni"), float2(0, 0)), "Coolant Vent", "No Such Thing")));
Assert.Contains(failures, failure => failure.Contains("no design named No Such Thing"));
}
// A vent staged in the hold is the real consumable: activating it makes a cloud on the venter.
[Fact]
public void TheStagedVentMakesACloud()
{
var (_, arena, staged, failures) = Launch(new Pursuit(), Inputs(() => GalaxySeed));
Assert.Empty(failures);
var player = staged.Player;
var vent = _items.ItemData.GetAll<ConsumableItemData>().Single(design => design.Name == "Coolant Vent");
arena.Update(0);
Assert.True(player.TryActivateConsumable(vent));
arena.Update(.1f);
var cloud = Assert.Single(arena.Clouds);
Assert.True(length(cloud.Body.Position - Ground(player)) <= cloud.Radius, "the cloud is not on its venter");
Assert.Equal(2, HeldOf(player, "Coolant Vent"));
}
}