Staged fits: a scenario states the quality of a fit its checks rest on

ScenarioStage.Fit(hull, quality, slots) mints every lot of the fit at the stated quality through Loadouts.Materialize/Resolve, so Crossing's fighter and EvasionTermTests' hand-fitted Longinus and Djinni do not ride the item stream. No seed changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-17 19:03:20 +02:00
commit 7b2a6d064d
5 changed files with 65 additions and 16 deletions

View file

@ -108,18 +108,19 @@ public static class Loadouts
// picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit, which stays where it is
// put; a station hull is refused. The entity always gets exactly WeaponGroupCount groups; a loadout with fewer is
// padded with empty groups. A failed materialization leaves any minted lots in the live ledger; they are not roots,
// so the next commit drops them.
// so the next commit drops them. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states
// one for a fit its checks rest on).
public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
Func<CraftedItemData, FactionProductData> choose, List<string> failures)
Func<CraftedItemData, FactionProductData> choose, List<string> failures, float? quality = null)
{
var cache = itemManager.ItemData;
var reported = failures.Count;
var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures);
var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality);
var hullData = cache.Get(loadout.Hull);
if (hullData is { HullType: HullType.Station })
failures.Add($"hull: {hullData.Name} is a station hull; a preset builds a ship or a turret");
var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures)).ToArray();
var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray();
var groups = loadout.WeaponGroups ?? new int[0][];
var groupCount = itemManager.GameplaySettings.WeaponGroupCount;
if (groups.Length > groupCount) failures.Add($"weapon groups: {groups.Length} groups, at most {groupCount}");
@ -168,11 +169,11 @@ public static class Loadouts
// How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A
// pinned product is built as named, whatever choose says; it must be a product of the design. A design with no
// product at all is outside the economy (docs/scenarios-cut.md, Q3: generation, stock and sale all go through
// products) and is built unbranded, with no maker, at UnbrandedQuality. Otherwise choose names the product, and
// products) and is built unbranded, with no maker, at UnbrandedQuality (a stated quality replaces it). Otherwise choose names the product, and
// null (nothing available) or a product of another design is a failure.
public static Func<CraftedItemInstance> Resolve<T>(ItemManager itemManager, CultRecordRef<T> design,
CultRecordRef<FactionProductData> product, Func<CraftedItemData, FactionProductData> choose, string where,
List<string> failures) where T : CraftedItemData
List<string> failures, float? quality = null) where T : CraftedItemData
{
var cache = itemManager.ItemData;
var data = cache.Get(design);
@ -195,7 +196,7 @@ public static class Loadouts
else
{
if (!cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(design.Key)))
return () => itemManager.CreateInstance(itemManager.CreateLot(data, default, UnbrandedQuality));
return () => itemManager.CreateInstance(itemManager.CreateLot(data, default, quality ?? UnbrandedQuality));
made = choose(data);
if (made == null)
{
@ -209,7 +210,7 @@ public static class Loadouts
failures.Add($"{where}: product {made.Name} is not a product of {data.Name}");
return null;
}
return () => itemManager.CreateInstance(made);
return () => itemManager.CreateInstance(quality is { } q ? itemManager.CreateLot(made, q) : itemManager.CreateLot(made));
}
private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}";

View file

@ -52,7 +52,15 @@ public sealed class ScenarioStage
// A hull fitted slot by slot: each slot a design or a product, the hull cell it goes in, and its rotation. A name is
// a design (open: the stage chooses its product) or a product (pinned: that brand).
public ScenarioFit Fit(string hull, params (string design, int2 cell, ItemRotation rotation)[] slots)
public ScenarioFit Fit(string hull, params (string design, int2 cell, ItemRotation rotation)[] slots) =>
FitAt(hull, null, slots);
// The same fit with every lot it mints (hull, slots, unbranded) at the stated quality, drawing nothing. A fit a scenario's
// checks rest on states its quality, so the check does not ride the item stream.
public ScenarioFit Fit(string hull, float quality, params (string design, int2 cell, ItemRotation rotation)[] slots) =>
FitAt(hull, quality, slots);
private ScenarioFit FitAt(string hull, float? quality, (string design, int2 cell, ItemRotation rotation)[] slots)
{
var where = $"fit {hull}";
var (hullData, hullProduct) = NamedOrProduct<HullData>(hull, where);
@ -69,7 +77,7 @@ public sealed class ScenarioStage
Product = product == null ? default : _items.ItemData.RefOf(product)
});
}
return hullData != null && loadout.Slots.Count == slots.Length ? new ScenarioFit(loadout) : null;
return hullData != null && loadout.Slots.Count == slots.Length ? new ScenarioFit(loadout, quality) : null;
}
// A hull with nothing fitted.
@ -154,7 +162,7 @@ public sealed class ScenarioStage
else
{
var failures = new List<string>();
entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _choose, failures);
entity = Loadouts.Materialize(_items, Arena, fit.Loadout, _choose, failures, fit.Quality);
_failures.AddRange(failures.Select(failure => $"{where} ({fit.Loadout.Name}): {failure}"));
if (entity == null) return null;
}
@ -190,7 +198,13 @@ public sealed class ScenarioFit
{
internal readonly Loadout Loadout;
internal readonly EntityPack Pack;
internal readonly float? Quality;
internal ScenarioFit(Loadout loadout, float? quality = null)
{
Loadout = loadout;
Quality = quality;
}
internal ScenarioFit(Loadout loadout) => Loadout = loadout;
internal ScenarioFit(EntityPack pack) => Pack = pack;
}

View file

@ -22,6 +22,8 @@ public sealed class Crossing : Scenario
// 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);
// 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;
public const float ProjectorRange = 1216f; // the Lightning Projector's Range in metres, 1216.4 as the fit stands settled at rest
public static readonly CultMath.float2 Waiting = Origin + float2(0, ProjectorRange * .8f);
@ -29,7 +31,7 @@ public sealed class Crossing : Scenario
public override void Stage(ScenarioStage stage)
{
var fighter = stage.Fit("Longinus",
var fighter = stage.Fit("Longinus", StagedQuality,
("Cockpit 2x2", int2(2, 6), None),
("Large Drive", int2(1, 0), Reversed),
("Large Drive", int2(3, 0), Reversed),

View file

@ -22,6 +22,9 @@ public sealed partial class RunStartTests
{
private const float EvDt = 1f / 60f;
// The quality the hand-fitted ships are staged at, so their measured constants do not ride the item stream.
private const float EvStagedQuality = .6f;
// The Longinus's drive train, fitted by name: its cockpit and power and the generated Longinus's thrusters (two Large Drive
// forward, two Rugged Drive on the flanks).
private static readonly (string design, int2 cell, ItemRotation rotation)[] EvLonginusDrive =
@ -32,7 +35,7 @@ public sealed partial class RunStartTests
};
// The Duel's guns on a Longinus: the drive train above, its Targeting Computer, and the Duel's guns, launchers and sensor.
private static ScenarioFit EvLonginus(ScenarioStage stage, string gun = "Cell Blaster", string second = null) => stage.Fit("Longinus",
private static ScenarioFit EvLonginus(ScenarioStage stage, string gun = "Cell Blaster", string second = null) => stage.Fit("Longinus", EvStagedQuality,
EvLonginusDrive.Concat(new (string design, int2 cell, ItemRotation rotation)[]
{
("Guided Missile Launcher", int2(0, 5), ItemRotation.None), ("Guided Missile Launcher", int2(5, 5), ItemRotation.None), (gun, int2(1, 8), ItemRotation.None),
@ -70,7 +73,7 @@ public sealed partial class RunStartTests
{
stage.Player(stage.Bare("Djinni"), float2(-50000, -50000));
djinni = stage.Place(EvDjinni(stage), float2(-10000, -30000), facing: float2(0, 1)) as Ship;
longinus = stage.Place(stage.Fit("Longinus", EvLonginusDrive), float2(-30000, -30000), facing: float2(0, 1)) as Ship;
longinus = stage.Place(stage.Fit("Longinus", EvStagedQuality, EvLonginusDrive), float2(-30000, -30000), facing: float2(0, 1)) as Ship;
});
var (_, arena, _, failures) = Launch(scenario, Inputs(() => GalaxySeed));
Assert.True(failures.Count == 0, string.Join("; ", failures));
@ -621,7 +624,7 @@ public sealed partial class RunStartTests
// The target's fit, written out: the Djinni's own hardpoints filled with the Rugged Drive it is generated with, its
// reactor, cockpit and radiators. What the evasion tests price is this ship's strafing, not whatever the generator
// rolls for it from the galaxy.
private static ScenarioFit EvDjinni(ScenarioStage stage) => stage.Fit("Djinni",
private static ScenarioFit EvDjinni(ScenarioStage stage) => stage.Fit("Djinni", EvStagedQuality,
("Cockpit 2x2", int2(6, 3), ItemRotation.None), ("Compact Reactor", int2(6, 11), ItemRotation.None),
("Rugged Drive", int2(6, 0), ItemRotation.Reversed), ("Rugged Drive", int2(5, 1), ItemRotation.Reversed), ("Rugged Drive", int2(7, 1), ItemRotation.Reversed),
("Rugged Drive", int2(4, 4), ItemRotation.CounterClockwise), ("Rugged Drive", int2(4, 11), ItemRotation.CounterClockwise),

View file

@ -291,6 +291,35 @@ public sealed partial class RunStartTests
Assert.Contains("No Such Gun", Assert.Single(failures));
}
// A fit that states its quality mints every lot it builds at it, assembly and role fills alike; the same fit without one
// draws them from the item stream, so some lot comes out otherwise. A scenario's checks rest on the first.
[Fact]
public void StagedFitMintsAtItsQuality()
{
(string design, int2 cell, ItemRotation rotation)[] slots =
{
("Cockpit 2x2", int2(2, 6), ItemRotation.None), ("Compact Reactor", int2(2, 4), ItemRotation.None),
("Large Drive", int2(1, 0), ItemRotation.Reversed), ("Radiator", int2(2, 2), ItemRotation.CounterClockwise)
};
var staged = Stage(Arena(true), new Scripted(true, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(0, 0));
stage.Place(stage.Fit("Longinus", .6f, slots), float2(300, 0));
stage.Place(stage.Fit("Longinus", slots), float2(-300, 0));
}));
IEnumerable<Lot> Lots(Entity entity) =>
EntitySerializer.Items(EntitySerializer.Pack(entity)).OfType<CraftedItemInstance>().Select(_items.GetLot);
var stated = Lots(staged.Entities[0]).ToArray();
var drawn = Lots(staged.Entities[1]).ToArray();
Assert.True(stated.Length > slots.Length);
Assert.All(stated, lot =>
{
Assert.Equal(.6f, lot.Quality, 3);
Assert.All(lot.Roles ?? new List<RoleFill>(), fill => Assert.Equal(.6f, fill.Quality, 3));
});
Assert.Contains(drawn, lot => Math.Abs(lot.Quality - .6f) > .001f);
}
// A stance holds both ways between an entity and the player. A piloted entity has exactly one agent; an unpiloted
// one has none, and nothing steers it. (It is not frozen: the zone's gravity still moves it, as it moves any ship,
// so the rule is observed on its steering inputs.)