diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 4ef347ead..d0641b695 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -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; diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index d0a888a3d..be6580a47 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -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().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(_cache.RefOf(hull).Key), Manufacturer = _cache.RefOf(_protagonist) + }); + return hull; + } + + private EquippableItem Instance(EquippableItemData design) => + (EquippableItem) _items.CreateInstance(_cache.GetAll().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.