Loadout.cs owns loadouts: the document, capture, save and materialization.
- Loadout ("aetheria.loadout", player store) holds a hull design ref, and
per slot a hull cell, a rotation and an EquippableItemData design ref,
plus weapon groups as slot indices. It names no product, manufacturer,
unit roll, cargo, faction, position or behaviour state.
- Loadouts.Capture records the hull design and one slot per equipped item
(gear, cargo bays, docking bays). Entity.Equipment also holds the hull's
own unit (MapEntity adds EquippedHull); that is Loadout.Hull, not a slot.
- Loadouts.Save is the only writer: one Commit to the player store, reusing
the existing key for the name.
- Loadouts.Materialize is the only builder of a ship from a loadout. For
the hull and each slot in order it resolves the design and takes the
first product in catalog order that LoadoutGenerator.IsAvailable (now
public) accepts, with no any-manufacturer fallback. Every failing slot is
listed; units are built only after everything resolved, and any resolve
or fit failure returns null with no ship, no charge and no write.
- Loadouts.Price is the hull design's price plus each slot design's price.
- InventoryPanel: Save Loadout asks for a name (default: the ship's name)
and saves the capture. Restore Loadout, shown when docked with saved
loadouts, lists each with its price, debits credits only after
Materialize succeeds, then docks the ship as before; a failure opens
"Loadout cannot be built here" with one line per failing slot.
Tests: new LoadoutTests (3): LoadoutRoundTripsThroughAFreshCache,
LoadoutHoldsNoRunOrGalaxyRefs, MissingDesignReports. Each fails with its
rule mutated out. The fixture hull is 3x3, not the spec's 2x2:
HullData.InteriorCells is Shape.Shrink(), so a 2x2 hull has no interior
cell a cargo bay can occupy.
Builds: headless, 14 tests pass, Unity batchmode compile reports no error CS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
286 lines
13 KiB
C#
286 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using GameCult.Caching;
|
|
using CultMath;
|
|
using static CultMath.math;
|
|
using Random = CultMath.Random;
|
|
|
|
public class LoadoutGenerator
|
|
{
|
|
public Random Random;
|
|
public ItemManager ItemManager { get; }
|
|
public Galaxy Galaxy { get; }
|
|
public GalaxyZone Zone { get; }
|
|
public Faction Faction { get; }
|
|
public float PriceExponent { get; }
|
|
|
|
public LoadoutGenerator(
|
|
ref Random random,
|
|
ItemManager itemManager,
|
|
Galaxy galaxy,
|
|
GalaxyZone zone,
|
|
Faction faction,
|
|
float priceExponent)
|
|
{
|
|
Random = random;
|
|
ItemManager = itemManager;
|
|
Galaxy = galaxy;
|
|
Zone = zone;
|
|
Faction = faction;
|
|
PriceExponent = priceExponent;
|
|
}
|
|
|
|
public EntityPack GenerateShipLoadout(Predicate<HullData> hullFilter = null)
|
|
{
|
|
var (hullProduct, hullData) = RandomHull(HullType.Ship, hullFilter);
|
|
if(hullData==null)
|
|
{
|
|
ItemManager.Log("Unable to generate ship loadout: no compatible hull found!");
|
|
return null;
|
|
}
|
|
var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem;
|
|
if(hull==null)
|
|
ItemManager.Log("WHAT???");
|
|
var entity = new Ship(ItemManager, null, hull, ItemManager.GameplaySettings.DefaultEntitySettings);
|
|
entity.Faction = Faction;
|
|
OutfitEntity(entity);
|
|
return EntitySerializer.Pack(entity);
|
|
}
|
|
|
|
public OrbitalEntityPack GenerateTurretLoadout()
|
|
{
|
|
var (hullProduct, hullData) = RandomHull(HullType.Turret);
|
|
if(hullData==null)
|
|
{
|
|
ItemManager.Log("Unable to generate turret loadout: no compatible hull found!");
|
|
return null;
|
|
}
|
|
var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem;
|
|
var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings);
|
|
entity.Faction = Faction;
|
|
OutfitEntity(entity);
|
|
return EntitySerializer.Pack(entity) as OrbitalEntityPack;
|
|
}
|
|
|
|
public OrbitalEntityPack GenerateStationLoadout()
|
|
{
|
|
var (hullProduct, hullData) = RandomHull(HullType.Station);
|
|
if(hullData==null)
|
|
{
|
|
ItemManager.Log("Unable to generate station loadout: no compatible hull found!");
|
|
return null;
|
|
}
|
|
var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem;
|
|
var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings);
|
|
entity.Faction = Faction;
|
|
|
|
// Hardpoint gear goes in first so the docking bay can only take space that gear left free
|
|
EquipHardpoints(entity);
|
|
|
|
var emptyShape = entity.UnoccupiedSpace;
|
|
|
|
var (dockingBayProduct, dockingBayData) = RandomProduct<DockingBayData>(2, item => item.Shape.FitsWithin(emptyShape, out _, out _), required: true);
|
|
if (dockingBayData == null) throw new InvalidLoadoutException("No compatible docking bay found for station!");
|
|
|
|
dockingBayData.Shape.FitsWithin(emptyShape, out var cargoRotation, out var cargoPosition);
|
|
var dockingBay = ItemManager.CreateInstance(dockingBayProduct) as EquippableItem;
|
|
dockingBay.Rotation = cargoRotation;
|
|
if (!entity.TryEquip(dockingBay, cargoPosition))
|
|
{
|
|
throw new InvalidLoadoutException("Failed to equip selected docking bay!");
|
|
}
|
|
|
|
FillInterior(entity);
|
|
|
|
var cargo = entity.CargoBays.First();
|
|
var inventory = RandomProducts<EquippableItemData>(16, 1,
|
|
data => !(data is HullData hull && hull.HullType != HullType.Ship) && !(data is CargoBayData))
|
|
.OrderByDescending(entry => entry.design.Shape.Coordinates.Length);
|
|
foreach (var entry in inventory)
|
|
{
|
|
var instance = ItemManager.CreateInstance(entry.product);
|
|
cargo.TryStore(instance);
|
|
}
|
|
|
|
entity.CanTow = hullData.CanTow;
|
|
|
|
return EntitySerializer.Pack(entity) as OrbitalEntityPack;
|
|
}
|
|
|
|
// Every entity needs a hull, so hulls are always required
|
|
public (FactionProductData product, HullData design) RandomHull(HullType type, Predicate<HullData> hullFilter = null)
|
|
{
|
|
return RandomProduct<HullData>(0, item =>
|
|
(hullFilter?.Invoke(item) ?? true) &&
|
|
item.HullType == type, required: true);
|
|
}
|
|
|
|
// What a faction makes is which products it has, so generation selects branded products rather than designs.
|
|
// Products normally come only from manufacturers present in the galaxy and known to the zone's faction; a
|
|
// required item falls back to any manufacturer when none of those make one, which means the product table
|
|
// lacks variety, so it is logged rather than hidden.
|
|
public (FactionProductData product, T design)[] RandomProducts<T>(int count, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
|
|
{
|
|
var candidates = ItemManager.ItemData.GetAll<FactionProductData>()
|
|
.Select(product => (product, design: ItemManager.ItemData.Get(product.Design) as T))
|
|
.Where(entry =>
|
|
entry.design != null &&
|
|
entry.design.Price > 0 &&
|
|
entry.product.Manufacturer.IsSet() &&
|
|
(filter?.Invoke(entry.design) ?? true))
|
|
.ToArray();
|
|
var available = candidates.Where(entry => IsAvailable(entry.product)).ToArray();
|
|
var preferManufacturers = true;
|
|
if (available.Length == 0 && required && candidates.Length > 0)
|
|
{
|
|
ItemManager.Log($"No {typeof(T).Name} product available from manufacturers known to {Faction?.Name ?? "this galaxy"}; using any manufacturer. The product table needs more variety.");
|
|
available = candidates;
|
|
preferManufacturers = false;
|
|
}
|
|
|
|
return available.WeightedRandomElements(ref Random, entry =>
|
|
(preferManufacturers ? ManufacturerPreference(entry.product.Manufacturer) : 1) *
|
|
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
|
|
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
|
|
count);
|
|
}
|
|
|
|
// No galaxy means no availability to filter by: every product is on offer. A fixture generates loadouts that
|
|
// way, so a test can exercise placement and products without standing up a whole galaxy; no game path does.
|
|
// Loadouts.Materialize asks this too, so a loadout and generation share one availability rule.
|
|
public bool IsAvailable(FactionProductData product) =>
|
|
Galaxy == null || Galaxy.IsPrelude ||
|
|
Galaxy.ContainsFaction(product.Manufacturer) && (Faction == null || Faction.Allegiance.ContainsKey(product.Manufacturer));
|
|
|
|
// Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters
|
|
private float ManufacturerPreference(CultRecordRef<Faction> manufacturer)
|
|
{
|
|
if (Faction == null || Galaxy == null) return 1;
|
|
var maker = ItemManager.ItemData.Get(manufacturer);
|
|
var allegiance = maker == Faction ? 1 :
|
|
Faction.Allegiance.TryGetValue(manufacturer, out var a) ? a : 0;
|
|
var home = maker != null && Galaxy.HomeZones.TryGetValue(maker, out var h) ? h : null;
|
|
var distance = home != null && Zone?.Distance != null && Zone.Distance.TryGetValue(home, out var d) ? d : 0;
|
|
return allegiance / (1 + distance);
|
|
}
|
|
|
|
public (FactionProductData product, T design) RandomProduct<T>(float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
|
|
{
|
|
return RandomProducts(1, sizeExponent, filter, required).FirstOrDefault();
|
|
}
|
|
|
|
public (FactionProductData product, T design) RandomProduct<T>(HardpointData hardpoint, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
|
|
{
|
|
return RandomProduct<T>(sizeExponent, item => item.HardpointType == hardpoint.Type &&
|
|
(filter?.Invoke(item) ?? true) &&
|
|
item.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) &&
|
|
item.Shape.Coordinates.Length==hardpoint.Shape.Coordinates.Length, required);
|
|
}
|
|
|
|
private void OutfitEntity(Entity entity)
|
|
{
|
|
EquipHardpoints(entity);
|
|
FillInterior(entity);
|
|
}
|
|
|
|
private void EquipHardpoints(Entity entity)
|
|
{
|
|
var hullData = ItemManager.GetData(entity.Hull) as HullData;
|
|
foreach (var v in hullData.Shape.Coordinates) entity.HullConductivity[v.x, v.y] = true;
|
|
var previousProducts = new List<(FactionProductData product, GearData design)>();
|
|
foreach (var hardpoint in hullData.Hardpoints.OrderByDescending(h=>h.Shape.Coordinates.Length))
|
|
{
|
|
if (hardpoint.Type == HardpointType.ControlModule)
|
|
{
|
|
var (controllerProduct, controllerData) = RandomProduct<GearData>(hardpoint, 2,
|
|
item => item.Behaviors.Any(b => entity is Ship && b is CockpitData || entity is OrbitalEntity && b is TurretControllerData), required: true);
|
|
if (controllerData == null)
|
|
throw new InvalidLoadoutException("No compatible controller found for entity!");
|
|
var controller = ItemManager.CreateInstance(controllerProduct) as EquippableItem;
|
|
if (!entity.TryEquip(controller))
|
|
{
|
|
throw new InvalidLoadoutException($"Failed to equip selected {Enum.GetName(typeof(HardpointType), hardpoint.Type)}!");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// If a previously selected product fits, use that one (this is why we must process larger hardpoints first)
|
|
var entry = previousProducts
|
|
.FirstOrDefault(e => e.design.HardpointType == hardpoint.Type && e.design.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _));
|
|
var previousItem = entity.Equipment.FirstOrDefault(item => item.Data == entry.design);
|
|
if (entry.design == null) entry = RandomProduct<GearData>(hardpoint, 2);
|
|
if (entry.design == null) ItemManager.Log($"No compatible item found for entity {Enum.GetName(typeof(HardpointType), hardpoint.Type)} hardpoint!");
|
|
else
|
|
{
|
|
var item = ItemManager.CreateInstance(entry.product) as EquippableItem;
|
|
// Matching hardpoints carry matching units: the same brand, workmanship, and parts
|
|
if (previousItem != null) CopyBuild(previousItem.EquippableItem, item);
|
|
if (!entity.TryEquip(item))
|
|
{
|
|
throw new InvalidLoadoutException($"Failed to equip selected {Enum.GetName(typeof(HardpointType), hardpoint.Type)}!");
|
|
}
|
|
previousProducts.Add(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// Interior gear may use any free interior cell, including empty hardpoint cells
|
|
private void FillInterior(Entity entity)
|
|
{
|
|
var emptyShape = entity.UnoccupiedSpace;
|
|
|
|
var (cargoProduct, cargoData) = RandomProduct<CargoBayData>(3, item =>
|
|
!(item is DockingBayData) &&
|
|
item.Shape.FitsWithin(emptyShape, out _, out _), required: true);
|
|
if (cargoData == null) throw new InvalidLoadoutException("No compatible cargo bay found for entity!");
|
|
|
|
cargoData.Shape.FitsWithin(emptyShape, out var cargoRotation, out var cargoPosition);
|
|
var cargo = ItemManager.CreateInstance(cargoProduct) as EquippableItem;
|
|
cargo.Rotation = cargoRotation;
|
|
if (!entity.TryEquip(cargo, cargoPosition))
|
|
throw new InvalidLoadoutException("Failed to equip selected cargo bay!");
|
|
|
|
emptyShape = entity.UnoccupiedSpace;
|
|
|
|
var (capacitorProduct, capacitorData) = RandomProduct<GearData>(2, item =>
|
|
item.Behaviors.Any(b => b is CapacitorData) &&
|
|
item.Shape.FitsWithin(emptyShape, out _, out _), required: true);
|
|
if (capacitorData == null) throw new InvalidLoadoutException("No compatible capacitor found for entity!");
|
|
|
|
capacitorData.Shape.FitsWithin(emptyShape, out var capacitorRotation, out var capacitorPosition);
|
|
var capacitor = ItemManager.CreateInstance(capacitorProduct) as EquippableItem;
|
|
capacitor.Rotation = capacitorRotation;
|
|
if (!entity.TryEquip(capacitor, capacitorPosition))
|
|
throw new InvalidLoadoutException("Failed to equip selected capacitor!");
|
|
}
|
|
|
|
// A second unit off the same line: same workmanship and the same parts, not a fresh roll.
|
|
private static void CopyBuild(CraftedItemInstance from, CraftedItemInstance to)
|
|
{
|
|
to.Quality = from.Quality;
|
|
to.Ingredients = from.Ingredients?
|
|
.Select(fill => new RoleFill { Role = fill.Role, Quality = fill.Quality })
|
|
.ToList() ?? new List<RoleFill>();
|
|
}
|
|
}
|
|
|
|
public class InvalidLoadoutException : Exception
|
|
{
|
|
public InvalidLoadoutException()
|
|
{
|
|
}
|
|
|
|
public InvalidLoadoutException(string message)
|
|
: base(message)
|
|
{
|
|
}
|
|
|
|
public InvalidLoadoutException(string message, Exception inner)
|
|
: base(message, inner)
|
|
{
|
|
}
|
|
}
|