Scenarios Cut 1: Materialize builds product-less designs unbranded; the hull picks the entity
Two rules on Loadouts.Materialize (docs/scenarios-cut.md, 2.2, Q3): - A design no product makes is outside the economy and is built unbranded, with no maker, at Loadouts.UnbrandedQuality (.95, the console give's). A design whose products are all unavailable still fails, as before. - The hull's type picks the entity: a ship hull builds a Ship, a turret hull an OrbitalEntity with no orbit; a station hull is refused. Materialize now returns Entity. Loadouts.Resolve is the one rule for how a design is built; RunStart uses it for scenario cargo. LoadoutTests.MissingDesignReports no longer uses the product-less "Orphan" as a failure (it now builds); two tests pin the Q3 rule and the unavailable-product contract it does not loosen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fbda4c74fb
commit
5706b657e3
2 changed files with 98 additions and 38 deletions
|
|
@ -27,7 +27,7 @@ public class LoadoutSlot
|
|||
[Key(2)] public CultRecordRef<EquippableItemData> Design;
|
||||
}
|
||||
|
||||
// The owner of loadouts: capture from a live ship, the only writer, and the only builder of a ship from a loadout.
|
||||
// The owner of loadouts: capture from a live ship, the only writer, and the only builder of an entity from a loadout.
|
||||
public static class Loadouts
|
||||
{
|
||||
// Records the hull design and one slot per equipped item (gear, then cargo bays, then docking bays) with its cell,
|
||||
|
|
@ -86,36 +86,30 @@ public static class Loadouts
|
|||
});
|
||||
}
|
||||
|
||||
// All-or-nothing: returns a ship only when every design resolved, had an available product and fitted, the loadout
|
||||
// has at most WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on
|
||||
// any failure nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from
|
||||
// itemManager.Random. A design is built by its first available product in record-key order. A game spawner is to
|
||||
// pass LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer. The ship 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.
|
||||
public static Ship Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
|
||||
// The quality a design no manufacturer makes is built at: the console give's.
|
||||
public const float UnbrandedQuality = .95f;
|
||||
|
||||
// All-or-nothing: returns an entity only when every design resolved and fitted, the loadout has at most
|
||||
// WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure is listed; on any failure
|
||||
// nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from
|
||||
// itemManager.Random. How each design is built is Resolve's rule. A game spawner is to pass
|
||||
// LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer; RunStart does. The hull's type
|
||||
// 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.
|
||||
public static Entity Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout,
|
||||
Predicate<FactionProductData> isAvailable, List<string> failures)
|
||||
{
|
||||
var cache = itemManager.ItemData;
|
||||
var products = cache.GetAll<FactionProductData>().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();
|
||||
var products = ProductsInKeyOrder(cache);
|
||||
var reported = failures.Count;
|
||||
|
||||
FactionProductData Resolve<T>(CultRecordRef<T> design, string where) where T : EquippableItemData
|
||||
{
|
||||
var data = cache.Get(design);
|
||||
if (data == null)
|
||||
{
|
||||
failures.Add($"{where}: design {design.Key} is not in the catalog");
|
||||
return null;
|
||||
}
|
||||
|
||||
var product = products.FirstOrDefault(p => p.Design.Key.Equals(design.Key) && isAvailable(p));
|
||||
if (product == null) failures.Add($"{where}: no available product of {data.Name}");
|
||||
return product;
|
||||
}
|
||||
|
||||
var hullProduct = Resolve(loadout.Hull, "hull");
|
||||
var slotProducts = loadout.Slots.Select(slot => Resolve(slot.Design, Cell(slot))).ToArray();
|
||||
var hullBuild = Resolve(itemManager, products, loadout.Hull, isAvailable, "hull", failures);
|
||||
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, products, slot.Design, isAvailable, Cell(slot), failures)).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}");
|
||||
|
|
@ -127,23 +121,26 @@ public static class Loadouts
|
|||
}
|
||||
if (failures.Count > reported) return null;
|
||||
|
||||
var hull = (EquippableItem) itemManager.CreateInstance(hullProduct);
|
||||
var ship = new Ship(itemManager, liveZone, hull, itemManager.GameplaySettings.DefaultEntitySettings);
|
||||
var hull = hullBuild();
|
||||
var settings = itemManager.GameplaySettings.DefaultEntitySettings;
|
||||
Entity entity = hullData.HullType == HullType.Turret
|
||||
? new OrbitalEntity(itemManager, liveZone, hull, default, settings)
|
||||
: new Ship(itemManager, liveZone, hull, settings);
|
||||
var equipped = new EquippedItem[loadout.Slots.Count];
|
||||
for (var i = 0; i < loadout.Slots.Count; i++)
|
||||
{
|
||||
var slot = loadout.Slots[i];
|
||||
var unit = (EquippableItem) itemManager.CreateInstance(slotProducts[i]);
|
||||
var unit = slotBuilds[i]();
|
||||
unit.Rotation = slot.Rotation;
|
||||
if (!ship.TryEquip(unit, slot.Position))
|
||||
if (!entity.TryEquip(unit, slot.Position))
|
||||
{
|
||||
failures.Add($"{Cell(slot)}: {cache.Get(slot.Design).Name} does not fit");
|
||||
continue;
|
||||
}
|
||||
|
||||
equipped[i] = ship.Equipment
|
||||
.Concat<EquippedItem>(ship.CargoBays)
|
||||
.Concat(ship.DockingBays)
|
||||
equipped[i] = entity.Equipment
|
||||
.Concat<EquippedItem>(entity.CargoBays)
|
||||
.Concat(entity.DockingBays)
|
||||
.First(item => item.EquippableItem == unit);
|
||||
}
|
||||
if (failures.Count > reported) return null;
|
||||
|
|
@ -154,9 +151,44 @@ public static class Loadouts
|
|||
var items = g < groups.Length ? groups[g].Select(i => equipped[i]).ToList() : new List<EquippedItem>();
|
||||
built[g] = (items.Select(item => item.GetBehavior<Weapon>()).ToList(), items);
|
||||
}
|
||||
ship.WeaponGroups = built;
|
||||
return ship;
|
||||
entity.WeaponGroups = built;
|
||||
return entity;
|
||||
}
|
||||
|
||||
// How one design is built, or null with the failure listed; nothing is minted until the returned builder runs. A
|
||||
// design is built by its first available product in record-key order. 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. A design whose products are all unavailable is not built.
|
||||
public static Func<EquippableItem> Resolve<T>(ItemManager itemManager, CultRecordRef<T> design,
|
||||
Predicate<FactionProductData> isAvailable, string where, List<string> failures) where T : EquippableItemData =>
|
||||
Resolve(itemManager, ProductsInKeyOrder(itemManager.ItemData), design, isAvailable, where, failures);
|
||||
|
||||
private static Func<EquippableItem> Resolve<T>(ItemManager itemManager, FactionProductData[] products,
|
||||
CultRecordRef<T> design, Predicate<FactionProductData> isAvailable, string where, List<string> failures)
|
||||
where T : EquippableItemData
|
||||
{
|
||||
var data = itemManager.ItemData.Get(design);
|
||||
if (data == null)
|
||||
{
|
||||
failures.Add($"{where}: design {design.Key} is not in the catalog");
|
||||
return null;
|
||||
}
|
||||
|
||||
var made = products.Where(p => p.Design.Key.Equals(design.Key)).ToArray();
|
||||
if (made.Length == 0)
|
||||
return () => (EquippableItem) itemManager.CreateInstance(itemManager.CreateLot(data, default, UnbrandedQuality));
|
||||
|
||||
var product = made.FirstOrDefault(p => isAvailable(p));
|
||||
if (product == null)
|
||||
{
|
||||
failures.Add($"{where}: no available product of {data.Name}");
|
||||
return null;
|
||||
}
|
||||
return () => (EquippableItem) itemManager.CreateInstance(product);
|
||||
}
|
||||
|
||||
private 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}";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -264,13 +264,13 @@ public sealed class LoadoutTests : IDisposable
|
|||
Assert.Contains("1,1", failure);
|
||||
Assert.Contains("absent-design", failure);
|
||||
|
||||
missing.Slots.Add(new LoadoutSlot { Position = new int2(2, 2), Design = cache.RefOf<EquippableItemData>(cache.GetByName<GearData>("Orphan")) });
|
||||
missing.Slots.Add(new LoadoutSlot { Position = new int2(2, 2), Design = new CultRecordRef<EquippableItemData>(new CultRecordKey("another-absent-design")) });
|
||||
failures.Clear();
|
||||
Assert.Null(Build(items, missing, failures));
|
||||
Assert.Equal(2, failures.Count);
|
||||
Assert.Contains("slot 1,1", failures[0]);
|
||||
Assert.Contains("slot 2,2", failures[1]);
|
||||
Assert.Contains("no available product of Orphan", failures[1]);
|
||||
Assert.Contains("another-absent-design", failures[1]);
|
||||
}
|
||||
|
||||
Assert.Equal(before, new[] { Catalog, Run, Player }.Select(Hash).ToArray());
|
||||
|
|
@ -321,6 +321,34 @@ public sealed class LoadoutTests : IDisposable
|
|||
Assert.Contains("slot 0,0: no available product of Lamp", Assert.Single(failures));
|
||||
}
|
||||
|
||||
// Scenarios Q3 (docs/scenarios-cut.md): a design no product makes is outside the economy, and a preset builds it
|
||||
// unbranded -- no maker -- at the one named quality. "Orphan" has no product in this catalog.
|
||||
[Fact]
|
||||
public void ADesignWithNoProductBuildsUnbranded()
|
||||
{
|
||||
using var cache = Open();
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
||||
var failures = new List<string>();
|
||||
var ship = Build(items, WithDesign(HandBuilt(cache), 0, cache, "Orphan"), failures);
|
||||
Assert.Empty(failures);
|
||||
var unit = ship.Equipment.Single(item => item.EquippableItem != ship.Hull && item.Position.Equals(HardpointCell)).EquippableItem;
|
||||
Assert.Equal("Orphan", items.GetData(unit).Name);
|
||||
Assert.Null(items.Brand(unit).Maker);
|
||||
Assert.Equal(Loadouts.UnbrandedQuality, items.GetLot(unit).Quality);
|
||||
}
|
||||
|
||||
// The existing contract Q3 does not loosen: a design that has a product, none of them available, is not built,
|
||||
// unbranded or otherwise.
|
||||
[Fact]
|
||||
public void ADesignWhoseOnlyProductIsUnavailableStillFails()
|
||||
{
|
||||
using var cache = Open();
|
||||
var items = new ItemManager(cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { });
|
||||
var failures = new List<string>();
|
||||
Assert.Null(Loadouts.Materialize(items, null, HandBuilt(cache), p => p.Name != "Lamp by Maker", failures));
|
||||
Assert.Equal("slot 0,0: no available product of Lamp", Assert.Single(failures));
|
||||
}
|
||||
|
||||
// Census's duplicate (maker, design) detector exists precisely because this should never happen in an
|
||||
// authored catalog; Brand()'s tie-break is what a runtime item falls back on if it ever does, so it must stay
|
||||
// deterministic rather than however the cache happens to enumerate.
|
||||
|
|
@ -895,7 +923,7 @@ public sealed class LoadoutTests : IDisposable
|
|||
}
|
||||
|
||||
// Everything available: for tests about placement and failure lists.
|
||||
private static Ship Build(ItemManager items, Loadout loadout, List<string> failures) =>
|
||||
private static Entity Build(ItemManager items, Loadout loadout, List<string> failures) =>
|
||||
Loadouts.Materialize(items, null, loadout, _ => true, failures);
|
||||
|
||||
private static (int2, ItemRotation, CultRecordKey) Describe(LoadoutSlot slot) => (slot.Position, slot.Rotation, slot.Design.Key);
|
||||
|
|
|
|||
Loading…
Reference in a new issue