Merge pull request 'Scenarios batch-4 minors: gun moves within its own hardpoint; station reactors regain quality spread' (#14) from nibu/scenarios-batch4-minors into master

This commit is contained in:
Nibu 2026-11-11 13:10:14 +00:00
commit 2c087bf2b0
7 changed files with 262 additions and 10 deletions

View file

@ -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)

BIN
GameData/Aetheria.cc (Stored with Git LFS)

Binary file not shown.

View file

@ -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]

View file

@ -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<int2> cells, ItemRotation rotation)

View file

@ -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<FactionProductData>().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<CraftedItemData>(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<FactionProductData>().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));
}
}

View file

@ -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);

View file

@ -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<FactionProductData>().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<FactionProductData>();
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;
}
}