diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 2394a6134..43d9729d9 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1525,7 +1525,8 @@ public abstract class Entity // Check whether the given item will fit when its origin is placed at the given coordinate. For hardpoint gear, // hardpoint is the one hardpoint the item would occupy there (HardpointAt); Tool gear occupies none. - private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord, out HardpointData hardpoint) + private bool ItemFits(EquippableItemData itemData, HullData hullData, EquippableItem item, int2 hullCoord, out HardpointData hardpoint, + EquippedItem moving = null) { hardpoint = null; // Items without specific hardpoints on the ship can be freely rotated and placed anywhere @@ -1538,13 +1539,13 @@ public abstract class Entity // 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] || GearOccupancy[itemCoord.x, itemCoord.y] != null) + if (!hullData.InteriorCells[itemCoord] || !IsFree(itemCoord.x, itemCoord.y, moving)) return false; } } else { - hardpoint = HardpointAt(itemData, hullData, hullCoord); + hardpoint = HardpointAt(itemData, hullData, hullCoord, moving); if (hardpoint == null) return false; item.Rotation = hardpoint.Rotation; } @@ -1552,27 +1553,33 @@ public abstract class Entity return true; } + // A cell is free when no gear occupies it, or when the only occupant is the item being moved: a re-place of + // equipped gear is judged against the hull minus that item's own cells (the caller unequips, then equips). + private bool IsFree(int x, int y, EquippedItem moving) => GearOccupancy[x, y] == null || GearOccupancy[x, y] == moving; + // 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) + private HardpointData HardpointAt(EquippableItemData itemData, HullData hullData, int2 hullCoord, EquippedItem moving) { 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); + .All(c => IsFree(c.x, c.y, moving)); 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); + .Any(occupant => occupant != null && occupant != moving && 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 + // Check whether the given item will fit when its origin is placed at the given coordinate on the hull. An item that + // is already equipped here is judged without its own cells, so a gun can be moved within its own hardpoint; + // TryEquip stays strict (it never sees an item as its own occupant), so equipping an equipped item twice is refused. public bool ItemFits(EquippableItem item, int2 hullCoord) { // Don't allow equipping while deployed @@ -1580,7 +1587,7 @@ public abstract class Entity var itemData = ItemManager.GetData(item); var hullData = ItemManager.GetData(Hull) as HullData; - return ItemFits(itemData, hullData, item, hullCoord, out _); + return ItemFits(itemData, hullData, item, hullCoord, out _, Equipment.FirstOrDefault(e => e.EquippableItem == item)); } public bool TryFindSpace(EquippableItem item, out int2 hullCoord) diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 2b8fa2270..1ddc1d10b 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -142,6 +142,65 @@ public sealed partial class RunStartTests Assert.Null(entity.EquippedHull.Hardpoint); } + private static Shape Bar(int width) + { + var bar = new Shape(width, 1); + foreach (var cell in bar.AllCoordinates) bar[cell] = true; + return bar; + } + + // Scenarios batch-4 minor: dragging an equipped gun within its own hardpoint. The UI asks ItemFits about the dragged + // item at its current and nudged origin, then TryUnequip and TryEquip at the target. The gun's own cells are not + // occupied against itself, so the nudge that overlaps its current cells is accepted and the sequence lands there. + [Fact] + public void AnEquippedGunCanBeMovedWithinItsOwnHardpoint() + { + var hull = ScratchHull("Minor Drag Mount", 9, Mount(HardpointType.Ballistic, 1, 1, Bar(3))); + var gun = ScratchGear("Minor Drag Gun", HardpointType.Ballistic, Bar(2)); + var entity = BareHull(hull); + var unit = Instance(gun); + Assert.True(entity.TryEquip(unit, int2(1, 1))); + var equipped = entity.Equipment.Single(i => i.Data == gun); + + Assert.True(entity.ItemFits(unit, int2(1, 1)), "its current origin"); + Assert.True(entity.ItemFits(unit, int2(2, 1)), "nudged one cell, overlapping its own cells"); + + Assert.NotNull(entity.TryUnequip(equipped)); + Assert.True(entity.TryEquip(unit, int2(2, 1))); + Assert.Equal(int2(2, 1), entity.Equipment.Single(i => i.Data == gun).Position); + Assert.Same(hull.Hardpoints[0], entity.Equipment.Single(i => i.Data == gun).Hardpoint); + } + + // Ignoring the item's own cells does not ignore anyone else's: a second hardpoint item cannot share the hardpoint, + // and TryEquip never treats an item as its own occupant, so equipping an equipped item again is refused. + [Fact] + public void MovingAnEquippedItemStillRespectsOtherOccupantsAndTryEquipStaysStrict() + { + var hull = ScratchHull("Minor Drag Strict", 9, Mount(HardpointType.Ballistic, 1, 1, Bar(4))); + var gun = ScratchGear("Minor Strict Gun", HardpointType.Ballistic, Bar(2)); + var other = ScratchGear("Minor Strict Other", HardpointType.Ballistic, Bar(1)); + var entity = BareHull(hull); + var unit = Instance(gun); + Assert.True(entity.TryEquip(unit, int2(1, 1))); + Assert.False(entity.TryEquip(Instance(other), int2(3, 1)), "the hardpoint holds one hardpoint item"); + Assert.False(entity.TryEquip(unit, int2(2, 1)), "TryEquip is strict: no duplicate of an equipped item"); + Assert.Single(entity.Equipment, i => i.Data == gun); + } + + // A tool dragged within its own cells is judged the same way. + [Fact] + public void AnEquippedToolCanBeMovedOverItsOwnCells() + { + var hull = ScratchHull("Minor Drag Tool Hull", 9); + var tool = ScratchGear("Minor Drag Tool", HardpointType.Tool, Bar(2)); + var entity = BareHull(hull); + var unit = Instance(tool); + Assert.True(entity.TryEquip(unit, int2(1, 1))); + Assert.True(entity.ItemFits(unit, int2(2, 1)), "nudged over its own cell"); + Assert.False(entity.TryEquip(unit, int2(2, 1)), "TryEquip never sees an item as its own occupant"); + Assert.Single(entity.Equipment, i => i.Data == tool); + } + // The design's cells, turned to a rotation, moved so the lowest x and y are zero. Written here from the cells alone // (the game's turn directions, up to translation), so it is not Shape.Rotate checking itself. private static HashSet<(int x, int y)> Normalized(IEnumerable cells, ItemRotation rotation)