From 7b2a6d064df59c1c8c20da2580a2f2d7db3fd16f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 17 Oct 2026 19:03:20 +0200 Subject: [PATCH] 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 --- Assets/Scripts/ServerShared/Loadout.cs | 17 ++++++----- Assets/Scripts/ServerShared/ScenarioStage.cs | 22 +++++++++++--- .../ServerShared/Scenarios/Crossing.cs | 4 ++- .../Aetheria.Shared.Tests/EvasionTermTests.cs | 9 ++++-- tests/Aetheria.Shared.Tests/ScenarioTests.cs | 29 +++++++++++++++++++ 5 files changed, 65 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 7effbfe76..bf8a2ac47 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -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 choose, List failures) + Func choose, List 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 Resolve(ItemManager itemManager, CultRecordRef design, CultRecordRef product, Func choose, string where, - List failures) where T : CraftedItemData + List 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().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}"; diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index b32a4993c..77e5bf1e3 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -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(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(); - 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; } diff --git a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs index c90859439..516aac5be 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs @@ -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), diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index ffd99d16e..80f0a145f 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -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), diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index 8e435d2f0..20a918438 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -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 Lots(Entity entity) => + EntitySerializer.Items(EntitySerializer.Pack(entity)).OfType().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(), 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.)