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 <noreply@anthropic.com>
348 lines
18 KiB
C#
348 lines
18 KiB
C#
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using CultMath;
|
|
using Xunit;
|
|
using static CultMath.math;
|
|
|
|
// An equipped item knows the one hardpoint it occupies (EquippedItem.Hardpoint), decided once by the placement rule at
|
|
// equip. Its origin cell need not lie in that hardpoint: these are Soul's scenarios batch-4 P30 fixtures, where reading
|
|
// the hardpoint from the origin cell found nothing, or found a neighbour.
|
|
public sealed partial class RunStartTests
|
|
{
|
|
// A 2x2 L whose origin cell (0, 0) is empty.
|
|
private static Shape LShape()
|
|
{
|
|
var l = new Shape(2, 2);
|
|
l[int2(1, 0)] = true;
|
|
l[int2(0, 1)] = true;
|
|
l[int2(1, 1)] = true;
|
|
return l;
|
|
}
|
|
|
|
// A 2x1 design whose only cell is (1, 0): its origin lies one cell left of wherever it is placed.
|
|
private static Shape EdgeShape()
|
|
{
|
|
var edge = new Shape(2, 1);
|
|
edge[int2(1, 0)] = true;
|
|
return edge;
|
|
}
|
|
|
|
private static HardpointData Mount(HardpointType type, int x, int y, Shape shape = null, float arc = 0,
|
|
ItemRotation rotation = ItemRotation.None) =>
|
|
new HardpointData { Type = type, Position = int2(x, y), Shape = shape ?? new Shape(), FiringArc = arc, Rotation = rotation };
|
|
|
|
private GearData ScratchGear(string name, HardpointType type, Shape shape)
|
|
{
|
|
var design = ScratchGear(name, type, 1, 1);
|
|
design.Shape = shape;
|
|
return design;
|
|
}
|
|
|
|
private static bool InHardpoint(HardpointData hardpoint, int2 hullCell) => hardpoint.Shape[hullCell - hardpoint.Position];
|
|
|
|
// P30 (a): an L gun in an L hardpoint sits at origin (1, 1), a cell outside the hardpoint, yet knows its hardpoint, and
|
|
// FireControl reads that hardpoint's authored arc rather than the default.
|
|
[Fact]
|
|
public void AnLShapedGunInAnLHardpointKnowsItsHardpoint()
|
|
{
|
|
var hull = ScratchHull("P30 L Mount", 9, Mount(HardpointType.Ballistic, 1, 1, LShape(), arc: 360));
|
|
var gun = ScratchGear("P30 L Gun", HardpointType.Ballistic, LShape());
|
|
var entity = BareHull(hull);
|
|
Assert.True(entity.TryEquip(Instance(gun)));
|
|
var item = entity.Equipment.Single(i => i.Data == gun);
|
|
|
|
Assert.False(InHardpoint(hull.Hardpoints[0], item.Position), $"the fixture's origin {item.Position} lies outside the hardpoint");
|
|
Assert.Same(hull.Hardpoints[0], item.Hardpoint);
|
|
Assert.NotEqual(360f, _items.GameplaySettings.FiringArc);
|
|
Assert.Equal(360f, FireControl.ArcFor(item));
|
|
}
|
|
|
|
// P30 (c) shape: an edge gun whose origin cell lies in a radiator hardpoint reports its ballistic hardpoint.
|
|
[Fact]
|
|
public void AnItemWhoseOriginIsInAnotherHardpointKnowsItsOwn()
|
|
{
|
|
var radiator = Mount(HardpointType.Radiator, 0, 4);
|
|
var ballistic = Mount(HardpointType.Ballistic, 1, 4, arc: 360);
|
|
var hull = ScratchHull("P30 Neighbours", 9, radiator, ballistic);
|
|
var gun = ScratchGear("P30 Edge Gun", HardpointType.Ballistic, EdgeShape());
|
|
var entity = BareHull(hull);
|
|
Assert.True(entity.TryEquip(Instance(gun), int2(0, 4)));
|
|
var item = entity.Equipment.Single(i => i.Data == gun);
|
|
|
|
Assert.True(InHardpoint(radiator, item.Position), "the fixture's origin lies in the radiator hardpoint");
|
|
Assert.Same(ballistic, item.Hardpoint);
|
|
Assert.Equal(360f, FireControl.ArcFor(item));
|
|
}
|
|
|
|
// P30 (b): for a design lacking its origin cell, TryFindSpace finds a placement wherever ItemFits accepts one, and the
|
|
// origin it returns passes ItemFits and equips into that hardpoint. Unrotated, and turned so the item's extent swaps
|
|
// axes (a counter-clockwise edge gun lies one cell below its origin).
|
|
[Fact]
|
|
public void TryFindSpaceFindsEveryPlacementTheFitRuleAccepts()
|
|
{
|
|
var gun = ScratchGear("P30 Edge Search Gun", HardpointType.Ballistic, EdgeShape());
|
|
var cases = new[]
|
|
{
|
|
(hull: ScratchHull("P30 Edge Mount", 9, Mount(HardpointType.Ballistic, 0, 4)), fits: int2(-1, 4)),
|
|
(hull: ScratchHull("P30 Turned Mount", 9, Mount(HardpointType.Ballistic, 4, 0, rotation: ItemRotation.CounterClockwise)), fits: int2(4, -1)),
|
|
};
|
|
foreach (var (hull, fits) in cases)
|
|
{
|
|
var entity = BareHull(hull);
|
|
var unit = Instance(gun);
|
|
Assert.True(entity.ItemFits(unit, fits), $"{hull.Name}: the fit rule accepts {fits}");
|
|
Assert.True(entity.TryFindSpace(unit, out var found), $"{hull.Name}: TryFindSpace finds what ItemFits accepts");
|
|
Assert.True(entity.ItemFits(unit, found), $"{hull.Name}: the found origin {found} passes ItemFits");
|
|
Assert.True(entity.TryEquip(unit, found));
|
|
Assert.Same(hull.Hardpoints[0], entity.Equipment.Single(i => i.Data == gun).Hardpoint);
|
|
}
|
|
}
|
|
|
|
// A saved entity reloads each item into the same hardpoint, the L gun and the edge gun at a negative origin alike.
|
|
[Fact]
|
|
public void AnEquippedItemReloadsWithTheSameHardpoint()
|
|
{
|
|
var hull = ScratchHull("P30 Reload Mounts", 9, Mount(HardpointType.Ballistic, 1, 1, LShape()), Mount(HardpointType.Ballistic, 0, 6));
|
|
var lGun = ScratchGear("P30 Reload L Gun", HardpointType.Ballistic, LShape());
|
|
var edgeGun = ScratchGear("P30 Reload Edge Gun", HardpointType.Ballistic, EdgeShape());
|
|
var entity = BareHull(hull);
|
|
Assert.True(entity.TryEquip(Instance(lGun), int2(1, 1)));
|
|
Assert.True(entity.TryEquip(Instance(edgeGun), int2(-1, 6)));
|
|
|
|
var reloaded = EntitySerializer.Unpack(_items, null, EntitySerializer.Pack(entity));
|
|
foreach (var design in new[] { lGun, edgeGun })
|
|
{
|
|
var before = hull.Hardpoints.IndexOf(entity.Equipment.Single(i => i.Data == design).Hardpoint);
|
|
var after = reloaded.HullData.Hardpoints.IndexOf(reloaded.Equipment.Single(i => i.Data == design).Hardpoint);
|
|
Assert.True(before >= 0, $"{design.Name} holds a hardpoint");
|
|
Assert.Equal(before, after);
|
|
}
|
|
}
|
|
|
|
// Tool gear occupies no hardpoint, even in a hardpoint's leftover cells; the hardpoint item beside it keeps its own.
|
|
[Fact]
|
|
public void ToolGearHasNoHardpoint()
|
|
{
|
|
var shape = new Shape(2, 1);
|
|
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
|
var hull = ScratchHull("P30 Shared Mount", 9, Mount(HardpointType.Ballistic, 1, 1, shape));
|
|
var gun = ScratchGear("P30 Small Gun", HardpointType.Ballistic, 1, 1);
|
|
var tool = ScratchGear("P30 Tool", HardpointType.Tool, 1, 1);
|
|
var entity = BareHull(hull);
|
|
Assert.True(entity.TryEquip(Instance(gun), int2(1, 1)));
|
|
Assert.True(entity.TryEquip(Instance(tool), int2(2, 1)), "tool gear in the hardpoint's leftover cell");
|
|
|
|
Assert.Null(entity.Equipment.Single(i => i.Data == tool).Hardpoint);
|
|
Assert.Same(hull.Hardpoints[0], entity.Equipment.Single(i => i.Data == gun).Hardpoint);
|
|
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<int2> cells, ItemRotation rotation)
|
|
{
|
|
var turned = cells.Select(c => rotation switch
|
|
{
|
|
ItemRotation.Clockwise => (x: c.y, y: -c.x),
|
|
ItemRotation.Reversed => (x: -c.x, y: -c.y),
|
|
ItemRotation.CounterClockwise => (x: -c.y, y: c.x),
|
|
_ => (x: c.x, y: c.y)
|
|
}).ToList();
|
|
if (turned.Count == 0) return new HashSet<(int, int)>();
|
|
var minX = turned.Min(c => c.x);
|
|
var minY = turned.Min(c => c.y);
|
|
return turned.Select(c => (c.x - minX, c.y - minY)).ToHashSet();
|
|
}
|
|
|
|
// IsFilledBy against an independent oracle: a design fills a hardpoint when it is of the hardpoint's type and its
|
|
// cells, turned to the hardpoint's rotation, are exactly the hardpoint's cells. Every catalog design against every
|
|
// catalog hardpoint of its type, plus the L fixtures, whose rotations a rectangle-only catalog would not tell apart.
|
|
[Fact]
|
|
public void IsFilledByAgreesWithACellOracle()
|
|
{
|
|
var l = LShape();
|
|
var hardpoints = _cache.GetAll<HullData>().SelectMany(hull => hull.Hardpoints).ToList();
|
|
hardpoints.AddRange(Enum.GetValues(typeof(ItemRotation)).Cast<ItemRotation>()
|
|
.Select(rotation => Mount(HardpointType.Ballistic, 0, 0, l, rotation: rotation)));
|
|
var designs = _cache.GetAll<EquippableItemData>().ToList();
|
|
designs.Add(new GearData { Name = "Oracle L", Hardpoint = HardpointType.Ballistic, Shape = l });
|
|
designs.Add(new GearData { Name = "Oracle Edge", Hardpoint = HardpointType.Ballistic, Shape = EdgeShape() });
|
|
|
|
int compared = 0, filled = 0;
|
|
var mismatches = new List<string>();
|
|
foreach (var hardpoint in hardpoints)
|
|
foreach (var design in designs.Where(d => d.HardpointType == hardpoint.Type))
|
|
{
|
|
var oracle = Normalized(design.Shape.Coordinates, hardpoint.Rotation).SetEquals(Normalized(hardpoint.Shape.Coordinates, ItemRotation.None));
|
|
compared++;
|
|
if (oracle) filled++;
|
|
if (hardpoint.IsFilledBy(design) != oracle && mismatches.Count < 10)
|
|
mismatches.Add($"{design.Name} in a {hardpoint.Shape.Width}x{hardpoint.Shape.Height} {hardpoint.Type} hardpoint turned {hardpoint.Rotation}: oracle {oracle}");
|
|
}
|
|
Assert.True(mismatches.Count == 0, string.Join("; ", mismatches));
|
|
Assert.True(filled > 0 && filled < compared, $"the oracle separates: {filled} of {compared} fill");
|
|
}
|
|
|
|
// Placement searches the hardpoint's last row and column: in a 3x3 hardpoint whose other cells hold tool gear, a
|
|
// one-cell gun is placed in the bottom-right cell, and once it is, the search finds no space.
|
|
[Fact]
|
|
public void PlacementSearchesTheLastRow()
|
|
{
|
|
var mount = new Shape(3, 3);
|
|
foreach (var cell in mount.AllCoordinates) mount[cell] = true;
|
|
var hull = ScratchHull("P30 Crowded Mount", 9, Mount(HardpointType.Ballistic, 1, 1, mount));
|
|
var gun = ScratchGear("P30 Corner Gun", HardpointType.Ballistic, 1, 1);
|
|
var tool = ScratchGear("P30 Filler", HardpointType.Tool, 1, 1);
|
|
var entity = BareHull(hull);
|
|
var last = int2(3, 3);
|
|
foreach (var cell in mount.Coordinates.Select(c => int2(1, 1) + c).Where(c => !c.Equals(last)))
|
|
Assert.True(entity.TryEquip(Instance(tool), cell), $"filler at {cell}");
|
|
|
|
var corner = Instance(gun);
|
|
Assert.True(entity.TryFindSpace(corner, out var found));
|
|
Assert.Equal(last, found);
|
|
Assert.True(entity.TryEquip(corner, found));
|
|
Assert.False(entity.TryFindSpace(Instance(gun), out _), "a full hardpoint offers no space");
|
|
}
|
|
|
|
// Billing does not depend on equip order: two heaters with different targets and draws, equipped in either order,
|
|
// bill the bus the same at every step of a 60 s temperature ramp that crosses both targets.
|
|
[Fact]
|
|
public void ThermostatOrderDoesNotChangeBilling()
|
|
{
|
|
GearData Heater(string name, float target, float draw)
|
|
{
|
|
var design = ScratchGear(name, HardpointType.Tool, 1, 1);
|
|
design.MinimumTemperature = 100;
|
|
design.MaximumTemperature = 500;
|
|
design.OptimalTemperature = 280;
|
|
design.PlateauWidth = 200; // online at every temperature of the ramp
|
|
design.Behaviors.Add(new ThermotoggleData { Group = 0, TargetTemperature = target });
|
|
design.Behaviors.Add(new EnergyDrawData { Group = 0, EnergyDraw = new PerformanceStat { Min = draw, Max = draw }, PerSecond = true });
|
|
return design;
|
|
}
|
|
var low = Heater("P30 Low Heater", 260, 10);
|
|
var high = Heater("P30 High Heater", 320, 7);
|
|
var hull = Hull("Djinni");
|
|
var zone = Arena(false);
|
|
|
|
List<float> Bills(params GearData[] order)
|
|
{
|
|
var ship = BareHull(hull);
|
|
ship.Zone = zone;
|
|
foreach (var design in order) Assert.True(ship.TryEquip(Instance(design)), $"{design.Name} equips");
|
|
zone.Admit(ship, piloted: false);
|
|
for (var tick = 0; tick < 5; tick++) zone.Update(.1f);
|
|
var bills = new List<float>();
|
|
for (var tick = 0; tick < 600; tick++)
|
|
{
|
|
var temperature = 220 + 140 * tick / 599f;
|
|
foreach (var v in ship.HullData.Shape.Coordinates) ship.Temperature[v.x, v.y] = temperature;
|
|
ship.PowerBus.Step(.1f);
|
|
bills.Add(ship.PowerBus.TotalDemand);
|
|
}
|
|
return bills;
|
|
}
|
|
var lowFirst = Bills(low, high);
|
|
var highFirst = Bills(high, low);
|
|
|
|
Assert.Equal(lowFirst, highFirst);
|
|
Assert.True(lowFirst.Distinct().Count() >= 3, $"the ramp crosses both thermostats: bills {string.Join(", ", lowFirst.Distinct())}");
|
|
}
|
|
|
|
// The readers that bind muzzles, arcs, thruster emitters and radiators dereference item.Hardpoint: on generated
|
|
// ships, stations and turrets every weapon, thruster and radiator holds a hardpoint containing all its cells, and
|
|
// Tool gear and the hull hold none (Soul's scenarios-adopt probe S4, finding headless-reader-input).
|
|
[Fact]
|
|
public void GeneratedLoadoutsGiveEveryReaderItsHardpoint()
|
|
{
|
|
var shipHulls = LoadoutGenerator.SoldHulls(_cache).Where(h => h.HullType == HullType.Ship).Select(h => h.Name).ToList();
|
|
var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f);
|
|
var packs = shipHulls.SelectMany(name => Enumerable.Range(0, 2).Select(_ => generator.GenerateShipLoadout(h => h.Name == name)))
|
|
.Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateStationLoadout(TestStationKinds.TradeHub)))
|
|
.Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateTurretLoadout()))
|
|
.ToList();
|
|
var readers = 0;
|
|
foreach (var pack in packs)
|
|
{
|
|
var entity = EntitySerializer.Unpack(_items, null, pack);
|
|
Assert.Null(entity.EquippedHull.Hardpoint);
|
|
foreach (var item in entity.Equipment.Where(i => i != entity.EquippedHull))
|
|
{
|
|
var label = $"{entity.HullData.Name}/{item.Data.Name}";
|
|
if (item.Data.HardpointType == HardpointType.Tool) Assert.True(item.Hardpoint == null, $"{label}: Tool gear holds no hardpoint");
|
|
if (!item.Behaviors.Any(b => b is Weapon || b is Thruster || b is Radiator)) continue;
|
|
readers++;
|
|
var hp = item.Hardpoint;
|
|
Assert.True(hp != null, $"{label}: a reader item holds a hardpoint");
|
|
var cells = item.Data.Shape.Coordinates.Select(c => item.Position + item.Data.Shape.Rotate(c, item.EquippableItem.Rotation));
|
|
Assert.True(cells.All(c => hp.Shape[c - hp.Position]), $"{label}: its hardpoint contains every cell");
|
|
}
|
|
}
|
|
Assert.True(readers > 0, "the generated loadouts carry weapons, thrusters or radiators");
|
|
}
|
|
}
|