Fix station docking bays, save-safe instance keys, and zero-weight draws

One rule now governs interior space: an empty hardpoint cell is usable, in
both UnoccupiedSpace and ItemFits. ItemFits used to refuse hardpoint cells
that UnoccupiedSpace offered, so stations failed to equip a docking bay.

CraftedItemInstance.Ingredients and Product move from keys 3 and 4 to 9 and
10, past every key the derived instances use. At 3 and 4 an older save reads
nil into a non-nullable Guid and fails to load.

WeightedRandomElements returns an empty array when every weight is zero,
instead of an array of nulls. LoadoutGenerator treats a missing Galaxy as
"every product available", the seam a fixture needs to generate loadouts
without a galaxy. ZoneGenerator skips a story station that fails to generate
and logs it, instead of throwing.

Verified: Aetheria.Shared and tools/AetherDb build headless; Unity 6000.3.24f1
batchmode compile succeeds; AetherDb doctor, station-fit and loadout pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-12 23:22:36 +02:00
commit 6bd3e17690
5 changed files with 25 additions and 12 deletions

View file

@ -532,13 +532,11 @@ public abstract class Entity
// Check every cell of the item's shape
foreach (var i in itemData.Shape.Coordinates)
{
// If there is any gear already occupying that space, it won't fit
// If there's a hardpoint there, it won't fit
// Thermal items have their own layer and do not collide with gear
// An interior cell is usable when no gear occupies it. An empty hardpoint cell counts as usable:
// general gear may take it until hardpoint gear claims it, which is the same rule
// UnoccupiedSpace reports, so what generation is offered and what equipping accepts agree.
var itemCoord = hullCoord + itemData.Shape.Rotate(i, item.Rotation);
if (!hullData.InteriorCells[itemCoord] ||
itemData.HardpointType == HardpointType.Tool && Hardpoints[itemCoord.x, itemCoord.y] != null ||
GearOccupancy[itemCoord.x, itemCoord.y] != null)
if (!hullData.InteriorCells[itemCoord] || GearOccupancy[itemCoord.x, itemCoord.y] != null)
return false;
}
}

View file

@ -34,6 +34,10 @@ public static class Extensions
totalWeight += weights[x];
}
// Nothing can be drawn from a set whose weights all come to nothing. Returning an empty array says so,
// where a full array of unset elements would read as "found these" and hand the caller nulls.
if (totalWeight <= 0) return new T[0];
var randomElements = new T[count];
for (int i = 0; i < count; i++)
{

View file

@ -37,12 +37,16 @@ public abstract class CraftedItemInstance : ItemInstance
// The workmanship of this particular unit, as distinct from the parts that went into it.
[JsonProperty("quality"), Key(2)] public float Quality;
// The part filling each of the design's roles, one level deep. Empty on items made before roles existed,
// and on parts themselves; stats then read Quality as they always did.
[JsonProperty("ingredients"), Key(3)] public List<RoleFill> Ingredients = new List<RoleFill>();
// Keys 9 and 10, past every key the derived instances use: an item saved before these existed has no
// element there at all. Keys 3 to 6 sit inside the old range and read back as nil, which a non-nullable
// Guid cannot deserialize, so nothing new may go there.
// How good the part filling each of the design's roles turned out. Empty on items made before roles
// existed; stats then read Quality as they always did.
[JsonProperty("ingredients"), Key(9)] public List<RoleFill> Ingredients = new List<RoleFill>();
// The manufacturer's branded product this was built as, naming it and carrying its flavor text.
[JsonProperty("product"), Key(4)] public Guid Product;
[JsonProperty("product"), Key(10)] public Guid Product;
// The quality a stat reads for one of the design's roles. An unnamed role, a design without roles, and an
// item built before roles existed all fall back to this item's own workmanship.

View file

@ -146,14 +146,16 @@ public class LoadoutGenerator
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.
private bool IsAvailable(FactionProductData product) =>
Galaxy.IsPrelude ||
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(Guid manufacturer)
{
if (Faction == null) return 1;
if (Faction == null || Galaxy == null) return 1;
var allegiance = manufacturer == Faction.ID ? 1 :
Faction.Allegiance.TryGetValue(manufacturer, out var a) ? a : 0;
var home = Galaxy.HomeZones.FirstOrDefault(h => h.Key.ID == manufacturer).Value;

View file

@ -296,6 +296,11 @@ public static class ZoneGenerator
var orbit = selectedStationOrbits[i];
var lagrangeOrbit = CreateLagrangeOrbit(orbit);
var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout();
if (station == null)
{
itemManager.Log($"Story station for {story.Faction?.Name} could not be generated; its story has nowhere to live!");
continue;
}
station.Orbit = lagrangeOrbit.ID;
station.SecurityLevel = story.Security;
station.SecurityRadius = pack.Radius;