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:
parent
b888b20c33
commit
9ccc846c1a
6 changed files with 192 additions and 8 deletions
|
|
@ -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}";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
60
Assets/Scripts/ServerShared/Scenarios/Pursuit.cs
Normal file
60
Assets/Scripts/ServerShared/Scenarios/Pursuit.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Scenarios/Pursuit.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Scenarios/Pursuit.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4b0587057e6a4618bf63944364298870
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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()
|
||||
};
|
||||
}
|
||||
|
|
|
|||
94
tests/Aetheria.Shared.Tests/PursuitScenarioTests.cs
Normal file
94
tests/Aetheria.Shared.Tests/PursuitScenarioTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue