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:
parent
d50fd1b973
commit
ff2be8e26f
2 changed files with 132 additions and 16 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue