Materialize refuses an unbuildable Loadout before minting or drawing; delete the boss item-stream restore

Materialize now settles what the catalog alone decides, tries the cells on a
throwaway ItemManager (own ledger and stream, designs built unbranded), and only
then asks the chooser and mints lots. ZoneGenerator's copy-and-restore of
itemManager.Random is deleted, and the boss faction's LoadoutGenerator is created
on the first choice instead of as an argument. Resolve keeps its signature and is
Settle then Build.

Tests: an unbuildable boss zone equals the no-boss zone (serialized pack, ledger,
roster and gear, next item draw) for one failure, several failures and design-only
slots, across every faction as boss faction.

Not yet built or run when committed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-19 01:22:38 +02:00
commit 8ae052bb47
3 changed files with 165 additions and 51 deletions

View file

@ -128,15 +128,19 @@ public static class Loadouts
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 design-only slot is chosen by choose; a game
// WeaponGroupCount weapon groups, and every group index names a weapon slot. Every failure of a stage is listed; on any
// failure nothing is returned and the zone and cache are unchanged. The stages run in the order that puts refusal
// first: what the catalog alone decides (designs, pinned products, hull kind, weapon groups), then whether the cells
// fit, tried on a throwaway item manager with its own ledger and stream, and only then the choice of a design-only
// slot's product (choose may draw) and the minting of lots. So a loadout that cannot be built mints no lot in the live
// ledger and draws nothing from itemManager.Random or from choose. The one exception is a design-only slot with no
// available product, which only choose can tell: it is found after an earlier slot's choice, and it too mints nothing.
// How each design is built is Resolve's rule. A design-only slot is chosen by choose; a game
// spawner is to pass LoadoutGenerator.ChooseProduct, with no fallback to any manufacturer; RunStart does, and so does the
// boss in ZoneGenerator, which is built here too from its faction's Loadout (Loadouts.BossOf). 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. A stated quality mints every lot of the fit at it, drawing nothing (a scenario states
// padded with empty groups. 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, float? quality = null)
@ -144,11 +148,11 @@ public static class Loadouts
var cache = itemManager.ItemData;
var reported = failures.Count;
var hullBuild = Resolve(itemManager, loadout.Hull, loadout.HullProduct, choose, "hull", failures, quality);
var hullSettled = Settle(itemManager, loadout.Hull, loadout.HullProduct, "hull", failures);
var hullData = cache.Get(loadout.Hull);
if (hullData is { HullType: HullType.Station })
failures.Add($"hull: {hullData.Name} is a station hull; a loadout builds a ship or a turret");
var slotBuilds = loadout.Slots.Select(slot => Resolve(itemManager, slot.Design, slot.Product, choose, Cell(slot), failures, quality)).ToArray();
var slotsSettled = loadout.Slots.Select(slot => Settle(itemManager, slot.Design, slot.Product, 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}");
@ -160,12 +164,43 @@ public static class Loadouts
}
if (failures.Count > reported) return null;
// The cells fit or they do not whatever brand a design-only slot takes: the shape is the design's. Tried on its own
// item manager, so nothing is minted into the live ledger and nothing is drawn.
var scratch = new ItemManager(cache, new ProvenanceLedger(), itemManager.GameplaySettings, _ => { });
Assemble(scratch, null, hullData, loadout, Unbranded(scratch, hullSettled), slotsSettled.Select(settled => Unbranded(scratch, settled)).ToArray(), failures, out _);
if (failures.Count > reported) return null;
var hullBuild = Build(itemManager, hullSettled, choose, quality, failures);
var slotBuilds = slotsSettled.Select(settled => Build(itemManager, settled, choose, quality, failures)).ToArray();
if (failures.Count > reported) return null;
var entity = Assemble(itemManager, liveZone, hullData, loadout, hullBuild, slotBuilds, failures, out var equipped);
if (entity == null) return null;
var built = new (List<Weapon> weapons, List<EquippedItem> items)[groupCount];
for (var g = 0; g < groupCount; g++)
{
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);
}
entity.WeaponGroups = built;
return entity;
}
// The hull, then each slot's gear, made by the given builders and equipped to the loadout's cells: the one placement of
// a loadout, run once on the throwaway manager to decide the fit and once on the live one to build. Null with every
// misfit listed when any cell does not take its gear.
private static Entity Assemble(ItemManager itemManager, Zone liveZone, HullData hullData, Loadout loadout,
Func<CraftedItemInstance> hullBuild, Func<CraftedItemInstance>[] slotBuilds, List<string> failures, out EquippedItem[] equipped)
{
var reported = failures.Count;
var cache = itemManager.ItemData;
var hull = (EquippableItem) 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];
equipped = new EquippedItem[loadout.Slots.Count];
for (var i = 0; i < loadout.Slots.Count; i++)
{
var slot = loadout.Slots[i];
@ -182,26 +217,22 @@ public static class Loadouts
.Concat(entity.DockingBays)
.First(item => item.EquippableItem == unit);
}
if (failures.Count > reported) return null;
var built = new (List<Weapon> weapons, List<EquippedItem> items)[groupCount];
for (var g = 0; g < groupCount; g++)
{
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);
}
entity.WeaponGroups = built;
return entity;
return failures.Count > reported ? null : entity;
}
// 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 (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, float? quality = null) where T : CraftedItemData
// What the catalog alone says about one design of a loadout: the design, and whether it pins a product, has no product
// at all, or needs a chooser to name one. Nothing is minted and nothing drawn.
private sealed class Settled
{
public CraftedItemData Data;
public FactionProductData Pinned; // the product the loadout names, if it does
public bool Unbranded; // a design no product makes
public string Where;
}
// Null with the failure listed.
private static Settled Settle<T>(ItemManager itemManager, CultRecordRef<T> design, CultRecordRef<FactionProductData> product,
string where, List<string> failures) where T : CraftedItemData
{
var cache = itemManager.ItemData;
var data = cache.Get(design);
@ -211,35 +242,69 @@ public static class Loadouts
return null;
}
FactionProductData made;
if (product.IsSet())
{
made = cache.Get(product);
var made = cache.Get(product);
if (made == null)
{
failures.Add($"{where}: product {product.Key} is not in the catalog");
return null;
}
}
else
{
if (!cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(design.Key)))
return () => itemManager.CreateInstance(itemManager.CreateLot(data, default, quality ?? UnbrandedQuality));
made = choose(data);
if (made == null)
if (!made.Design.Key.Equals(design.Key))
{
failures.Add($"{where}: no available product of {data.Name}");
failures.Add($"{where}: product {made.Name} is not a product of {data.Name}");
return null;
}
return new Settled { Data = data, Pinned = made, Where = where };
}
if (!made.Design.Key.Equals(design.Key))
return new Settled
{
failures.Add($"{where}: product {made.Name} is not a product of {data.Name}");
return null;
Data = data, Where = where,
Unbranded = !cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(design.Key))
};
}
// A design made with no product, at UnbrandedQuality: no maker, no draw. It is also the stand-in the fit trial builds
// every design as, since a brand never changes a shape. A settled design that failed is never built.
private static Func<CraftedItemInstance> Unbranded(ItemManager itemManager, Settled settled) =>
() => itemManager.CreateInstance(itemManager.CreateLot(settled.Data, default, UnbrandedQuality));
// How a settled design is built, or null with the failure listed; nothing is minted until the returned builder runs.
// A design-only slot is where choose is asked, and may draw.
private static Func<CraftedItemInstance> Build(ItemManager itemManager, Settled settled,
Func<CraftedItemData, FactionProductData> choose, float? quality, List<string> failures)
{
if (settled == null) return null;
if (settled.Unbranded)
return () => itemManager.CreateInstance(itemManager.CreateLot(settled.Data, default, quality ?? UnbrandedQuality));
var made = settled.Pinned;
if (made == null)
{
made = choose(settled.Data);
if (made == null)
{
failures.Add($"{settled.Where}: no available product of {settled.Data.Name}");
return null;
}
if (!made.Design.Key.Equals(itemManager.ItemData.RefOf(settled.Data).Key))
{
failures.Add($"{settled.Where}: product {made.Name} is not a product of {settled.Data.Name}");
return null;
}
}
return () => itemManager.CreateInstance(quality is { } q ? itemManager.CreateLot(made, q) : itemManager.CreateLot(made));
}
// 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 (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, float? quality = null) where T : CraftedItemData =>
Build(itemManager, Settle(itemManager, design, product, where, failures), choose, quality, failures);
private static string Cell(LoadoutSlot slot) => $"slot {slot.Position.x},{slot.Position.y}";
}

View file

@ -433,11 +433,9 @@ public static class ZoneGenerator
continue;
}
var failures = new List<string>();
var streamBefore = itemManager.Random;
var entity = Loadouts.Materialize(itemManager, null, loadout, GetLoadoutGenerator(bossFaction).ChooseProduct, failures);
var entity = Loadouts.Materialize(itemManager, null, loadout, design => GetLoadoutGenerator(bossFaction).ChooseProduct(design), failures); // the generator exists only once the Loadout is known to fit
if (entity == null)
{
itemManager.Random = streamBefore; // a failed fit has drawn lots; an unbuildable boss draws nothing, as a refused one does
itemManager.Log($"{bossFaction.Name}'s boss Loadout {loadout.Name} cannot be built: {string.Join("; ", failures)}");
continue;
}

View file

@ -630,21 +630,72 @@ public sealed partial class RunStartTests
Assert.Equal(expected.Random.NextFloat(), next);
}
[Fact]
public void An_unbuildable_boss_loadout_draws_nothing()
// Everything a generated zone exposes afterwards: the serialized pack, the ledger, the roster and the gear of its ships, and
// the next draw of the item stream. A faction's generator stream shows in the ships it outfitted, so it is in the pack.
private string ZoneFacts(ItemManager items, ZonePack pack)
{
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
var bytes = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(MessagePack.MessagePackSerializer.Serialize(pack, options)));
var gear = pack.Entities.Select(entity => $"{HullName(items, entity)}|{(entity.Faction.IsSet() ? entity.Faction.Key.ToString() : "-")}|{string.Join(";", Cells(items, entity))}");
return $"pack {bytes} NextLot={items.Lots.NextLot} lots={items.Lots.Lots.Count}\n{string.Join("\n", gear)}\nnext item draw {items.Random.NextFloat():R}";
}
// The boss of the named faction, whose Loadout is made unbuildable by unbuild, refuses before anything is minted or drawn: the
// zone is the zone with no boss in every state it exposes. The faction need not be the zone's nearest.
private void AssertUnbuildableBossIsAsIfAbsent(string bossFaction, Action<Loadout> unbuild)
{
var galaxy = DemoGalaxy(1);
var loadout = GiveAntagonistABoss(galaxy);
loadout.Slots[0].Position = new int2(999, 999);
unbuild(loadout);
StoreLoadout(loadout);
var items = LoggingItems(new List<string>());
var pack = BossZonePack(galaxy, items, galaxy.Exit);
Assert.False(pack.BossSpawned);
var next = items.Random.NextFloat();
var boss = galaxy.Factions.Single(faction => faction.Name == bossFaction);
Zhestokost(galaxy).BossLoadout = default;
SetBoss(boss, loadout);
galaxy.BossZones.Clear();
galaxy.BossZones[boss] = galaxy.Exit;
var log = new List<string>();
var items = LoggingItems(log);
var pack = BossZonePack(galaxy, items, galaxy.Exit);
Assert.False(pack.BossSpawned, bossFaction);
Assert.Contains(log, line => line.Contains("Antagonist Boss") && line.Contains("cannot be built"));
var bare = DemoGalaxy(1);
bare.BossZones.Clear();
var bareItems = LoggingItems(new List<string>());
var bare = BossZonePack(galaxy, bareItems, galaxy.Exit);
Assert.Equal(Roster(bareItems, bare), Roster(items, pack));
Assert.Equal(bareItems.Random.NextFloat(), next);
var barePack = BossZonePack(bare, bareItems, bare.Exit);
Assert.Equal($"{bossFaction}\n{ZoneFacts(bareItems, barePack)}", $"{bossFaction}\n{ZoneFacts(items, pack)}");
}
private IEnumerable<string> AllFactionNames() => DemoGalaxy(1).Factions.Select(faction => faction.Name).OrderBy(name => name, StringComparer.Ordinal);
private static void MoveOut(Loadout loadout, int slots)
{
for (var i = 0; i < slots; i++) loadout.Slots[i].Position = new int2(999 - i, 999 - i);
}
[Fact]
public void An_unbuildable_boss_loadout_with_one_failure_leaves_the_zone_as_if_it_had_no_boss()
{
foreach (var faction in AllFactionNames()) AssertUnbuildableBossIsAsIfAbsent(faction, loadout => MoveOut(loadout, 1));
}
[Fact]
public void An_unbuildable_boss_loadout_with_several_failures_leaves_the_zone_as_if_it_had_no_boss()
{
foreach (var faction in AllFactionNames()) AssertUnbuildableBossIsAsIfAbsent(faction, loadout => MoveOut(loadout, 3));
}
// A design-only slot asks the chooser, which draws its faction's stream; the fit is decided before it is asked.
[Fact]
public void An_unbuildable_boss_loadout_with_design_only_slots_leaves_the_zone_as_if_it_had_no_boss()
{
foreach (var faction in AllFactionNames())
AssertUnbuildableBossIsAsIfAbsent(faction, loadout =>
{
foreach (var slot in loadout.Slots) slot.Product = default;
Assert.Contains(loadout.Slots, slot => _cache.GetAll<FactionProductData>().Any(p => p.Design.Key.Equals(slot.Design.Key)));
MoveOut(loadout, 1);
loadout.Slots[loadout.Slots.Count - 1].Position = new int2(900, 900);
});
}
}