One placement rule for hardpoint gear

Soul's S3 and the S2 ruling ("B is the design intent, saving slots in a
hardpoint can be a valid tradeoff for crowded ships if you really need to fit
an extra tool"). Entity.HardpointAt is the one placement rule: at an origin,
the item lies within a hardpoint of its type at that hardpoint's rotation
(HardpointData.TakesAt), its own cells are free, and that hardpoint holds no
other hardpoint item. The origin no longer has to be a hardpoint cell, and
TryFindSpace tries every offset inside a hardpoint, the offsets the fit rule
tries, so an L-shaped hardpoint takes its L design instead of generation
throwing. Leftover hardpoint cells may hold general gear, and a hardpoint still
holds one hardpoint item.

Tests: an L hardpoint takes its L design by hand and in generation (Soul's
P11); a turret's gun goes back beside the tool gear in its leftover cells
(P21); a second gun in a roomy hardpoint is refused; a saved entity reloads to
the same loadout with its items in reverse order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 23:13:37 +02:00
commit ff2be8e26f
2 changed files with 132 additions and 16 deletions

View file

@ -790,9 +790,6 @@ public abstract class Entity
// Check whether the given item will fit when its origin is placed at the given coordinate
private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord)
{
// If the given coordinate isn't even in the ship it obviously won't fit
if (!hullData.Shape[hullCoord]) return false;
// Items without specific hardpoints on the ship can be freely rotated and placed anywhere
if (itemData.HardpointType == HardpointType.Tool)
{
@ -809,24 +806,34 @@ public abstract class Entity
}
else
{
var hardpoint = Hardpoints[hullCoord.x, hullCoord.y];
// If there's no hardpoint there, it won't fit
var hardpoint = HardpointAt(itemData, hullData, hullCoord);
if (hardpoint == null) return false;
// The one fit rule, at this placement: the item, aligned to the hardpoint's rotation, lies within its cells
if (!hardpoint.TakesAt(itemData, hullCoord - hardpoint.Position)) return false;
item.Rotation = hardpoint.Rotation;
// One item per hardpoint: every cell of it is free (and so every cell of the item)
foreach (var v in hullData.Shape.Inset(hardpoint.Shape, hardpoint.Position).Coordinates)
if (GearOccupancy[v.x, v.y] != null)
return false;
}
return true;
}
// The one placement rule for hardpoint gear. At this origin the item lies within a hardpoint of its type, turned to
// that hardpoint's rotation (HardpointData.TakesAt, the fit rule); its own cells are free, since a hardpoint's
// leftover cells may hold general gear (operator, 2026-09-30: "saving slots in a hardpoint can be a valid tradeoff
// for crowded ships"); and that hardpoint holds no other hardpoint item, since it holds one at most.
private HardpointData HardpointAt(EquippableItemData itemData, HullData hullData, int2 hullCoord)
{
foreach (var hardpoint in hullData.Hardpoints)
{
if (!hardpoint.TakesAt(itemData, hullCoord - hardpoint.Position)) continue;
var ownCellsFree = itemData.Shape.Coordinates
.Select(v => hullCoord + itemData.Shape.Rotate(v, hardpoint.Rotation))
.All(c => GearOccupancy[c.x, c.y] == null);
var holdsAnother = hardpoint.Shape.Coordinates
.Select(v => GearOccupancy[hardpoint.Position.x + v.x, hardpoint.Position.y + v.y])
.Any(occupant => occupant != null && occupant.Data.HardpointType != HardpointType.Tool);
if (ownCellsFree && !holdsAnother) return hardpoint;
}
return null;
}
// Check whether the given item will fit when its origin is placed at the given coordinate on the hull
public bool ItemFits(EquippableItem item, int2 hullCoord)
{
@ -867,13 +874,15 @@ public abstract class Entity
// Search the ship for an empty hardpoint that matches the type and shape of the item
else
{
// Every offset within a hardpoint of the item's type, the same offsets the fit rule tries
foreach (var hardpoint in hullData.Hardpoints)
{
if(hardpoint.Type == itemData.HardpointType)
{
foreach (var hardpointCoord in hardpoint.Shape.Coordinates)
for (var x = 0; x < hardpoint.Shape.Width; x++)
for (var y = 0; y < hardpoint.Shape.Height; y++)
{
var hullCoord2 = hardpoint.Position + hardpointCoord;
var hullCoord2 = hardpoint.Position + int2(x, y);
if (ItemFits(itemData, hullData, item, hullCoord2))
{
hullCoord = hullCoord2;

View file

@ -825,6 +825,113 @@ public sealed class RunStartTests : IDisposable
Assert.True(missing.Count == 0, $"sold weapons with no damage curve: {string.Join(", ", missing)}");
}
// An empty hull of this kind to equip into by hand.
private Entity BareHull(HullData hull) =>
new Ship(_items, null, (EquippableItem) _items.CreateInstance(_cache.GetAll<FactionProductData>().First(p => p.Design.Key.Equals(_cache.RefOf(hull).Key))),
_items.GameplaySettings.DefaultEntitySettings);
private HullData ScratchHull(string name, int size, params HardpointData[] hardpoints)
{
var shape = new Shape(size, size);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
var hull = new HullData { Name = name, HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 1000, Price = 1 };
hull.Hardpoints.AddRange(hardpoints);
_cache.Upsert(hull);
_cache.Upsert(new FactionProductData
{
Name = name, Design = new CultRecordRef<CraftedItemData>(_cache.RefOf(hull).Key), Manufacturer = _cache.RefOf(_protagonist)
});
return hull;
}
private EquippableItem Instance(EquippableItemData design) =>
(EquippableItem) _items.CreateInstance(_cache.GetAll<FactionProductData>().First(p => p.Design.Key.Equals(_cache.RefOf(design).Key)));
// Placement searches every offset the fit rule does: an L-shaped hardpoint, whose origin cell is empty, takes the L
// design that fills it, by hand and in generation.
[Fact]
public void AnLShapedHardpointTakesItsLDesign()
{
var l = new Shape(2, 2);
l[int2(1, 0)] = true;
l[int2(0, 1)] = true;
l[int2(1, 1)] = true;
var hull = ScratchHull("L Mount", 9, new HardpointData { Type = HardpointType.Ballistic, Position = int2(1, 1), Shape = l });
var gun = ScratchGear("L Gun", HardpointType.Ballistic, 1, 1);
gun.Shape = l;
Assert.True(hull.Hardpoints[0].IsFilledBy(gun));
Assert.True(BareHull(hull).TryEquip(Instance(gun)), "equipping by hand");
var ship = EntitySerializer.Unpack(_items, null, PreludeGenerator().GenerateShipLoadout(candidate => candidate == hull));
Assert.True(ship.Equipment.Any(item => item.Data.HardpointType == HardpointType.Ballistic), "generation equips the L hardpoint");
}
// Generated entities whose hardpoints share leftover cells with general gear, found by generating until enough.
private List<(Entity entity, HardpointData hardpoint, EquippedItem held)> SharedHardpoints(int wanted)
{
var found = new List<(Entity, HardpointData, EquippedItem)>();
var generator = PreludeGenerator();
for (var i = 0; i < 400 && found.Count < wanted; i++)
{
var entity = EntitySerializer.Unpack(_items, null, generator.GenerateTurretLoadout());
foreach (var hardpoint in entity.HullData.Hardpoints)
{
var occupants = hardpoint.Shape.Coordinates.Select(c => entity.GearOccupancy[hardpoint.Position.x + c.x, hardpoint.Position.y + c.y])
.Where(o => o != null).Distinct().ToList();
var held = occupants.FirstOrDefault(o => o.Data.HardpointType == hardpoint.Type);
if (held != null && occupants.Any(o => o.Data.HardpointType == HardpointType.Tool)) found.Add((entity, hardpoint, held));
}
}
return found;
}
// A hardpoint's leftover cells may hold general gear (operator, 2026-09-30, "B is the design intent"): a hardpoint
// item needs only its own cells free. So a generated turret whose spare gun-mount cells hold tool gear takes its own
// gun back after the gun is pulled.
[Fact]
public void AHardpointItemGoesBackBesideGearInItsLeftoverCells()
{
var shared = SharedHardpoints(10);
Assert.True(shared.Count > 0, "generation shares hardpoint cells with general gear");
foreach (var (entity, hardpoint, held) in shared)
{
var item = entity.TryUnequip(held);
Assert.True(item != null && entity.TryEquip(item), $"{entity.HullData.Name}: {held.Data.Name} back into its {hardpoint.Type} hardpoint");
}
}
// A hardpoint holds at most one hardpoint item, however much room it has left.
[Fact]
public void AHardpointHoldsOneHardpointItem()
{
var mount = new Shape(8, 8);
foreach (var cell in mount.AllCoordinates) mount[cell] = true;
var hull = ScratchHull("Roomy Mount", 10, new HardpointData { Type = HardpointType.Ballistic, Position = int2(1, 1), Shape = mount });
var gun = ScratchGear("Small Gun", HardpointType.Ballistic, 1, 1);
var entity = BareHull(hull);
Assert.True(entity.TryEquip(Instance(gun)), "the first gun");
Assert.False(entity.TryEquip(Instance(gun)), "a second gun in the same hardpoint");
}
// Loading a saved entity equips its items one by one; the result does not depend on their order, even where general
// gear sits in a hardpoint's leftover cells.
[Fact]
public void SaveLoadGivesTheSameLoadoutInAnyOrder()
{
var shared = SharedHardpoints(3);
Assert.True(shared.Count > 0, "generation shares hardpoint cells with general gear");
string Loadout(Entity e) => string.Join(";", e.Equipment.Select(i => $"{i.Data.Name}@{i.Position}").OrderBy(s => s, StringComparer.Ordinal));
foreach (var entity in shared.Select(s => s.entity).Distinct())
{
var pack = EntitySerializer.Pack(entity);
var inOrder = Loadout(EntitySerializer.Unpack(_items, null, pack));
pack.Equipment = pack.Equipment.Reverse().ToArray();
var reversed = Loadout(EntitySerializer.Unpack(_items, null, pack));
Assert.Equal(Loadout(entity), inOrder);
Assert.Equal(inOrder, reversed);
}
}
// Q3, with the operator's 2026-09-30 ruling on the ten product-less designs: a design no product sells is a
// scenario test design and nothing else. A test design is one a scenario places: its preset's hull or a slot of
// it, or cargo it carries. These are the unsold designs no scenario places, by name.