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:
commit
2c087bf2b0
7 changed files with 262 additions and 10 deletions
|
|
@ -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)
BIN
GameData/Aetheria.cc
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
99
tests/Aetheria.Shared.Tests/StationReactorSpreadTests.cs
Normal file
99
tests/Aetheria.Shared.Tests/StationReactorSpreadTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
82
tools/AetherDb/StationReactorSpreadCommand.cs
Normal file
82
tools/AetherDb/StationReactorSpreadCommand.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue