From b1dae6218e80eeaf28a260d0a3754ed179dc95d3 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 12:43:37 +0000 Subject: [PATCH] Entity.ItemFits: an equipped item is judged without its own cells, so a gun moves within its hardpoint The public ItemFits(item, coord) the inventory drag asks counted the dragged equipped item's own cells as occupied, so a nudge overlapping its current cells was refused. The private rule now takes the item being moved as an excluded occupant. TryEquip and TryFindSpace pass none and stay strict, so equipping an equipped item twice is still refused; the UI keeps TryUnequip then TryEquip (cargo bay emptiness rule). Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 23 +++++--- .../HardpointOwnershipTests.cs | 59 +++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) 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)