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/GameData/Aetheria.cc b/GameData/Aetheria.cc index da60f2dd3..f4cfdc4a3 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:319c14830b174e4bd58fe849b1300f38d7724d91027e47a03a5c121741d38e51 +oid sha256:9edc8434524f43f751c89416479cf07fba03b424f266cb8bfc1870bd46eef6a7 size 220537 diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 31b71dba0..7b7182400 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -54,7 +54,11 @@ public sealed partial class RunStartTests // moved the digest and returned it to 52fbb10d, which master already measured before this cut. The authored-cargo and // ammunition-data blob of this cut moved nothing (52fbb10d measured with it merged in). Fixtures run ThrustScale=1 while play // uses 0.1, so this digest pins the fight's logic, not the shipped feel. - private const string TwoAiPinnedDigest = "52fbb10df2559829a11f3a38aae0783f3f1858b0499f6f46794a267a747adacb"; + // Repinned a ninth time, once (scenarios-batch4-minors): the catalog restores the five station-reactor products' quality + // spread (station-reactor-spread), and that blob alone moved the digest (the same tree with the old catalog measured 52fbb10d; + // traced: only Notorious Station moves it, because the zone's station Zenith carries a Notorious Station reactor lot whose + // minted quality changes with the role spread, and Snapshot hashes every zone entity's heat and shield). + private const string TwoAiPinnedDigest = "9c70a3ee234fb3b879f721a4a4abf13dd83bf56fd8733690d356b89b8a392258"; private const float TwoAiSeconds = 60f; [Fact] 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) diff --git a/tests/Aetheria.Shared.Tests/StationReactorSpreadTests.cs b/tests/Aetheria.Shared.Tests/StationReactorSpreadTests.cs new file mode 100644 index 000000000..41161975a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationReactorSpreadTests.cs @@ -0,0 +1,99 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using Xunit; + +// station-reactor-spread (aetheria-release follow-up scenarios-batch4-minors): a station reactor's product keeps the +// quality spread of the reactor product it was scaled from. The mint reads a product's Roles (Mean, StandardDeviation) per +// design role, and a role missing or left at the unauthored default draws a flat .5 / .15 instead of the maker's spread. +public sealed class StationReactorSpreadTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-station-spread-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public StationReactorSpreadTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + // Read the way the mint does (ItemManager.Mint): the product's own Roles, matched by role name. + [Fact] + public void ShippedStationReactorProductsCarryTheirSourcesSpread() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var products = cache.GetAll().ToArray(); + FactionProductData Find(string name, string maker) => + products.Single(p => p.Name == name && cache.Get(p.Manufacturer)?.ShortName == maker); + + foreach (var (stationName, sourceName, maker) in StationReactorSpreadCommand.Rows) + { + var station = Find(stationName, maker); + var source = Find(sourceName, maker); + Assert.NotEmpty(station.Roles); + foreach (var role in station.Roles) + { + var from = source.Roles.Single(r => r.Role == role.Role); + Assert.Equal(from.Mean, role.Mean); + Assert.Equal(from.StandardDeviation, role.StandardDeviation); + } + Assert.Contains(station.Roles, r => r.Mean != .5f || r.StandardDeviation != .15f); + } + } + + private void Seed() + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Upsert(new VerseGrammar { Revision = 1 }); + var maker = cache.Upsert(new Faction { Name = "Maker", ShortName = "M" }); + var gear = cache.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 10 }); + var design = new CultRecordRef(gear.Key); + cache.Upsert(new FactionProductData + { + Name = "Source", Design = design, Manufacturer = maker, + Roles = { new ProductRole { Role = "core", Mean = .7f, StandardDeviation = .05f }, new ProductRole { Role = "regulator", Mean = .3f, StandardDeviation = .2f } } + }); + cache.Upsert(new FactionProductData + { + Name = "Source Station", Design = design, Manufacturer = maker, + Roles = { new ProductRole { Role = "core" }, new ProductRole { Role = "regulator", Mean = .9f, StandardDeviation = .01f } } + }); + cache.FlushAsync().Wait(); + } + + private static readonly (string Station, string Source, string Maker)[] Fixture = { ("Source Station", "Source", "M") }; + + private string Run(bool apply) + { + using var cache = AetheriaStores.Open(Catalog, catalogWritable: apply); + var output = new StringWriter(); + Assert.Equal(0, StationReactorSpreadCommand.Run(cache, apply, output, Fixture)); + cache.FlushAsync().Wait(); + return output.ToString(); + } + + private ProductRole RoleOf(string role) + { + using var cache = AetheriaStores.Open(Catalog); + return cache.GetAll().Single(p => p.Name == "Source Station").Roles.Single(r => r.Role == role); + } + + // A role at the unauthored default takes the source's pair, a role tuned in Studio is kept, a dry run writes nothing, + // and a second apply has nothing to do. + [Fact] + public void CopiesOnlyUnauthoredRolesAndReplaysToNothing() + { + Seed(); + var before = File.ReadAllBytes(Catalog); + Assert.Contains("core", Run(apply: false)); + Assert.Equal(before, File.ReadAllBytes(Catalog)); + + Run(apply: true); + Assert.Equal(.7f, RoleOf("core").Mean, 3); + Assert.Equal(.05f, RoleOf("core").StandardDeviation, 3); + Assert.Equal(.9f, RoleOf("regulator").Mean, 3); + Assert.Equal(.01f, RoleOf("regulator").StandardDeviation, 3); + + var written = File.ReadAllBytes(Catalog); + Assert.Contains("No changes", Run(apply: true)); + Assert.Equal(written, File.ReadAllBytes(Catalog)); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 5d3d18e93..2caaaed4b 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -59,6 +59,7 @@ public static class Program case "inspection-scanner": return InspectionScannerCatalog(args.Contains("apply")); case "product-lines": return ProductLinesCommand.Run(args.Skip(1).ToArray()); case "minor-power-factions": return MinorPowerFactionsCommand.Run(args.Skip(1).ToArray()); + case "station-reactor-spread": return StationReactorSpreadCommand.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); case "assembly-from-maker": return AssemblyFromMakerCommand.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); case "place-names": Console.OutputEncoding = new System.Text.UTF8Encoding(false); diff --git a/tools/AetherDb/StationReactorSpreadCommand.cs b/tools/AetherDb/StationReactorSpreadCommand.cs new file mode 100644 index 000000000..6f7064fb3 --- /dev/null +++ b/tools/AetherDb/StationReactorSpreadCommand.cs @@ -0,0 +1,82 @@ +/* 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.IO; +using System.Linq; +using GameCult.Caching; + +// station-reactor-spread [apply] (aetheria-release follow-up scenarios-batch4-minors): the per-manufacturer station reactors +// (f458206c7) were authored as scaled copies of each maker's largest reactor, but their products were authored with a bare +// ProductRole { Role } for each role, which is the .5 / .15 default, so they lost the source product's quality spread. This +// gives each station product the Mean and StandardDeviation the source product holds for the same role. It writes only a role +// still at the default pair, so a spread tuned in Studio survives every replay, and a second apply has nothing to do. Replays +// before assembly-from-maker (which derives from role means) when that command has an unauthored maker to fill. Dry run +// unless apply. +public static class StationReactorSpreadCommand +{ + // The role pair an unauthored ProductRole carries (ProductRole's field defaults). + private static readonly ProductRole Unauthored = new ProductRole(); + + // (station product, source product, maker short name): the source is the maker's product of the reactor the station + // design was scaled from. + public static readonly (string Station, string Source, string Maker)[] Rows = + { + ("Steadfast Station", "Steadfast", "R&D"), + ("Manhattan Station", "Manhattan", "Zhestokost"), + ("Notorious Station", "Notorious", "Lucent"), + ("Vulcan Station", "Vulcan", "AU"), + ("MoveOnPro Station", "MoveOnPro", "NiteLife"), + }; + + public static int Run(CultCache cache, bool apply, TextWriter output, (string Station, string Source, string Maker)[] rows = null) + { + var products = cache.GetAll().ToArray(); + FactionProductData Find(string name, string maker) + { + var found = products.Where(p => p.Name == name && cache.Get(p.Manufacturer)?.ShortName == maker).ToArray(); + if (found.Length != 1) throw new InvalidOperationException($"Station reactor row \"{name}\" by {maker}: {found.Length} products match."); + return found[0]; + } + + var changed = new List(); + foreach (var (stationName, sourceName, maker) in rows ?? Rows) + { + var station = Find(stationName, maker); + var source = Find(sourceName, maker); + foreach (var role in station.Roles) + { + var from = source.Roles.SingleOrDefault(r => r.Role == role.Role) + ?? throw new InvalidOperationException($"\"{sourceName}\" holds no \"{role.Role}\" role to copy to \"{stationName}\"."); + if (role.Mean != Unauthored.Mean || role.StandardDeviation != Unauthored.StandardDeviation) continue; + if (from.Mean == role.Mean && from.StandardDeviation == role.StandardDeviation) continue; + output.WriteLine($"{stationName} ({maker}) {role.Role}: {role.Mean:0.###}±{role.StandardDeviation:0.###} -> {from.Mean:0.###}±{from.StandardDeviation:0.###}"); + role.Mean = from.Mean; + role.StandardDeviation = from.StandardDeviation; + if (!changed.Contains(station)) changed.Add(station); + } + } + + if (changed.Count == 0) + { + output.WriteLine("No changes."); + return 0; + } + + if (!apply) + { + output.WriteLine($"\nDry run. Pass \"apply\" to write {changed.Count} products."); + return 0; + } + + foreach (var product in changed) CultRecordRefs.Validate(product); + cache.Commit(batch => + { + foreach (var product in changed) batch.Upsert(typeof(FactionProductData), product, cache.RefOf(product).Key); + }); + output.WriteLine($"\nWrote {changed.Count} products in Aetheria.cc"); + return 0; + } +}