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),