From 2fd8cbe8e9f94bdffc2e41c8e9a9b5795f27f979 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:17:59 +0200 Subject: [PATCH 001/230] AetherDb: ship-authoring stats sets a package hull's mass, durability, armor, drag and top speed, judged before the file opens Ruling ship-authoring-stats-command. --top-speed writes a fixed top speed (Min = Max) on the hull's one VelocityLimit and refuses a hull with none. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/ShipProductTests.cs | 58 +++++++++++++++++++ tools/AetherDb/ShipAuthoringCommands.cs | 38 +++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 27e84629f..e06143f38 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -325,6 +325,64 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM", "--remove"); } + // The hull as data, to compare everything stats did not name. + private static string HullJson(string path) => Newtonsoft.Json.JsonConvert.SerializeObject(ShipAuthoringStore.Load(path).Hull); + + [Fact] + public void StatsWritesOnlyTheFieldsItNamesAndARefusalLeavesTheFileUntouched() + { + Environment.CurrentDirectory = _root.Path; + var path = Package("mod.skiff", "Skiffhull", 0); + var bare = Path.Combine(ShipFixture.WritePackage(Mods, "mod.bare", "Barehull", tweak: ship => ship.Hull.Behaviors.Clear()), "ship.cc"); + void RefusedAndUntouched(string file, params string[] flags) + { + var before = File.ReadAllBytes(file); + Assert.Equal(1, Run(new[] { "stats", file }.Concat(flags).ToArray()).Code); + Assert.Equal(before, File.ReadAllBytes(file)); + } + + RefusedAndUntouched(path); + RefusedAndUntouched(path, "--speed", "5"); + RefusedAndUntouched(path, "--mass"); + RefusedAndUntouched(path, "--mass", "NaN"); + RefusedAndUntouched(path, "--mass", "Infinity"); + RefusedAndUntouched(path, "--armor", "-1"); + RefusedAndUntouched(path, "--drag", "slow"); + RefusedAndUntouched(path, "--mass", "5", "--mass", "6"); + // A valid flag beside a bad one writes nothing. + RefusedAndUntouched(path, "--mass", "5", "--durability", "-2"); + RefusedAndUntouched(bare, "--top-speed", "50"); + RefusedAndUntouched(bare, "--mass", "5", "--top-speed", "50"); + + // Each flag sets its own field and nothing else. + var expected = ShipAuthoringStore.Load(path).Hull; + foreach (var (flag, value, set) in new (string, float, Action)[] + { + ("--mass", 60000f, hull => hull.Mass = 60000f), + ("--durability", 8000f, hull => hull.Durability = 8000f), + ("--armor", 40f, hull => hull.Armor = 40f), + ("--drag", .35f, hull => hull.Drag = .35f), + ("--top-speed", 37.5f, hull => { var speed = hull.Behaviors.OfType().Single().TopSpeed; speed.Min = speed.Max = 37.5f; }), + }) + { + var (code, output, error) = Run("stats", path, flag, value.ToString("R", System.Globalization.CultureInfo.InvariantCulture)); + Assert.True(code == 0, error); + Assert.Contains(flag.Substring(2), output); + set(expected); + Assert.Equal(Newtonsoft.Json.JsonConvert.SerializeObject(expected), HullJson(path)); + } + var written = ShipAuthoringStore.Load(path).Hull; + Assert.Equal((60000f, 8000f, 40f, .35f), (written.Mass, written.Durability, written.Armor, written.Drag)); + var top = written.Behaviors.OfType().Single().TopSpeed; + Assert.Equal((37.5f, 37.5f), (top.Min, top.Max)); + + // Several at once, and a hull with no top speed takes the others. + Assert.Equal(0, Run("stats", path, "--mass", "1", "--drag", "0").Code); + Assert.Equal((1f, 0f, 8000f), (ShipAuthoringStore.Load(path).Hull.Mass, ShipAuthoringStore.Load(path).Hull.Drag, ShipAuthoringStore.Load(path).Hull.Durability)); + Assert.Equal(0, Run("stats", bare, "--armor", "12").Code); + Assert.Equal(12f, ShipAuthoringStore.Load(bare).Hull.Armor); + } + [Fact] public void ValidateJudgesProductsOnADraftAndInspectListsThem() { diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index b745e3dbf..bce7eda08 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -8,7 +8,9 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | compose "; + private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | stats [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose "; + + private static readonly string[] StatFlags = { "--mass", "--durability", "--armor", "--drag", "--top-speed" }; public static int Run(string[] args) { @@ -91,6 +93,40 @@ public static class ShipAuthoringCommands Console.WriteLine($"{ship.Id}: price {price}."); return 0; } + if (args[0] == "stats" && args.Length >= 2) + { + // Every flag is judged before the file is touched: a refusal leaves it byte-identical. + var stats = new Dictionary(StringComparer.Ordinal); + for (var i = 2; i < args.Length; i += 2) + { + if (Array.IndexOf(StatFlags, args[i]) < 0) throw new InvalidOperationException($"stats: unknown flag {args[i]}."); + if (stats.ContainsKey(args[i])) throw new InvalidOperationException($"stats: {args[i]} is given twice."); + if (i + 1 == args.Length) throw new InvalidOperationException($"stats: {args[i]} needs a value."); + if (!float.TryParse(args[i + 1], NumberStyles.Float, CultureInfo.InvariantCulture, out var value) || !float.IsFinite(value) || value < 0) + throw new InvalidOperationException($"stats: {args[i]} must be a finite, non-negative number."); + stats[args[i]] = value; + } + if (stats.Count == 0) throw new InvalidOperationException("stats: name at least one stat."); + var (hull, ship, _) = ShipAuthoringStore.Load(args[1]); + if (stats.TryGetValue("--top-speed", out var topSpeed)) + { + var limits = (hull.Behaviors ?? new List()).OfType().ToArray(); + if (limits.Length != 1) throw new InvalidOperationException($"{ship.Id}: --top-speed needs exactly one VelocityLimit on the hull; it has {limits.Length}."); + // A fixed top speed, as every shipped hull authors one (Min equal to Max); its terms are kept. + limits[0].TopSpeed.Min = limits[0].TopSpeed.Max = topSpeed; + } + if (stats.TryGetValue("--mass", out var mass)) hull.Mass = mass; + if (stats.TryGetValue("--durability", out var durability)) hull.Durability = durability; + if (stats.TryGetValue("--armor", out var armor)) hull.Armor = armor; + if (stats.TryGetValue("--drag", out var drag)) hull.Drag = drag; + using (var cache = ShipAuthoringStore.Open(args[1], writable: true)) + { + ShipAuthoringStore.Write(cache, hull, ship); + cache.FlushAsync().Wait(); + } + Console.WriteLine($"{ship.Id}: " + string.Join(", ", stats.Select(stat => string.Create(CultureInfo.InvariantCulture, $"{stat.Key.Substring(2)} {stat.Value:R}"))) + "."); + return 0; + } if (args[0] == "product" && args.Length >= 4) { // A trailing --not-offered takes the product off the market (ruling off-market-is-unoffered-product); --remove takes none. From 0bccb395a45fa491a8fc44edad335f236a720abd Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:22:44 +0200 Subject: [PATCH 002/230] ShipProductTests: compare the stats hull by its MessagePack bytes (Newtonsoft loops on Shape) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipProductTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index e06143f38..8fac0c5b0 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -326,7 +326,7 @@ public sealed class ShipProductTests : IDisposable } // The hull as data, to compare everything stats did not name. - private static string HullJson(string path) => Newtonsoft.Json.JsonConvert.SerializeObject(ShipAuthoringStore.Load(path).Hull); + private static byte[] HullBytes(HullData hull) => MessagePack.MessagePackSerializer.Serialize(hull, CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly)); [Fact] public void StatsWritesOnlyTheFieldsItNamesAndARefusalLeavesTheFileUntouched() @@ -369,7 +369,7 @@ public sealed class ShipProductTests : IDisposable Assert.True(code == 0, error); Assert.Contains(flag.Substring(2), output); set(expected); - Assert.Equal(Newtonsoft.Json.JsonConvert.SerializeObject(expected), HullJson(path)); + Assert.Equal(HullBytes(expected), HullBytes(ShipAuthoringStore.Load(path).Hull)); } var written = ShipAuthoringStore.Load(path).Hull; Assert.Equal((60000f, 8000f, 40f, .35f), (written.Mass, written.Durability, written.Armor, written.Drag)); From 7cb6fb0c9d1bc444838de1806e5481166e0318d7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:28:31 +0200 Subject: [PATCH 003/230] Mods: aetheria.nakovalnya, the Zhestokost boss hull package (hull.scad, ship.cc, ship.glb) Built from hull-bench Nakovalnya B by the Starfire job: New Ship --like Djinni, ship-authoring stats (mass 60000, durability 8000, armor 40, drag 0.35, top speed 25), price 60000000, a not-offered Zhestokost product, Build, Rasterise (19x30), 27 layout rows, Package. The validator accepted it and compose excludes nothing. Co-Authored-By: Claude Opus 5.5 --- GameData/Mods/aetheria.nakovalnya/hull.scad | 157 ++++++++++++++++++++ GameData/Mods/aetheria.nakovalnya/ship.cc | 3 + GameData/Mods/aetheria.nakovalnya/ship.glb | 3 + 3 files changed, 163 insertions(+) create mode 100644 GameData/Mods/aetheria.nakovalnya/hull.scad create mode 100644 GameData/Mods/aetheria.nakovalnya/ship.cc create mode 100644 GameData/Mods/aetheria.nakovalnya/ship.glb diff --git a/GameData/Mods/aetheria.nakovalnya/hull.scad b/GameData/Mods/aetheria.nakovalnya/hull.scad new file mode 100644 index 000000000..976078073 --- /dev/null +++ b/GameData/Mods/aetheria.nakovalnya/hull.scad @@ -0,0 +1,157 @@ +// Zhestokost fortress (boss) "Nakovalnya" (anvil). Built from the Zhestokost kit. +// part = lang | brief | anchors | primary | secondary | accent | recess | marking | emit: | rad: +// Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard. +include +part = "primary"; + +HULL_FACTION = "zhestokost"; +HULL_DONOR = ""; +HULL_ROLE = "fortress"; +HULL_LENGTH = [45, 60]; +HULL_TRIANGLES = [6000, 12000]; +HULL_READS = ["three long thick heavy barrels from fixed armoured casemates built into the stepped prow, all pointing straight forward", + "a ring of ten small squat flak turrets on raised rings evenly spaced around the upper edge", + "huge low broad stepped mass, wider at the bow, tapering to a squared stern; narrow cockpit slit high on the prow", + "round feed collar with guide lugs and an oxide ammunition door on each flank, magazine hatches in rows on the dorsal plating; six turning blocks and two broad stern drives"]; +if (part == "brief") echo(brief = [HULL_FACTION, HULL_DONOR, HULL_ROLE, HULL_LENGTH, HULL_TRIANGLES, HULL_READS]); + +// ---- masses: three steps (hull, mid-deck, spine) with the casemates set into the prow's lowest step ----------------- +// M: the hull, broadest at the bow, tapering aft to a squared stern with a cast lip. Its bow face is set back so the +// casemates sit inside the bow line. +M = [zh_s(-16, 32, 5.0, -2.25), zh_s(-15, 34, 5.5, -2.5), zh_s(5, 33, 5.5, -2.5), zh_s(14, 27, 5.5, -2.5), + zh_s(19.6, 23, 5.5, -2.5), zh_s(20, 22.4, 5.2, -2.35)]; +DECK = 3.0; +// T1: the mid-deck, ~60% of the hull's width, 3 m up; the outer band is the flak ring's gallery. +T1 = zh_tier([zh_sec(M, -15), zh_sec(M, 5), zh_sec(M, 11)], 5.4, 3.0, 4.0); +// T2: the spine, 2 m above that, carrying the cab and its slit at the prow's top step. +T2 = zh_tier(T1, 4.0, 2.0, 2.5); +// the spine runs on aft at the same height to the stern shoulders +SPINE = zh_block(12.0, 12, 8, 5.05, DECK - 0.05, 0.8, 0, 0.5); +// the belly steps down too: a keel block under the centre +KEEL = zh_block(0.5, 30, 18, 1.6, -4.0, 0, 0.6, 0.8); +// The prow's lowest step: a glacis apron, and three casemates whose tops are flush with the hull deck. +APRON = zh_block(-18.5, 8, 26, 2.6, -2.7, 0.6, 0.5, 0.4); +CX = [-9.6, 0, 9.6]; +CAS = zh_block(-18.0, 10, 7.2, 5.7, -2.7, 1.3, 0.9, 0.6); +GY = -23.0; // casemate face +GZ = 0.1; // barrel axis height +BL = 11.0; BR = 0.9; // barrel length, radius +// stern shoulders: radiator troughs either side of the spine +RAD_Y = 12.4; RAD_U = 0.55; RAD_W = 3.0; RAD_L = 2.4; +// stern drives +DX = 6.0; DRV = zh_block(20.6, 4, 8, 4.6, -2.3, 0.9, 0.7, 0.4); +DRIVE_Y = 22.6; DZ = -2.3 + 2.3; + +zh_mass(M); +zh_mass(T1); +zh_mass(T2); +zh_mass(APRON); +for (x = CX) translate([x, 0, 0]) zh_mass(CAS); +zh_mass(SPINE); +zh_mass(KEEL); +for (s = [-1, 1]) translate([s*DX, 0, 0]) zh_mass(DRV, "secondary"); + +// ---- the casemate guns: lighter cast-steel barrels out of charcoal mantlets ----------------------------------------- +module casemate_gun() { + zh_z("secondary") { + translate([0, GY - 0.3, GZ]) cbox([4.0, 1.2, 3.4], 0.2); // mantlet + translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR*1.55, 1.8, 8); // barrel collar + translate([0, GY - 0.7 - BL, GZ]) cbox([BR*2.7, 1.6, BR*2.7], 0.12); // muzzle block + } + zh_z("primary") translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR, BL, 8); + zh_z("recess") translate([0, GY - 0.7 - BL - 0.81, GZ]) cube([BR*1.2, 0.05, BR*1.2], center=true); +} +for (x = CX) translate([x, 0, 0]) casemate_gun(); +for (x = CX) translate([x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); +// black reveals between the casemates, on the apron +for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0); + +// ---- the flak ring: ten turrets on raised drums, equal arc-length around the hull's deck rim ---------------------- +// [x, y, outward yaw]; F1 at the bow-port quarter, numbered clockwise seen from above; equal-arc-length stations, baked. +FLAK = [[5.43, -13.40, 21], [-5.43, -13.40, 339], [-13.77, -10.93, 311], [-13.50, -0.06, 274], [-11.53, 10.51, 230], + [-5.43, 17.20, 199], [5.43, 17.20, 161], [11.53, 10.51, 130], [13.50, -0.06, 86], [13.77, -10.93, 49]]; +FR = 2.2; FH = 1.7; // raised drum +FG = [2, 0.27, 3.0, 3.2]; // zh_gun n, cal, len, base +module flak(yaw) { + zh_z("secondary") cylinder(r=FR, h=FH + ZH_SINK, $fn=8); + zh_z("accent") translate([0, 0, FH*0.35]) cylinder(r=FR + 0.08, h=0.35, $fn=8); // oxide band round the drum + zh_z("recess") translate([0, 0, FH - 0.06]) cylinder(r=FR*0.84, h=0.1, $fn=8); + translate([0, 0, FH - 0.02]) rotate([0, 0, yaw]) zh_gun(FG[0], FG[1], FG[2], FG[3], "primary"); +} +for (f = FLAK) translate([f[0], f[1], DECK - ZH_SINK]) flak(f[2]); +function rz(a, p) = [p[0]*cos(a) - p[1]*sin(a), p[0]*sin(a) + p[1]*cos(a), p[2]]; + +// ---- bow: cab with the slit high on the spine's front step --------------------------------------------------------- +zh_on_top(T2, -6.4, 0) zh_cab(6.0, 3.4, 3.0, "primary"); +zh_on_top(T2, -3.6, 0) zh_serial(0.7, 4); + +// ---- dorsal magazine hatches: two rows of four on the mid-deck, clear of the rim ----------------------------------- +for (y = [-8.0, -3.4, 1.2, 5.6], u = [-0.78, 0.78]) zh_on_top(T1, y, u) zh_hatch(2.8, 3.0, dogs=false); +for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); // plate seams + +// Feed collar for an Oboz: a lighter cast-steel flange ring standing proud of the flank round the kit collar. +module feed_collar() { + zh_z("primary") difference() { + cylinder(r=3.3, h=0.9 + ZH_SINK, $fn=8); translate([0, 0, -0.1]) cylinder(r=2.1, h=2, $fn=8); } + zh_collar(2.2, 1.5, 4); +} +// ---- flanks: feed collar, ammunition door, unit serial ------------------------------------------------------------- +for (s = [-1, 1]) { + zh_on_belt(M, 1.5, s, 0.5) rotate([0, 0, 22.5]) feed_collar(); + zh_on_wall(M, 7.0, s, 0.45) zh_hatch(2.6, 3.0); + zh_on_wall(M, -9.0, s, 0.5) zh_serial(0.8, 5); +} + +// ---- six square turning blocks on the belt, a small exhaust slot in each ------------------------------------------- +TB = [2.8, 2.6, 1.8]; // along Y, down the wall, outward +TY = [-11.5, 13.6]; +module turn_block() { + zh_mass(zh_block(0, TB[0], TB[1], TB[2], 0, 0.45, 0.45, 0.35), "secondary"); + zh_z("recess") translate([0, 0, TB[2] + 0.02]) cube([0.6, 1.4, 0.14], center=true); +} +for (y = TY, s = [-1, 1]) zh_on_belt(M, y, s, 0.5) turn_block(); +// the forward pair face ahead from the bow corners, outboard of the casemates +NX = 14.6; +for (s = [-1, 1]) zh_on_nose(M, s*NX, 0.55) turn_block(); +function nb_at(s) = zh_nose_pt(M, s*NX, 0.55) + [0, ZH_SINK - TB[2] - 0.1, 0]; +function tb_at(y, s) = zh_belt(M, y, s, 0.5) + [s*(TB[2] + 0.1 - ZH_SINK), 0, 0]; + +// ---- stern: drives, radiator troughs on the shoulders, stern plate ------------------------------------------------ +module drive_mouth() { + zh_z("secondary") translate([0, DRIVE_Y + 0.12, 0]) difference() { + cbox([7.0, 0.4, 3.6], 0.1); cube([5.4, 1, 2.4], center=true); } + zh_z("recess") translate([0, DRIVE_Y - 0.02, 0]) cube([5.5, 0.3, 2.5], center=true); +} +for (s = [-1, 1]) translate([s*DX, 0, DZ]) drive_mouth(); +for (s = [-1, 1]) zh_on_top(M, RAD_Y, s*RAD_U) zh_rad_trough(RAD_W, RAD_L, 0.45); +zh_on_stern(M, 0, 0.62) zh_plate(4.0, 1.4, 0.3); +zh_on_stern(M, 0, 0.62) translate([0, 0, 0.3]) zh_stripes(3.4, 1.0, 4); + +// ---- anchor meshes ------------------------------------------------------------------------------------------------ +for (s = [-1, 1]) if (part == (s > 0 ? "rad:Ra.L" : "rad:Ra.R")) + zh_on_top(M, RAD_Y, s*RAD_U) for (h = [-1, 1]) zh_rad_vanes(RAD_W, RAD_L, h, 4, 1.0); +for (s = [-1, 1]) if (part == (s > 0 ? "emit:Th.L" : "emit:Th.R")) + translate([s*DX, DRIVE_Y + 0.15, DZ]) cube([5.2, 0.05, 2.3], center=true); +TID = [["Th.T3", TY[0], 1], ["Th.T4", TY[0], -1], ["Th.T5", TY[1], 1], ["Th.T6", TY[1], -1]]; +for (t = TID) if (part == str("emit:", t[0])) translate(tb_at(t[1], t[2])) cube([0.05, 1.2, 0.4], center=true); +for (s = [-1, 1]) if (part == (s > 0 ? "emit:Th.T1" : "emit:Th.T2")) translate(nb_at(s)) cube([1.2, 0.05, 0.4], center=true); + +if (part == "anchors") { + for (i = [0:2]) { + id = str("Gun.H", i + 1); + echo(anchor=[id, "weapon-mount", [CX[i], GY, GZ], 0, ""]); + echo(anchor=[str(id, ".muzzle"), "weapon-muzzle", [CX[i], GY - 0.7 - BL - 0.85, GZ], 0, id]); + } + for (i = [0:9]) { + f = FLAK[i]; id = str("Gun.F", i + 1); base = [f[0], f[1], DECK - ZH_SINK + FH - 0.02]; + echo(anchor=[id, "weapon-mount", base + [0, 0, 0.8], f[2], ""]); + echo(anchor=[str(id, ".muzzle"), "weapon-muzzle", base + rz(f[2], zh_gun_muzzle(FG[0], FG[1], FG[2], FG[3])), f[2], id]); + } + echo(anchor=["Th.T1", "thruster-emitter", nb_at(1), 0, ""]); + echo(anchor=["Th.T2", "thruster-emitter", nb_at(-1), 0, ""]); + for (t = TID) echo(anchor=[t[0], "thruster-emitter", tb_at(t[1], t[2]), t[2] > 0 ? 90 : 270, ""]); + echo(anchor=["Th.L", "thruster-emitter", [DX, DRIVE_Y + 0.15, DZ], 180, ""]); + echo(anchor=["Th.R", "thruster-emitter", [-DX, DRIVE_Y + 0.15, DZ], 180, ""]); + echo(anchor=["Ra.L", "radiator-mesh", zh_top_at(M, RAD_Y, RAD_U, [0, 0, 0.5]), 0, ""]); + echo(anchor=["Ra.R", "radiator-mesh", zh_top_at(M, RAD_Y, -RAD_U, [0, 0, 0.5]), 0, ""]); +} diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc new file mode 100644 index 000000000..ce591805b --- /dev/null +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62f66a2f76c82af1caac2977c22f39e66b331d5fffe2de82beaab68f0a7bc28b +size 12701 diff --git a/GameData/Mods/aetheria.nakovalnya/ship.glb b/GameData/Mods/aetheria.nakovalnya/ship.glb new file mode 100644 index 000000000..ff556fc6b --- /dev/null +++ b/GameData/Mods/aetheria.nakovalnya/ship.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01508b4903138bf8fd13caf3d01fcd3f6b6c410c8007a5266fe95c86d9b773d7 +size 843464 From 95f89a38a6bc3509ca0c5d6cda0ca1de2f5b4f97 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:28:59 +0200 Subject: [PATCH 004/230] NakovalnyaPackageTests: the package composes, its Zhestokost product is never offered or drawn, and its layout is the brief's Co-Authored-By: Claude Opus 5.5 --- .../NakovalnyaPackageTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs new file mode 100644 index 000000000..153892804 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -0,0 +1,96 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// The first first-party ship package, GameData/Mods/aetheria.nakovalnya: the Zhestokost boss hull. It enters the game as +// data through the one compose path (ShipModCatalog.ResolveCatalog) and is made by Zhestokost but never offered +// (rulings off-market-is-unoffered-product, boss-hulls-exclusive). +public sealed class NakovalnyaPackageTests +{ + private const string Id = "aetheria.nakovalnya"; + + private static CultCache Catalog() + { + var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + return cache; + } + + private static HullData Hull(CultCache cache) => cache.Get(new CultRecordRef(ShipModCatalog.HullKey(Id))); + + [Fact] + public void ComposeExcludesNothing() + { + var root = RunStartTests.FindRepoRoot(); + using var work = new TempDirectory(); + var shipped = Path.Combine(root, "GameData", "Aetheria.cc"); + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(shipped, Path.Combine(work.Path, "derived.cc"), + Path.Combine(root, "GameData", "Mods"), TestCatalog.Registry()); + Assert.Empty(excluded); + Assert.NotEqual(shipped, catalog); + + using var cache = Catalog(); + var hull = Hull(cache); + Assert.NotNull(hull); + Assert.Equal(("Nakovalnya", HullType.Ship), (hull.Name, hull.HullType)); + Assert.Equal(ShipModCatalog.AuthoringKey(Id), hull.Visual.Key); + } + + [Fact] + public void BossProductIsNotOffered() + { + using var cache = Catalog(); + var hull = Hull(cache); + var zhestokost = cache.GetAll().Single(faction => faction.Name == "Zhestokost"); + var product = Assert.Single(cache.GetAll(), p => p.Design.Key.Equals(ShipModCatalog.HullKey(Id))); + Assert.Equal(cache.RefOf(zhestokost).Key, product.Manufacturer.Key); + Assert.Equal(ShipModCatalog.ProductKey(Id, cache.RefOf(zhestokost).Key), cache.RefOf(product).Key); + Assert.Equal("Nakovalnya", product.Name); + Assert.True(product.NotOffered); + Assert.True(hull.Price > 0); + + Assert.DoesNotContain(hull, LoadoutGenerator.SoldHulls(cache)); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + foreach (var faction in cache.GetAll().Append(null)) + { + var random = new CultMath.Random(7u); + var generator = new LoadoutGenerator(ref random, items, null, null, faction, .5f); + Assert.False(generator.IsAvailable(product), faction?.Name); + // Even a required draw that falls back to any manufacturer never reaches it. + Assert.Null(generator.RandomHull(HullType.Ship, design => design == hull).design); + } + } + + [Fact] + public void LayoutMatchesTheBrief() + { + using var cache = Catalog(); + var hull = Hull(cache); + var ship = cache.Get(new CultRecordRef(ShipModCatalog.AuthoringKey(Id))); + var rows = hull.Hardpoints.GroupBy(h => (h.Type, h.Shape.Width, h.Shape.Height)).ToDictionary(g => g.Key, g => g.Count()); + Assert.Equal(3, rows.GetValueOrDefault((HardpointType.Ballistic, 2, 2))); + Assert.Equal(10, rows.GetValueOrDefault((HardpointType.Ballistic, 1, 2))); + Assert.Equal(8, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster)); + Assert.Equal(2, hull.Hardpoints.Count(h => h.Type == HardpointType.Radiator)); + foreach (var kind in new[] { HardpointType.Reactor, HardpointType.Shield, HardpointType.ControlModule, HardpointType.Sensors }) + Assert.Equal(1, hull.Hardpoints.Count(h => h.Type == kind)); + Assert.Equal(27, hull.Hardpoints.Count); + + // Every mount row names a model anchor by id (ruling schematic-and-model-are-separate-spaces). Every row has an id + // (ShipAuthoringStore.Validate); an internal row's id names no anchor. + var anchors = ship.Anchors.Select(anchor => anchor.Id).ToHashSet(StringComparer.Ordinal); + var internals = new[] { HardpointType.Reactor, HardpointType.Shield, HardpointType.ControlModule, HardpointType.Sensors }; + var mounts = hull.Hardpoints.Where(h => !internals.Contains(h.Type)).ToArray(); + Assert.Equal(23, mounts.Length); + Assert.All(mounts, h => Assert.Contains(h.Transform, anchors)); + Assert.All(hull.Hardpoints.Where(h => internals.Contains(h.Type)), h => Assert.DoesNotContain(h.Transform, anchors)); + Assert.Equal(Enumerable.Range(1, 3).Select(i => $"Gun.H{i}").Concat(Enumerable.Range(1, 10).Select(i => $"Gun.F{i}")).OrderBy(s => s, StringComparer.Ordinal), + mounts.Where(h => h.Type == HardpointType.Ballistic).Select(h => h.Transform).OrderBy(s => s, StringComparer.Ordinal)); + + Assert.InRange(hull.Shape.Width, 1, 32); + Assert.InRange(hull.Shape.Height, 1, 32); + } +} From 85e539af2b6e49c50cb97641e3eaf723d465a2f2 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:29:47 +0200 Subject: [PATCH 005/230] NakovalnyaPackageTests: import System.Collections.Generic Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs index 153892804..6f55823f0 100644 --- a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; From 9ac8f23307793665802db0f9181e51746fb6eacf Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:09 +0200 Subject: [PATCH 006/230] RunStartTests: the own-maker and factionless reach claims hold for every product LoadoutGenerator.IsOffered offers Ruling census-tests-read-the-owners. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunStartGearTests.cs | 3 ++- tests/Aetheria.Shared.Tests/RunStartTests.cs | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunStartGearTests.cs b/tests/Aetheria.Shared.Tests/RunStartGearTests.cs index 3b5766dcd..2d0e1fb67 100644 --- a/tests/Aetheria.Shared.Tests/RunStartGearTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartGearTests.cs @@ -55,7 +55,8 @@ public sealed partial class RunStartTests var galaxy = MainSectorGalaxy(); AbsentMaker("AbsentToAll"); var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, null, .5f); - var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + // Every offered product (LoadoutGenerator.IsOffered); a not-offered one is available to nobody. + var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet() && LoadoutGenerator.IsOffered(_cache, p)).ToArray(); Assert.NotEmpty(products); Assert.All(products, product => Assert.True(generator.IsAvailable(product), product.Name)); } diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 9d499b3e1..b3c502a6b 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -178,13 +178,14 @@ public sealed partial class RunStartTests : IDisposable } } - // A faction always reaches its own manufacturer's gear: in a main-sector galaxy every product a faction makes is - // on offer to that faction, allegiance or not. And an allegiance map lists only other factions. + // A faction always reaches its own manufacturer's gear: in a main-sector galaxy every offered product a faction makes + // (LoadoutGenerator.IsOffered, the market's one owner) is on offer to that faction, allegiance or not. And an + // allegiance map lists only other factions. [Fact] public void AFactionReachesItsOwnGearAndItsAllegianceNamesOnlyOthers() { var galaxy = MainSectorGalaxy(); - var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet() && LoadoutGenerator.IsOffered(_cache, p)).ToArray(); foreach (var faction in galaxy.Factions) { Assert.DoesNotContain(faction.Allegiance.Keys, key => key.Key.Equals(_cache.RefOf(faction).Key)); From bc53361ff95d81d055f3f6aaa852b4907181a18b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:10 +0200 Subject: [PATCH 007/230] Census tests: sold hulls are LoadoutGenerator.SoldHulls; a hull's asset is its Prefab or its mod-ship Visual record Ruling census-tests-read-the-owners. Co-Authored-By: Claude Opus 5.5 --- .../HardpointOwnershipTests.cs | 4 +--- .../RestoredHullsTests.cs | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 12daaee32..2650b8009 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -261,9 +261,7 @@ public sealed partial class RunStartTests [Fact] public void GeneratedLoadoutsGiveEveryReaderItsHardpoint() { - var sold = _cache.GetAll().Select(p => p.Design.Key).ToHashSet(); - var shipHulls = _cache.GetAll() - .Where(h => h.HullType == HullType.Ship && sold.Contains(_cache.RefOf(h).Key)).Select(h => h.Name).ToList(); + 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())) diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 8b0065f93..6dd235551 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -187,9 +187,21 @@ public sealed class RestoredHullsTests using var cache = OpenCatalog(); var missing = new List(); + // A hull names one body (ShipAuthoringStore.RequireOneBody): a Unity prefab, or a mod ship's visual record, which + // must be in the catalog under its own mod-ship key. foreach (var hull in cache.GetAll()) - if (!assetGuids.Contains(hull.Prefab ?? "")) + { + if (hull.Visual.IsSet()) + { + var visual = cache.Get(hull.Visual); + if (visual == null || !hull.Visual.Key.Equals(ShipModCatalog.AuthoringKey(visual.Id))) + missing.Add($"{hull.Name}.Visual = {hull.Visual.Key.Value}"); + if (!string.IsNullOrWhiteSpace(hull.Prefab)) + missing.Add($"{hull.Name} names both Prefab {hull.Prefab} and Visual {hull.Visual.Key.Value}"); + } + else if (!assetGuids.Contains(hull.Prefab ?? "")) missing.Add($"{hull.Name}.Prefab = {hull.Prefab ?? "null"}"); + } foreach (var design in cache.GetAll()) { foreach (var thruster in design.Behaviors.OfType()) @@ -245,14 +257,13 @@ public sealed class RestoredHullsTests } // The dock intro dereferences the ship's VelocityLimit unguarded (ActionGameManager), so every ship hull the catalog - // sells carries exactly one, with a top speed that is a finite positive number. After the retirement + // sells (LoadoutGenerator.SoldHulls) carries exactly one, with a top speed that is a finite positive number. After the retirement // (ruling retire-longinusx) the shipped ship hulls are the Longinus and the Djinni. [Fact] public void EveryShippedShipHullHasATopSpeed() { using var cache = OpenCatalog(); - var sold = cache.GetAll().Select(product => product.Design.Key).ToHashSet(); - var ships = cache.GetAll().Where(h => h.HullType == HullType.Ship && sold.Contains(cache.RefOf(h).Key)).OrderBy(h => h.Name, StringComparer.Ordinal).ToArray(); + var ships = LoadoutGenerator.SoldHulls(cache).Where(h => h.HullType == HullType.Ship).OrderBy(h => h.Name, StringComparer.Ordinal).ToArray(); Assert.Equal(new[] { "Djinni", "Longinus" }, ships.Select(h => h.Name)); foreach (var hull in ships) { From 07bd57d9e1fd80cf986fd79106694046ecdd6e0d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:12 +0200 Subject: [PATCH 008/230] ConsumablesFirstSetTests: the vent's smallest cloud holds every sold ship hull Ruling vapour-cloud-sold-hulls; the radius is unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs b/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs index 300c2f9b9..a3858a407 100644 --- a/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs @@ -74,9 +74,10 @@ public sealed class ConsumablesFirstSetTests Assert.Equal(new[] { "nozzle", "coolant" }, vent.Roles.Select(r => r.Name).ToArray()); Assert.Equal(2, vent.Behaviors.Count); var dump = Assert.IsType(vent.Behaviors[0]); - // The cloud at its smallest holds every ship hull; its largest is half as big again. + // The cloud at its smallest holds every sold ship hull (ruling vapour-cloud-sold-hulls: a boss hull is never sold and + // its Loadout is agent-authored); its largest is half as big again. var radiusMin = dump.Radius.Min; - foreach (var hull in cache.GetAll().Where(h => h.HullType == HullType.Ship)) + foreach (var hull in LoadoutGenerator.SoldHulls(cache).Where(h => h.HullType == HullType.Ship)) Assert.True(radiusMin >= Circumradius(hull, cellSize), $"{hull.Name} pokes out of the smallest cloud"); Assert.Equal(1.5f * Circumradius(cache.GetByName("Djinni"), cellSize), radiusMin, 3); Assert.Equal(33.03f, radiusMin, 1); // the Djinni, 14x17 cells at two units a cell From f7314b26614141f5cd270cf2ea5ce0b8af79c83b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:12 +0200 Subject: [PATCH 009/230] ShipProductTests: stats --top-speed refuses a hull with two VelocityLimits and leaves it untouched Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipProductTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 8fac0c5b0..bb358c4df 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -334,6 +334,8 @@ public sealed class ShipProductTests : IDisposable Environment.CurrentDirectory = _root.Path; var path = Package("mod.skiff", "Skiffhull", 0); var bare = Path.Combine(ShipFixture.WritePackage(Mods, "mod.bare", "Barehull", tweak: ship => ship.Hull.Behaviors.Clear()), "ship.cc"); + var twin = Path.Combine(ShipFixture.WritePackage(Mods, "mod.twin", "Twinhull", + tweak: ship => ship.Hull.Behaviors.Add(ship.Hull.Behaviors.OfType().First())), "ship.cc"); void RefusedAndUntouched(string file, params string[] flags) { var before = File.ReadAllBytes(file); @@ -353,6 +355,8 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched(path, "--mass", "5", "--durability", "-2"); RefusedAndUntouched(bare, "--top-speed", "50"); RefusedAndUntouched(bare, "--mass", "5", "--top-speed", "50"); + // Two VelocityLimits leave no one top speed to set. + RefusedAndUntouched(twin, "--top-speed", "50"); // Each flag sets its own field and nothing else. var expected = ShipAuthoringStore.Load(path).Hull; From d76563dd5a188ba5cd38ea6a5c9b0f4b2ee5cfe3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:25:22 +0200 Subject: [PATCH 010/230] NakovalnyaPackageTests: every mount takes its gear through Takes; rows inside the shape and disjoint; no footprint-count pin (red) Co-Authored-By: Claude Opus 5.5 --- .../NakovalnyaPackageTests.cs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs index 6f55823f0..d2b08f8ba 100644 --- a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -71,9 +71,7 @@ public sealed class NakovalnyaPackageTests using var cache = Catalog(); var hull = Hull(cache); var ship = cache.Get(new CultRecordRef(ShipModCatalog.AuthoringKey(Id))); - var rows = hull.Hardpoints.GroupBy(h => (h.Type, h.Shape.Width, h.Shape.Height)).ToDictionary(g => g.Key, g => g.Count()); - Assert.Equal(3, rows.GetValueOrDefault((HardpointType.Ballistic, 2, 2))); - Assert.Equal(10, rows.GetValueOrDefault((HardpointType.Ballistic, 1, 2))); + Assert.Equal(13, hull.Hardpoints.Count(h => h.Type == HardpointType.Ballistic)); Assert.Equal(8, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster)); Assert.Equal(2, hull.Hardpoints.Count(h => h.Type == HardpointType.Radiator)); foreach (var kind in new[] { HardpointType.Reactor, HardpointType.Shield, HardpointType.ControlModule, HardpointType.Sensors }) @@ -93,5 +91,33 @@ public sealed class NakovalnyaPackageTests Assert.InRange(hull.Shape.Width, 1, 32); Assert.InRange(hull.Shape.Height, 1, 32); + + // Every row's cells lie inside the hull's shape, and no two rows share a cell. + var owners = new Dictionary<(int, int), string>(); + foreach (var h in hull.Hardpoints) + foreach (var c in h.Shape.Coordinates) + { + var cell = h.Position + c; + Assert.True(hull.Shape[cell], $"{h.Transform} covers ({cell.x},{cell.y}), outside the hull's shape"); + Assert.True(owners.TryAdd((cell.x, cell.y), h.Transform), $"{h.Transform} and {owners.GetValueOrDefault((cell.x, cell.y))} share ({cell.x},{cell.y})"); + } + } + + // Each mount takes the gear it was placed for, under the one fit rule (HardpointData.Takes): ruling nakovalnya-flak-1x2 + // means the Flak Gun fits every flak mount at its facing, and a row's footprint is its gear's shape turned by Rotation. + [Fact] + public void EveryMountTakesItsGear() + { + using var cache = Catalog(); + var hull = Hull(cache); + EquippableItemData Gear(string name) => cache.GetAll().Single(design => design.Name == name); + var gear = Enumerable.Range(1, 10).Select(i => ($"Gun.F{i}", "Flak Gun")) + .Concat(Enumerable.Range(1, 3).Select(i => ($"Gun.H{i}", "Autocannon"))) + .Concat(new[] { ("Th.L", "Large Drive"), ("Th.R", "Large Drive") }) + .Concat(Enumerable.Range(1, 6).Select(i => ($"Th.T{i}", "Medium Drive"))) + .Concat(new[] { ("Ra.L", "Radiator"), ("Ra.R", "Radiator") }); + var refused = gear.Where(row => !hull.Hardpoints.Single(h => h.Transform == row.Item1).Takes(Gear(row.Item2))) + .Select(row => $"{row.Item1} refuses the {row.Item2}").ToArray(); + Assert.Empty(refused); } } From a67711b3d32a3feaaffb9fc66560c91e5e9c62c3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:31:16 +0200 Subject: [PATCH 011/230] aetheria.nakovalnya: side flak rows and radiators are 2x1 in the grid frame (drive.py rerun through the add-on) Co-Authored-By: Claude Opus 5.5 --- GameData/Mods/aetheria.nakovalnya/ship.cc | 2 +- GameData/Mods/aetheria.nakovalnya/ship.glb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc index ce591805b..2de9f3458 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.cc +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62f66a2f76c82af1caac2977c22f39e66b331d5fffe2de82beaab68f0a7bc28b +oid sha256:2390998c0f36f647fbd7b12098a726445a747fc2dfd57c0e68b9fcc7e9116338 size 12701 diff --git a/GameData/Mods/aetheria.nakovalnya/ship.glb b/GameData/Mods/aetheria.nakovalnya/ship.glb index ff556fc6b..27ed4683b 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.glb +++ b/GameData/Mods/aetheria.nakovalnya/ship.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:01508b4903138bf8fd13caf3d01fcd3f6b6c410c8007a5266fe95c86d9b773d7 -size 843464 +oid sha256:c0f2e8bdb820330a1ba2b49c9615484b971f96634e7108ed675e788c38c3c8ba +size 843456 From 9b4b46e2430da9ff3580927464f042c73a5f4b66 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:39:02 +0200 Subject: [PATCH 012/230] Zone: a side is a faction or one unaffiliated ship; each unaffiliated ship flies a flight of its own (brain-one-per-side-r2) Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 13 ++++++++----- tests/Aetheria.Shared.Tests/FlightTests.cs | 10 +++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index f2276f4e4..fe3136464 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -69,10 +69,9 @@ public class Zone private ItemManager _itemManager; private double _time; public List Agents = new List(); - // One flight per faction, created on its first piloted join; unaffiliated ships share Unaffiliated. A flight owns - // what its members target and do (Agents/Flight.cs). - private static readonly Faction Unaffiliated = new Faction(); - private readonly Dictionary _flights = new Dictionary(); + // One flight per side, created on its first piloted join. A side is a faction, or one unaffiliated ship + // (brain-one-per-side-r2). A flight owns what its members target and do (Agents/Flight.cs). + private readonly Dictionary _flights = new Dictionary(); public IEnumerable Flights => _flights.Values; // Cut 2 (docs/mining-cut.md): chunk wear, keyed by chunk. Zone.Wear is the only writer; ChunkExists and @@ -177,6 +176,8 @@ public class Zone if (agent == null) return; Agents.Remove(agent); foreach (var flight in _flights.Values) flight.Leave(agent); + // A side of one is gone with its ship. + if (remove.Value.Faction == null) _flights.Remove(remove.Value); }); Targets.Add(new EntityTargets()); @@ -310,9 +311,11 @@ public class Zone return true; } + private static object SideOf(Ship ship) => (object) ship.Faction ?? ship; + private Agent CreateAgent(Ship ship, AgentTask stated) { - var key = ship.Faction ?? Unaffiliated; + var key = SideOf(ship); if (!_flights.TryGetValue(key, out var flight)) _flights[key] = flight = new Flight(ship.Faction?.Doctrine, _itemManager.GameplaySettings); var id = NextAgentId(); diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 7bab33174..9f397ebdf 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -359,14 +359,18 @@ public sealed class FlightTests : IDisposable } [Fact] - public void EachFactionFliesAFlightOfItsOwnAndUnaffiliatedShipsShareOne() + public void EachFactionFliesAFlightOfItsOwnAndEachUnaffiliatedShipFliesAlone() { Pilot(new Faction { Name = "One" }); Pilot(new Faction { Name = "Two" }); Pilot(null); - Pilot(null); + var alone = Pilot(null); - Assert.Equal(new[] { 1, 1, 2 }, _zone.Flights.Select(f => f.Count).OrderBy(c => c)); + Assert.Equal(new[] { 1, 1, 1, 1 }, _zone.Flights.Select(f => f.Count).OrderBy(c => c)); + + _zone.Remove(alone); + + Assert.Equal(3, _zone.Flights.Count()); } [Fact] From 6252c29efae1eef458a09ad0774d40632ce14852 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:42:12 +0200 Subject: [PATCH 013/230] Determinism ledger: repin TwoAiDigestIsPinned for the side split (the fighters were one side and followed themselves) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 1a1a64b1e..e3ab326f0 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -24,7 +24,8 @@ public sealed partial class RunStartTests // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them // (ruling replays-per-host-class), and arenas run on Linux. private const string TwoAiPinnedHostClass = "linux-x64"; - private const string TwoAiPinnedDigest = "8ba2116ed0ecd574703846f493c408936d536e3b7c9e4652556103ec1b75098c"; + // Repinned once, for the side split: the two fighters are unaffiliated, were one side and each followed itself. + private const string TwoAiPinnedDigest = "1b2ffe51f799e762199717a3bc003e880a80106023463aabc40bd2e4f746697c"; private const int TwoAiSteps = 3600; [Fact] From 2ad52e7e9eae08adf1fffd12e78623a395a1e3f7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:45:02 +0200 Subject: [PATCH 014/230] PilotView: brains reach other ships only through their side's view, by opaque handles One view per side over its members' sensors and books. Flight, Combat, Follow and Minion read contacts as ContactId and fire-control readouts as view calls; FollowTask.Anchor is a handle. Own-ship reads stay direct. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 4 + Assets/Scripts/ServerShared/Agents/Flight.cs | 99 +++++----- Assets/Scripts/ServerShared/Agents/Minion.cs | 4 +- .../Scripts/ServerShared/Agents/PilotView.cs | 173 ++++++++++++++++++ .../ServerShared/Agents/PilotView.cs.meta | 11 ++ .../ServerShared/Agents/States/Combat.cs | 40 ++-- .../ServerShared/Agents/States/Follow.cs | 6 +- .../ServerShared/Agents/Tasks/FollowTask.cs | 2 +- Assets/Scripts/ServerShared/ScenarioStage.cs | 2 +- .../ConsumableAiTests.cs | 3 +- tests/Aetheria.Shared.Tests/FlightTests.cs | 36 ++-- 11 files changed, 293 insertions(+), 87 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Agents/PilotView.cs create mode 100644 Assets/Scripts/ServerShared/Agents/PilotView.cs.meta diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index eb74818ba..586438c97 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -23,6 +23,9 @@ public class Agent private VelocityLimit _velocityLimit; public AgentTask Task { get; set; } + // What this pilot's side knows of other ships. A ship outside a flight is a side of one: a view over itself, until + // Flight.Join sets the flight's. + public PilotView View { get; set; } // Zone-scoped, from 1: the ordinal of this pilot's own dice, set by Zone.CreateAgent as it admits the pilot (0 for // an agent built outside a zone's admission). public uint Id { get; set; } @@ -36,6 +39,7 @@ public class Agent public Agent(Ship ship, RoleDoctrine doctrine) { Ship = ship; + View = new PilotView(ship); Doctrine = doctrine; ItemManager = Ship.ItemManager; Settings = Ship.ItemManager.GameplaySettings; diff --git a/Assets/Scripts/ServerShared/Agents/Flight.cs b/Assets/Scripts/ServerShared/Agents/Flight.cs index 497ac865f..098598e92 100644 --- a/Assets/Scripts/ServerShared/Agents/Flight.cs +++ b/Assets/Scripts/ServerShared/Agents/Flight.cs @@ -3,15 +3,17 @@ using System.Collections.Generic; using System.Linq; using CultMath; using static CultMath.math; +using ContactId = PilotView.ContactId; -// One flight per (zone, faction) of piloted ships; unaffiliated ships form one flight of their own. The flight alone +// One flight per (zone, side) of piloted ships; a side is a faction, or one unaffiliated ship. The flight alone // decides what its members do: which hostile each engages (Ship.Target) and what each does otherwise (Agent.Task). // The agent graph executes those two and decides nothing. The faction's doctrine (FactionDoctrine) holds the numbers. // -// The picture is the union of the members' VisibleEnemies. A track passes through phases the flight owns, runtime only: -// None, Hailing (grace running, one hail spoken), Complied (slow enough at the grace's end: followed, not engaged) and -// Engaged. A member still fires only at what its own VisibleEntities hold (FireControl), so the shared picture never -// lets it fire at what it cannot see. +// The flight reads other ships only through its side's PilotView (ruling observation-sensors-only): it holds a contact +// as a handle and never as the ship. The picture is the view's: the union of what the members' sensors hold hostile, never a member. +// A track passes through phases the flight owns, runtime only: None, Hailing (grace running, one hail spoken), Complied +// (slow enough at the grace's end: followed, not engaged) and Engaged. A member still fires only at what its own +// sensors hold (fire control), so the shared picture never lets it fire at what it cannot see. public class Flight { public enum Phase { None, Hailing, Complied, Engaged } @@ -31,7 +33,7 @@ public class Flight private readonly FactionDoctrine _doctrine; private readonly GameplaySettings _settings; private readonly List _members = new List(); - private readonly Dictionary _tracks = new Dictionary(); + private readonly Dictionary _tracks = new Dictionary(); private double _time; // No doctrine fights as FactionDoctrine.Default; a doctrine that leaves a role unauthored gets that role's default. @@ -44,58 +46,64 @@ public class Flight } public RoleDoctrine Combatant { get; } + // What this side knows of other ships; every member's Agent.View. + public PilotView View { get; } = new PilotView(); public int Count => _members.Count; public bool Contains(Agent agent) => _members.Any(m => m.Agent == agent); - public Phase PhaseOf(Entity track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None; + public Phase PhaseOf(ContactId track) => _tracks.TryGetValue(track, out var t) ? t.Phase : Phase.None; // The task a member holds when the flight has nothing to follow: its patrol circuit, or the task a scenario stated. public void Join(Agent agent, AgentTask baseline) { _members.Add(new Member { Agent = agent, Baseline = baseline }); + View.Add(agent.Ship); + agent.View = View; agent.Task = baseline; } - public void Leave(Agent agent) => _members.RemoveAll(m => m.Agent == agent); + public void Leave(Agent agent) + { + if (_members.RemoveAll(m => m.Agent == agent) > 0) View.Remove(agent.Ship); + } public void Update(float dt) { _time += dt; - var picture = _members.SelectMany(m => m.Agent.Ship.VisibleEnemies).Distinct().ToList(); + var picture = View.Picture(); foreach (var gone in _tracks.Keys.Where(t => !picture.Contains(t)).ToList()) _tracks.Remove(gone); - foreach (var entity in picture) + foreach (var contact in picture) { - if (!_tracks.TryGetValue(entity, out var track)) _tracks[entity] = track = new Track(); - Advance(entity, track); + if (!_tracks.TryGetValue(contact, out var track)) _tracks[contact] = track = new Track(); + Advance(contact, track); } foreach (var member in _members) Order(member, picture); } // A grudge is a hostile IFF override that any member holds on the track. - private bool Grudged(Entity entity) => _members.Any(m => m.Agent.Ship.IffOverride(entity) == true); + private bool Grudged(ContactId contact) => _members.Any(m => View.IffOverride(m.Agent.Ship, contact) == true); - private bool Engageable(Entity entity, bool grudge) + private bool Engageable(ContactId contact, bool grudge) { switch (_doctrine.EngageOn) { case EngageOn.Never: return false; case EngageOn.Detection: return true; case EngageOn.Identified: - return grudge || _members.Any(m => m.Agent.Ship.EntityInfoGathered.TryGetValue(entity, out var info) && - info >= _settings.TargetGearInfoThreshold); + return grudge || _members.Any(m => View.Info(m.Agent.Ship, contact) >= _settings.TargetGearInfoThreshold); case EngageOn.Provoked: return grudge; - case EngageOn.Trespass: return grudge || _members.Any(m => m.Agent.Ship.BelievesTrespassing(entity)); + case EngageOn.Trespass: return grudge || _members.Any(m => View.BelievesTrespassing(m.Agent.Ship, contact)); default: return false; } } - private void Advance(Entity entity, Track track) + private void Advance(ContactId contact, Track track) { if (track.Phase == Phase.Engaged) return; - var grudge = Grudged(entity); - if (!Engageable(entity, grudge)) + var grudge = Grudged(contact); + if (!Engageable(contact, grudge)) { track.Phase = Phase.None; return; @@ -115,56 +123,59 @@ public class Flight { track.Phase = Phase.Hailing; track.Start = _time; - if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(entity)?.Agent.Ship.Command.Hail(_doctrine.Hail); + if (!string.IsNullOrEmpty(_doctrine.Hail)) Nearest(contact)?.Agent.Ship.Command.Hail(_doctrine.Hail); } else track.Phase = Phase.Engaged; break; case Phase.Hailing: if (_time >= track.Start + _doctrine.Grace) - track.Phase = _doctrine.ComplySpeed > 0 && length(entity.Velocity) < _doctrine.ComplySpeed + track.Phase = _doctrine.ComplySpeed > 0 && length(View.SideTrack(contact).Value.Velocity) < _doctrine.ComplySpeed ? Phase.Complied : Phase.Engaged; break; case Phase.Complied: - if (length(entity.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged; + if (length(View.SideTrack(contact).Value.Velocity) >= _doctrine.ComplySpeed) track.Phase = Phase.Engaged; break; } } - private Member Nearest(Entity entity) => - _members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - entity.Position.xz)).FirstOrDefault(); + private Member Nearest(ContactId contact) + { + var at = View.SideTrack(contact).Value.Position.xz; + return _members.OrderBy(m => lengthsq(m.Agent.Ship.Position.xz - at)).FirstOrDefault(); + } - private void Order(Member member, List picture) + private void Order(Member member, List picture) { var ship = member.Agent.Ship; - var seen = ship.VisibleEnemies; // Engage: keep the current target while it stays engaged, else the nearest engaged track this member can see. - var engaged = picture.Where(e => _tracks[e].Phase == Phase.Engaged && seen.Contains(e)).ToList(); + var engaged = picture.Where(c => _tracks[c].Phase == Phase.Engaged && View.Sees(ship, c)).ToList(); if (engaged.Count > 0) { - var current = ship.Target.Value.Entity; - var pick = current != null && engaged.Contains(current) - ? current - : engaged.OrderBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); - Aim(ship, pick); + var current = View.AppliedTargetOf(ship); + var pick = current != null && engaged.Contains(current.Value) + ? current.Value + : engaged.OrderBy(c => lengthsq(ship.Position.xz - View.SideTrack(c).Value.Position.xz)).First(); + View.Designate(ship, pick); return; } // No engaged track in this member's own sensors: it steers on the flight's picture, a bearing and never targeting // data (ruling shared-track-bearing-only). That is the hailed and the complied, and an engaged track only a flightmate sees. - Aim(ship, null); + View.Designate(ship, null); // Overrides are per member: a flightmate's hostile track is not one this member follows after it has made it friendly. - var held = picture.Where(e => _tracks[e].Phase != Phase.None && ship.IffOverride(e) != false).ToList(); + var held = picture.Where(c => _tracks[c].Phase != Phase.None && View.IffOverride(ship, c) != false).ToList(); if (held.Count > 0) { // An engaged track outranks a hailed one; the held anchor is kept only while nothing outranks it. - var anyEngaged = held.Any(e => _tracks[e].Phase == Phase.Engaged); - var anchor = member.Agent.Task is FollowTask follow && held.Contains(follow.Anchor) - && (!anyEngaged || _tracks[follow.Anchor].Phase == Phase.Engaged) - ? follow.Anchor - : held.OrderBy(e => _tracks[e].Phase == Phase.Engaged ? 0 : 1) - .ThenBy(e => lengthsq(ship.Position.xz - e.Position.xz)).First(); + var anyEngaged = held.Any(c => _tracks[c].Phase == Phase.Engaged); + var heldAnchor = (member.Agent.Task as FollowTask)?.Anchor; + var anchor = heldAnchor != null && held.Contains(heldAnchor.Value) + && (!anyEngaged || _tracks[heldAnchor.Value].Phase == Phase.Engaged) + ? heldAnchor.Value + : held.OrderBy(c => _tracks[c].Phase == Phase.Engaged ? 0 : 1) + .ThenBy(c => lengthsq(ship.Position.xz - View.SideTrack(c).Value.Position.xz)).First(); if (!(member.Agent.Task is FollowTask current) || current.Anchor != anchor) member.Agent.Task = new FollowTask { Anchor = anchor, Standoff = Standoff(ship) }; } @@ -172,12 +183,6 @@ public class Flight member.Agent.Task = member.Baseline; } - private static void Aim(Ship ship, Entity target) - { - var wanted = target == null ? TargetRef.None : new TargetRef(target); - if (!ship.Target.Value.Equals(wanted)) ship.Command.Designate(wanted); - } - private float Standoff(Ship ship) { if (Combatant.HoldStandoff > 0) return Combatant.HoldStandoff; diff --git a/Assets/Scripts/ServerShared/Agents/Minion.cs b/Assets/Scripts/ServerShared/Agents/Minion.cs index a1277b264..c95b7cfbb 100644 --- a/Assets/Scripts/ServerShared/Agents/Minion.cs +++ b/Assets/Scripts/ServerShared/Agents/Minion.cs @@ -22,9 +22,9 @@ public class Minion : Agent var combatState = new CombatState(this); _rootState.AddTransition(combatState, - () => (Ship.Command.Designated ?? Ship.Target.Value).Entity != null, null, true, _rootState); + () => View.TargetOf(Ship) != null, null, true, _rootState); combatState.AddTransition(_rootState, - () => (Ship.Command.Designated ?? Ship.Target.Value).Entity == null, null, true, _rootState); + () => View.TargetOf(Ship) == null, null, true, _rootState); } } diff --git a/Assets/Scripts/ServerShared/Agents/PilotView.cs b/Assets/Scripts/ServerShared/Agents/PilotView.cs new file mode 100644 index 000000000..8c9a71e65 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/PilotView.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using CultMath; + +// What one side's brain may know of other ships. A side is a faction, or one unaffiliated ship (brain-one-per-side-r2); +// its view reads only that side's members: their sensors (VisibleEntities, VisibleEnemies, EntityInfoGathered), their +// IFF overrides and reveal books (ruling observation-sensors-only). A brain never holds another ship: it holds a +// ContactId or a RevealId, which only the view that issued it can resolve, so there is no path from a handle to the +// ship's truth that does not pass the sensors. +// +// A contact's Track (position, velocity, facing) exists only while a member tracks it (its VisibleEntities holds it): +// within a zone step nothing moves during the agent phase, so the track equals what the Entity would answer. The +// fire-control readouts call FireControl's own functions with the resolved subject, so the HUD, the player and the +// brains price one model (A-one-sim). Own-ship reads stay the brain's own. +public sealed class PilotView +{ + private readonly List _members = new List(); + private static Func _openContact; + private static Func _openReveal; + + public PilotView(params Ship[] members) => _members.AddRange(members); + + internal void Add(Ship member) => _members.Add(member); + internal void Remove(Ship member) => _members.Remove(member); + + // A contact of the view, equal to itself across absences from a member's picture: equality is by reference. + public readonly struct ContactId : IEquatable + { + private readonly Entity _subject; + + static ContactId() => _openContact = id => id._subject; + internal ContactId(Entity subject) => _subject = subject; + + public bool Equals(ContactId other) => ReferenceEquals(_subject, other._subject); + public override bool Equals(object obj) => obj is ContactId other && Equals(other); + public override int GetHashCode() => RuntimeHelpers.GetHashCode(_subject); + public static bool operator ==(ContactId a, ContactId b) => a.Equals(b); + public static bool operator !=(ContactId a, ContactId b) => !a.Equals(b); + } + + // A revealed item of a contact, to aim at; the live item stays behind the handle (ruling contact-reads-book-aim-reads-live). + public readonly struct RevealId : IEquatable + { + private readonly EquippedItem _item; + + static RevealId() => _openReveal = id => id._item; + internal RevealId(EquippedItem item) => _item = item; + + public bool Equals(RevealId other) => ReferenceEquals(_item, other._item); + public override bool Equals(object obj) => obj is RevealId other && Equals(other); + public override int GetHashCode() => RuntimeHelpers.GetHashCode(_item); + public static bool operator ==(RevealId a, RevealId b) => a.Equals(b); + public static bool operator !=(RevealId a, RevealId b) => !a.Equals(b); + } + + public readonly struct Track + { + public readonly float3 Position; + public readonly float2 Velocity; + public readonly float2 Direction; + + public Track(float3 position, float2 velocity, float2 direction) + { + Position = position; + Velocity = velocity; + Direction = direction; + } + } + + // For fixtures: the handle of a subject, as the view would issue it. + internal static ContactId Handle(Entity subject) => new ContactId(subject); + + private static Entity Subject(ContactId id) + { + if (_openContact == null) RuntimeHelpers.RunClassConstructor(typeof(ContactId).TypeHandle); + return _openContact(id); + } + + private static EquippedItem Item(RevealId id) + { + if (_openReveal == null) RuntimeHelpers.RunClassConstructor(typeof(RevealId).TypeHandle); + return _openReveal(id); + } + + // The side's contacts: its members' VisibleEnemies in member order, each once, never a member of the side. + public List Picture() + { + var picture = new List(); + var seen = new HashSet(); + foreach (var member in _members) + foreach (var enemy in member.VisibleEnemies) + if (!_members.Any(m => ReferenceEquals(m, enemy)) && seen.Add(enemy)) picture.Add(new ContactId(enemy)); + return picture; + } + + public bool Sees(Ship member, ContactId contact) => member.VisibleEnemies.Contains(Subject(contact)); + + // The member's own track of the contact, or null while it does not track it. + public Track? TrackOf(Ship member, ContactId contact) + { + var subject = Subject(contact); + if (subject == null || !member.VisibleEntities.Contains(subject)) return null; + return new Track(subject.Position, subject.Velocity, subject.Direction); + } + + // The track of the first member, in order, that tracks the contact (question side-shared-kinematics). + public Track? SideTrack(ContactId contact) + { + foreach (var member in _members) + { + var track = TrackOf(member, contact); + if (track != null) return track; + } + + return null; + } + + public bool? IffOverride(Ship member, ContactId contact) => member.IffOverride(Subject(contact)); + + public float? Info(Ship member, ContactId contact) => + member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null; + + public bool BelievesTrespassing(Ship member, ContactId contact) => member.BelievesTrespassing(Subject(contact)); + + // The member's pending designation when set, else last step's applied target. + public ContactId? TargetOf(Ship member) + { + var subject = (member.Command.Designated ?? member.Target.Value).Entity; + return subject == null ? (ContactId?) null : new ContactId(subject); + } + + public ContactId? AppliedTargetOf(Ship member) + { + var subject = member.Target.Value.Entity; + return subject == null ? (ContactId?) null : new ContactId(subject); + } + + // The weapons the member believes the contact has, in the contact's equipment order; Aimable is FireControl.IsRevealed. + public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact) => + member.RevealedWeapons(Subject(contact)) + .Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList(); + + public bool Designated(Ship member, Weapon weapon, ContactId contact) => + FireControl.Designated(weapon, member, Subject(contact), out _); + + public bool Refuses(Ship member, Weapon weapon, ContactId contact) => + FireControl.Refuses(weapon, member, Subject(contact)); + + public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim) => + FireControl.AgentFires(weapon, member, Subject(contact), minHitProbability, + aim == null ? AimTarget.None : AimTarget.OfItem(Item(aim.Value))); + + // Where to point to hit the contact with the weapon: the shared prediction, at the height the contact rides. + public float3 Intercept(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + var predicted = FireControl.PredictedIntercept(weapon, member, subject); + predicted.y = subject.RideHeight(predicted.xz); + return predicted; + } + + // Designate the contact, or nothing for null, when that is not already the member's applied target. + public void Designate(Ship member, ContactId? contact) + { + var wanted = contact == null ? TargetRef.None : new TargetRef(Subject(contact.Value)); + if (!member.Target.Value.Equals(wanted)) member.Command.Designate(wanted); + } + + public void SelectItem(Ship member, RevealId? reveal) => + member.Command.SelectItem(reveal == null ? null : Item(reveal.Value)); +} diff --git a/Assets/Scripts/ServerShared/Agents/PilotView.cs.meta b/Assets/Scripts/ServerShared/Agents/PilotView.cs.meta new file mode 100644 index 000000000..8c9b644f3 --- /dev/null +++ b/Assets/Scripts/ServerShared/Agents/PilotView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5b0f6c2e9d3a4f71a8c4e1d7b2a63f90 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 1a062e7be..a424437b3 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -43,36 +43,39 @@ public class CombatState : BaseState _optimumStale = false; } - // The pilot's own pending designation (its Flight's, this step) when set, else last step's applied target. - var target = (_agent.Ship.Command.Designated ?? _agent.Ship.Target.Value).Entity; + // The pilot's own pending designation (its Flight's, this step) when set, else last step's applied target; and + // only a contact a member tracks has a track to fly on, so an untracked target is no target here. + var view = _agent.View; + var target = view.TargetOf(_agent.Ship); if (target == null) return; + var track = view.TrackOf(_agent.Ship, target.Value); + if (track == null) return; _availableGroups.Clear(); _availableLockingWeapons.Clear(); - var toTarget = target.Position - _agent.Ship.Position; + var toTarget = track.Value.Position - _agent.Ship.Position; var targetDistance = length(toTarget.xz); // Cut 2 (docs/fire-control-cut.md): aim at whichever revealed weapon on the target hits hardest at // this range, or clear the aim point when nothing qualifies -- the same predicate - // (FireControl.IsRevealed) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it + // (the view's Aimable) and the same writer (TrySelectTargetItem) the player path uses. Which weapons it // believes the target has, and how hard each hits, come from its book's claims, not the target's live gear // (aetheria-ew, classify-from-belief; ew-claim-weapon-measure-ruled: the claimed design's rated figures); the // reveal's Item is the aim and stays the truth. - EquippedItem bestTargetItem = null; + PilotView.RevealId? bestTargetItem = null; var bestTargetDps = float.MinValue; - foreach (var reveal in _agent.Ship.RevealedWeapons(target)) + foreach (var reveal in view.WeaponReveals(_agent.Ship, target.Value)) { - if (!FireControl.IsRevealed(_agent.Ship, reveal.Item)) continue; + if (!reveal.Aimable) continue; var dps = reveal.Claimed.Behaviors.OfType().First().RatedRangeDamagePerSecond(targetDistance); if (dps > bestTargetDps) { bestTargetDps = dps; - bestTargetItem = reveal.Item; + bestTargetItem = reveal.Id; } } - _agent.Ship.Command.SelectItem(bestTargetItem); - var aim = bestTargetItem == null ? AimTarget.None : AimTarget.OfItem(bestTargetItem); + view.SelectItem(_agent.Ship, bestTargetItem); for (var i = 0; i < _agent.Ship.WeaponGroups.Length; i++) { @@ -82,8 +85,8 @@ public class CombatState : BaseState // The range test is FireControl's (Designated), and a weapon Solve refuses adds nothing to a group's // damage. if (weapon.Item.Online.Value && - FireControl.Designated(weapon, _agent.Ship, target, out _) && - !FireControl.Refuses(weapon, _agent.Ship, target) && + view.Designated(_agent.Ship, weapon, target.Value) && + !view.Refuses(_agent.Ship, weapon, target.Value) && (weapon is ConstantWeapon || weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) { @@ -111,11 +114,11 @@ public class CombatState : BaseState } } - var targetRight = target.Direction.Rotate(ItemRotation.Clockwise); + var targetRight = track.Value.Direction.Rotate(ItemRotation.Clockwise); var optimumRangeDelta = abs(_optimumRange - targetDistance); var directionToTarget = normalize(toTarget.xz); var targetPortAlignment = dot(targetRight, directionToTarget); - var targetForeAlignment = dot(-target.Direction, directionToTarget); + var targetForeAlignment = dot(-track.Value.Direction, directionToTarget); var forwardness = saturate(optimumRangeDelta / _agent.Settings.AgentMaxForwardDistance) * _agent.Settings.AgentForwardLerp; if (targetForeAlignment > 0) forwardness = lerp(forwardness, 1, pow(targetPortAlignment, 2)); @@ -128,20 +131,19 @@ public class CombatState : BaseState var testWeapon = _agent.Ship.WeaponGroups[selectedGroup].weapons.First(); if(testWeapon.Velocity > 1) { - // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with FireControl.Fire's + // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with the shot's // own snapshot and the roll's deviation measurement -- two separate predictions here and in the // roll would let an AI aim at one point and get judged for missing a different one. - var predictedPosition = FireControl.PredictedIntercept(testWeapon, _agent.Ship, target); - predictedPosition.y = target.RideHeight(predictedPosition.xz); + var predictedPosition = view.Intercept(_agent.Ship, testWeapon, target.Value); toTarget = normalize(predictedPosition - _agent.Ship.Position); } // Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a - // verdict. FireControl.AgentFires is the AI's "worth it" heuristic for a weapon that hits what it + // verdict. The view's Fires is the AI's "worth it" heuristic for a weapon that hits what it // fires at, and "the target is designated" for a fused one. foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { - if (FireControl.AgentFires(weapon, _agent.Ship, target, _agent.Doctrine.MinHitProbability, aim)) + if (view.Fires(_agent.Ship, weapon, target.Value, _agent.Doctrine.MinHitProbability, bestTargetItem)) _agent.Ship.Command.Pull(weapon); // A lock pulled above this step counts as firing, as it did when it was activated in place. else if (weapon.Firing || _agent.Ship.Command.Pulls(weapon)) diff --git a/Assets/Scripts/ServerShared/Agents/States/Follow.cs b/Assets/Scripts/ServerShared/Agents/States/Follow.cs index 1bbf3f826..e1701bc6c 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Follow.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Follow.cs @@ -11,14 +11,16 @@ public class FollowState : BaseState { var task = _agent.Task as FollowTask; if (task?.Anchor == null) return; + var anchor = _agent.View.SideTrack(task.Anchor.Value); + if (anchor == null) return; - var toAnchor = task.Anchor.Position.xz - _agent.Ship.Position.xz; + var toAnchor = anchor.Value.Position.xz - _agent.Ship.Position.xz; var distance = length(toAnchor); var direction = distance > 0 ? toAnchor / distance : _agent.Ship.Direction; // Positive error: too far, close. Negative: too near, open. var error = distance - task.Standoff; var pull = direction * (float) sign(error) * saturate(abs(error) / max(task.Standoff, 1f)); - var desired = task.Anchor.Velocity + pull * _agent.TopSpeed; + var desired = anchor.Value.Velocity + pull * _agent.TopSpeed; _agent.Ship.Command.Aim = float3(direction.x, 0, direction.y); _agent.Accelerate(desired); diff --git a/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs index 4e09d0a35..946539c6f 100644 --- a/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs +++ b/Assets/Scripts/ServerShared/Agents/Tasks/FollowTask.cs @@ -5,7 +5,7 @@ using MessagePack; // Runtime only, like every agent task. public class FollowTask : AgentTask { - [IgnoreMember] public Entity Anchor; + [IgnoreMember] public PilotView.ContactId? Anchor; [IgnoreMember] public float Standoff; [IgnoreMember] public override TaskType Type => TaskType.Follow; } diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index c9531e60f..f0df352e9 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -145,7 +145,7 @@ public sealed class ScenarioStage var placement = _placed.FirstOrDefault(p => ReferenceEquals(p.Entity, entity)); if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; } if (target == null) { _failures.Add("approach: no target to close on"); return; } - placement.Task = new FollowTask { Anchor = target, Standoff = 0f }; + placement.Task = new FollowTask { Anchor = PilotView.Handle(target), Standoff = 0f }; } // Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage diff --git a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs index ea1ee917e..f0a570b1f 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableAiTests.cs @@ -586,7 +586,8 @@ public sealed class ConsumableAiTests : IDisposable s.Zone.Update(.05f); s.Zone.Update(.05f); Assert.True(s.Agent.Target.Value.IsNone); - agent.Task = new FollowTask { Anchor = s.Prey, Standoff = 100 }; + if (!s.Agent.VisibleEntities.Contains(s.Prey)) s.Agent.VisibleEntities.Add(s.Prey); + agent.Task = new FollowTask { Anchor = PilotView.Handle(s.Prey), Standoff = 100 }; for (var i = 0; i < 6 && !(s.State is FollowState); i++) { agent.Update(.05f); diff --git a/tests/Aetheria.Shared.Tests/FlightTests.cs b/tests/Aetheria.Shared.Tests/FlightTests.cs index 9f397ebdf..1cb2f9c48 100644 --- a/tests/Aetheria.Shared.Tests/FlightTests.cs +++ b/tests/Aetheria.Shared.Tests/FlightTests.cs @@ -48,9 +48,17 @@ public sealed class FlightTests : IDisposable return track; } + // A follower holds the anchor as a handle and flies on it only while its side tracks it. + private static FollowTask Follows(Ship follower, Entity anchor, float standoff) + { + if (!follower.VisibleEntities.Contains(anchor)) follower.VisibleEntities.Add(anchor); + return new FollowTask { Anchor = PilotView.Handle(anchor), Standoff = standoff }; + } + private static void See(Ship member, Entity track) { member.EntityInfoGathered[track] = 1; + if (!member.VisibleEntities.Contains(track)) member.VisibleEntities.Add(track); member.VisibleEnemies.Add(track); } @@ -254,7 +262,7 @@ public sealed class FlightTests : IDisposable { anchor.Position = ship.Position + anchorAt; var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + { Task = Follows(ship, anchor, 100) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -279,7 +287,7 @@ public sealed class FlightTests : IDisposable for (var i = 0; i < 4; i++) minion.Update(.1f); Assert.IsType(CurrentState(minion)); - minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; + minion.Task = Follows(ship, anchor, 50); minion.Update(.1f); PilotApplier.Apply(minion.Ship); @@ -544,12 +552,12 @@ public sealed class FlightTests : IDisposable See(member, far); Tick(.1f); - Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor); near.Position = new float3(-900, 0, 0); far.Position = new float3(990, 0, 0); Tick(.1f); - Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor); } // The held anchor leaves the picture: the member re-anchors on what is still held. Kills: the anchor rewritten only @@ -563,12 +571,12 @@ public sealed class FlightTests : IDisposable See(member, near); See(member, far); Tick(.1f); - Assert.Same(near, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(near), ((FollowTask) _zone.Agents.Single().Task).Anchor); member.VisibleEnemies.Remove(near); Tick(.1f); - Assert.Same(far, ((FollowTask) _zone.Agents.Single().Task).Anchor); + Assert.Equal(PilotView.Handle(far), ((FollowTask) _zone.Agents.Single().Task).Anchor); } [Fact] @@ -586,7 +594,7 @@ public sealed class FlightTests : IDisposable member.VisibleEnemies.Remove(track); Tick(.1f); Assert.Same(patrol, agent.Task); - Assert.Equal(Flight.Phase.None, _zone.Flights.Single().PhaseOf(track)); + Assert.Equal(Flight.Phase.None, _zone.Flights.Single().PhaseOf(PilotView.Handle(track))); member.Messages.Clear(); See(member, track); @@ -617,7 +625,7 @@ public sealed class FlightTests : IDisposable var ship = Pilot(null, -200, 50); anchor.Position = ship.Position + new float3(anchorX, 0, 0); var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + { Task = Follows(ship, anchor, 100) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -639,7 +647,7 @@ public sealed class FlightTests : IDisposable var ship = Pilot(null); var anchor = Track(); var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 50 } }; + { Task = Follows(ship, anchor, 50) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -651,7 +659,7 @@ public sealed class FlightTests : IDisposable PilotApplier.Apply(minion.Ship); Assert.IsType(CurrentState(minion)); - minion.Task = new FollowTask { Anchor = anchor, Standoff = 50 }; + minion.Task = Follows(ship, anchor, 50); minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); @@ -700,7 +708,7 @@ public sealed class FlightTests : IDisposable Assert.All(sighted, m => Assert.Same(track, m.Target.Value.Entity)); Assert.True(blind.Target.Value.IsNone); var task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); - Assert.Same(track, task.Anchor); + Assert.Equal(PilotView.Handle(track), task.Anchor); } // The flight's engaged track outranks a hailed one a blind member does see, though that one is nearer. Both sit @@ -718,14 +726,14 @@ public sealed class FlightTests : IDisposable See(blind, hailing); Tick(.1f); var task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); - Assert.Same(hailing, task.Anchor); // nothing outranks it yet: the nearest held + Assert.Equal(PilotView.Handle(hailing), task.Anchor); // nothing outranks it yet: the nearest held sighted.SetIff(engaged, true); Tick(.1f); Assert.Same(engaged, sighted.Target.Value.Entity); task = Assert.IsType(_zone.Agents.Single(a => a.Ship == blind).Task); - Assert.Same(engaged, task.Anchor); + Assert.Equal(PilotView.Handle(engaged), task.Anchor); } // Hail then engage is the production path of every graced doctrine: the member goes from FollowState into combat, @@ -887,7 +895,7 @@ public sealed class FlightTests : IDisposable anchor.Position = ship.Position + new float3(0, 0, distance); anchor.Velocity = anchorVelocity; var minion = new Minion(ship, FactionDoctrine.Default(_items.GameplaySettings).Combatant) - { Task = new FollowTask { Anchor = anchor, Standoff = 100 } }; + { Task = Follows(ship, anchor, 100) }; minion.Update(.1f); PilotApplier.Apply(minion.Ship); minion.Update(.1f); From bd343b2a4e0ead4929389fed748fa5a7c01ca847 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:53:20 +0200 Subject: [PATCH 015/230] Kit frames: mates are lp_at placements that track the Ship Root frame; zone OFFs unchanged Co-Authored-By: Claude Sonnet 5.5 --- .../aetheria_ships/factions/lowpoly.scad | 25 +++++++++++++++++++ .../aetheria_ships/factions/zhestokost.scad | 15 +++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/tools/blender/aetheria_ships/factions/lowpoly.scad b/tools/blender/aetheria_ships/factions/lowpoly.scad index 9979483a0..ef3063c6c 100644 --- a/tools/blender/aetheria_ships/factions/lowpoly.scad +++ b/tools/blender/aetheria_ships/factions/lowpoly.scad @@ -12,3 +12,28 @@ module cbox(s, c=0.2, center=true) { module tube(r, l, fn=8) { rotate([-90,0,0]) cylinder(r=r, h=l, $fn=fn); } // a straight strut between two points module strut(a, b, r=0.08, fn=6) { hull() { translate(a) sphere(r, $fn=fn); translate(b) sphere(r, $fn=fn); } } + +// ==== Frames and schematic lines ===================================================================================== +// A kit part that draws schematic lines is placed with lp_at, never with raw translate/rotate/mirror: lp_at moves the +// geometry (multmatrix) and tracks the same frame in $lp_m, so lp_line, called anywhere beneath it, emits its points in +// the Ship Root frame. Placement has this one owner. A line is echoed (part "lines") only when the frame's local +Z +// points up, so fittings on the belly, the walls and the belts, which a top view cannot see, emit nothing. +$lp_m = [[1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]]; +function lp_T(t) = [[1,0,0,t[0]], [0,1,0,t[1]], [0,0,1,t[2]], [0,0,0,1]]; +function lp_R(r) = // Rz * Ry * Rx, as rotate() composes + [[cos(r[2]), -sin(r[2]), 0, 0], [sin(r[2]), cos(r[2]), 0, 0], [0,0,1,0], [0,0,0,1]] + * [[cos(r[1]), 0, sin(r[1]), 0], [0,1,0,0], [-sin(r[1]), 0, cos(r[1]), 0], [0,0,0,1]] + * [[1,0,0,0], [0, cos(r[0]), -sin(r[0]), 0], [0, sin(r[0]), cos(r[0]), 0], [0,0,0,1]]; +lp_MX = [[-1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]]; // mirror across x = 0 +function lp_ap(m, p) = let(v = m * [p[0], p[1], p[2], 1]) [v[0], v[1], v[2]]; +// Mirror (when mx), then translate by t, then rotate by r (as nested mirror, translate, rotate would). +module lp_at(t=[0,0,0], r=[0,0,0], mx=false) { + M = (mx ? lp_MX : [[1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]]) * lp_T(t) * lp_R(r); + $lp_m = $lp_m * M; + multmatrix(M) children(); +} +// One schematic line: layer (outline, step, seam or fitting), points in the local frame, cyclic when it closes. +module lp_line(layer, pts, cyclic=false) { + if (!is_undef(part) && part == "lines" && ($lp_m * [0, 0, 1, 0])[2] > 0.5) + echo(line = [layer, cyclic, [for (p = pts) lp_ap($lp_m, p)]]); +} diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index e077351ee..ae38b0186 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -22,6 +22,7 @@ // zh_on_stern, zh_on_nose. In the mate frame local +Z is the outward normal and the part is sunk ZH_SINK into // the skin, so nothing floats. Top/belly frames keep local +Y aft; wall/belt frames keep +Y aft with local +X // pointing down the wall on either side (starboard mates are mirrors of port); stern/nose frames have local +Y down. +// Mates are lp_at frames: a part under a mate that draws lines (lp_line) emits them in the Ship Root frame. // 3. Zone routing: every zh_ module draws only the zone the global `part` names, so a hull writes its assembly once. // A hull on a Zhestokost donor (HULL_DONOR == "zhestokost") names a donor zone 'zhestokost:'. // Anchor meshes (zh_rad_vanes, zh_emitter) are not routed: call them inside the hull's rad:/emit: parts under @@ -65,17 +66,17 @@ function zh_wall_tilt(secs, y) = let(s = zh_sec(secs, y)) atan2(s[4], s[2]*(1-ZH function zh_stern_pt(secs, x=0, v=0.5) = let(s = secs[len(secs)-1]) [x, s[0], s[3] + s[2]*v]; function zh_nose_pt(secs, x=0, v=0.5) = let(s = secs[0]) [x, s[0], s[3] + s[2]*v]; -module zh_on_top(secs, y, u=0, sink=ZH_SINK) translate(zh_top(secs, y, u) - [0, 0, sink]) children(); -module zh_on_belly(secs, y, u=0, sink=ZH_SINK) translate(zh_belly(secs, y, u) + [0, 0, sink]) rotate([0, 180, 0]) children(); -module _zh_port(side) { if (side < 0) mirror([1, 0, 0]) children(); else children(); } +module zh_on_top(secs, y, u=0, sink=ZH_SINK) lp_at(t=zh_top(secs, y, u) - [0, 0, sink]) children(); +module zh_on_belly(secs, y, u=0, sink=ZH_SINK) lp_at(t=zh_belly(secs, y, u) + [0, 0, sink], r=[0, 180, 0]) children(); module zh_on_belt(secs, y, side=1, v=0.5, sink=ZH_SINK) - _zh_port(side) translate(zh_belt(secs, y, 1, v) - [sink, 0, 0]) rotate([0, 90, 0]) children(); + lp_at(t=zh_belt(secs, y, 1, v) - [sink, 0, 0], r=[0, 90, 0], mx=side < 0) children(); module zh_on_wall(secs, y, side=1, v=0.5, sink=ZH_SINK) { t = zh_wall_tilt(secs, y); - _zh_port(side) translate(zh_wall(secs, y, 1, v) - [cos(t), 0, sin(t)]*sink) rotate([0, 90 - t, 0]) children(); + lp_at(t=zh_wall(secs, y, 1, v) - [cos(t), 0, sin(t)]*sink, r=[0, 90 - t, 0], mx=side < 0) children(); } -module zh_on_stern(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_stern_pt(secs, x, v) - [0, sink, 0]) rotate([-90, 0, 0]) children(); -module zh_on_nose(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_nose_pt(secs, x, v) + [0, sink, 0]) rotate([90, 0, 0]) rotate([0, 0, 180]) children(); +module zh_on_stern(secs, x=0, v=0.5, sink=ZH_SINK) lp_at(t=zh_stern_pt(secs, x, v) - [0, sink, 0], r=[-90, 0, 0]) children(); +module zh_on_nose(secs, x=0, v=0.5, sink=ZH_SINK) + lp_at(t=zh_nose_pt(secs, x, v) + [0, sink, 0], r=[90, 0, 0]) lp_at(r=[0, 0, 180]) children(); // world point of a local offset under a mate (for anchors): top, belly, stern frames function zh_top_at(secs, y, u, o, sink=ZH_SINK) = zh_top(secs, y, u) - [0, 0, sink] + o; function zh_belly_at(secs, y, u, o, sink=ZH_SINK) = zh_belly(secs, y, u) + [0, 0, sink] + [-o[0], o[1], -o[2]]; From 28ed24b28ed0648e79bb8802934da80e65eba729 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:54:27 +0200 Subject: [PATCH 016/230] Zhestokost kit and Nakovalnya emit schematic lines from their own frames (outline, step, seam, fitting) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Mods/aetheria.nakovalnya/hull.scad | 26 ++++++---- .../aetheria_ships/factions/zhestokost.scad | 47 +++++++++++++++---- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/hull.scad b/GameData/Mods/aetheria.nakovalnya/hull.scad index 976078073..062285157 100644 --- a/GameData/Mods/aetheria.nakovalnya/hull.scad +++ b/GameData/Mods/aetheria.nakovalnya/hull.scad @@ -1,5 +1,5 @@ // Zhestokost fortress (boss) "Nakovalnya" (anvil). Built from the Zhestokost kit. -// part = lang | brief | anchors | primary | secondary | accent | recess | marking | emit: | rad: +// part = lang | brief | anchors | lines | primary | secondary | accent | recess | marking | emit: | rad: // Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard. include part = "primary"; @@ -46,10 +46,10 @@ zh_mass(M); zh_mass(T1); zh_mass(T2); zh_mass(APRON); -for (x = CX) translate([x, 0, 0]) zh_mass(CAS); +for (x = CX) lp_at(t=[x, 0, 0]) zh_mass(CAS); zh_mass(SPINE); -zh_mass(KEEL); -for (s = [-1, 1]) translate([s*DX, 0, 0]) zh_mass(DRV, "secondary"); +zh_mass(KEEL, lines=false); // under the hull: no top view sees it +for (s = [-1, 1]) lp_at(t=[s*DX, 0, 0]) zh_mass(DRV, "secondary"); // ---- the casemate guns: lighter cast-steel barrels out of charcoal mantlets ----------------------------------------- module casemate_gun() { @@ -60,9 +60,11 @@ module casemate_gun() { } zh_z("primary") translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR, BL, 8); zh_z("recess") translate([0, GY - 0.7 - BL - 0.81, GZ]) cube([BR*1.2, 0.05, BR*1.2], center=true); + lp_line("fitting", [for (p = zh_rect(4.0, 1.2, GZ + 1.7)) p + [0, GY - 0.3, 0]], true); + lp_line("fitting", [[0, GY - 0.7, GZ + BR], [0, GY - 0.7 - BL, GZ + BR]]); } -for (x = CX) translate([x, 0, 0]) casemate_gun(); -for (x = CX) translate([x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); +for (x = CX) lp_at(t=[x, 0, 0]) casemate_gun(); +for (x = CX) lp_at(t=[x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); // black reveals between the casemates, on the apron for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0); @@ -76,9 +78,10 @@ module flak(yaw) { zh_z("secondary") cylinder(r=FR, h=FH + ZH_SINK, $fn=8); zh_z("accent") translate([0, 0, FH*0.35]) cylinder(r=FR + 0.08, h=0.35, $fn=8); // oxide band round the drum zh_z("recess") translate([0, 0, FH - 0.06]) cylinder(r=FR*0.84, h=0.1, $fn=8); - translate([0, 0, FH - 0.02]) rotate([0, 0, yaw]) zh_gun(FG[0], FG[1], FG[2], FG[3], "primary"); + lp_line("fitting", zh_oct(FR, FH), true); + lp_at(t=[0, 0, FH - 0.02], r=[0, 0, yaw]) zh_gun(FG[0], FG[1], FG[2], FG[3], "primary"); } -for (f = FLAK) translate([f[0], f[1], DECK - ZH_SINK]) flak(f[2]); +for (f = FLAK) lp_at(t=[f[0], f[1], DECK - ZH_SINK]) flak(f[2]); function rz(a, p) = [p[0]*cos(a) - p[1]*sin(a), p[0]*sin(a) + p[1]*cos(a), p[2]]; // ---- bow: cab with the slit high on the spine's front step --------------------------------------------------------- @@ -87,7 +90,10 @@ zh_on_top(T2, -3.6, 0) zh_serial(0.7, 4); // ---- dorsal magazine hatches: two rows of four on the mid-deck, clear of the rim ----------------------------------- for (y = [-8.0, -3.4, 1.2, 5.6], u = [-0.78, 0.78]) zh_on_top(T1, y, u) zh_hatch(2.8, 3.0, dogs=false); -for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); // plate seams +for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) { // plate seams + zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); + lp_line("seam", [[-4.2, 0, 0.16], [4.2, 0, 0.16]]); +} // Feed collar for an Oboz: a lighter cast-steel flange ring standing proud of the flank round the kit collar. module feed_collar() { @@ -97,7 +103,7 @@ module feed_collar() { } // ---- flanks: feed collar, ammunition door, unit serial ------------------------------------------------------------- for (s = [-1, 1]) { - zh_on_belt(M, 1.5, s, 0.5) rotate([0, 0, 22.5]) feed_collar(); + zh_on_belt(M, 1.5, s, 0.5) lp_at(r=[0, 0, 22.5]) feed_collar(); zh_on_wall(M, 7.0, s, 0.45) zh_hatch(2.6, 3.0); zh_on_wall(M, -9.0, s, 0.5) zh_serial(0.8, 5); } diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index ae38b0186..01829d368 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -84,7 +84,22 @@ function zh_stern_at(secs, x, v, o, sink=ZH_SINK) = zh_stern_pt(secs, x, v) - [0 // ==== PRIMARY: masses that make the silhouette ======================================================================== // Any section list as one faceted poured mass. -module zh_mass(secs, zone="primary") zh_z(zone) for (i = [0:len(secs)-2]) hull() { _zh_slice(secs[i]); _zh_slice(secs[i+1]); } +// Lines: the plan at the cast belt (outline), the deck edge (step) and, on a primary mass, a seam across the deck at each +// interior station. lines=false for a mass no top view can see (a keel under the hull). +module zh_mass(secs, zone="primary", lines=true) { + zh_z(zone) for (i = [0:len(secs)-2]) hull() { _zh_slice(secs[i]); _zh_slice(secs[i+1]); } + if (lines) { + n = len(secs); + lp_line("outline", concat([for (s = secs) [-s[1]/2, s[0], s[3] + s[2]*ZH_WU]], + [for (i = [n-1:-1:0]) [secs[i][1]/2, secs[i][0], secs[i][3] + secs[i][2]*ZH_WU]]), true); + lp_line("step", concat([for (s = secs) [-(s[1]/2 - s[4]), s[0], s[3] + s[2]]], + [for (i = [n-1:-1:0]) [secs[i][1]/2 - secs[i][4], secs[i][0], secs[i][3] + secs[i][2]]]), true); + if (zone == "primary") for (i = [1:n-2]) let(s = secs[i]) + lp_line("seam", [[-(s[1]/2 - s[4]), s[0], s[3] + s[2]], [s[1]/2 - s[4], s[0], s[3] + s[2]]]); + } +} +function zh_rect(w, l, z) = [[-w/2, -l/2, z], [w/2, -l/2, z], [w/2, l/2, z], [-w/2, l/2, z]]; +function zh_oct(r, z) = [for (k = [0:7]) [r*cos(45*k), r*sin(45*k), z]]; // A core or whole hull: bow chamfered in plan and dropped (bow, as a fraction of L), stern a flat face with a cast lip. // bowW / bowH: nose width and height as fractions of W and H. @@ -128,6 +143,7 @@ module zh_collar(r=ZH_COLLAR_R, proud=0.55, lugs=4) { zh_z("secondary") difference() { cylinder(r=r, h=proud + ZH_SINK, $fn=8); translate([0, 0, -0.1]) cylinder(r=r*0.6, h=proud + 1, $fn=8); } zh_z("recess") cylinder(r=r*0.62, h=0.08 + ZH_SINK, $fn=8); + lp_line("fitting", zh_oct(r, proud), true); zh_z("accent") for (i = [0:lugs-1]) rotate(i*360/lugs) translate([r + 0.16, 0, (proud + 0.25)/2]) cube([0.5, 0.6, proud + 0.25 + ZH_SINK], center=true); } @@ -140,13 +156,17 @@ module zh_hatch(w=ZH_HATCH, l=ZH_HATCH, t=0.22, dogs=true) { for (x = [-1, 1]) translate([x*w*0.27, 0, t + 0.12]) cbox([0.5, l + 0.6, 0.34], 0.07); if (dogs) for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 + 0.12), sy*l*0.3, t*0.6]) cube([0.36, 0.44, 0.4], center=true); } + lp_line("fitting", zh_rect(w, l, t), true); translate([0, l/2 - 0.42, t]) zh_stripes(w*0.36, 0.5, dogs ? 3 : 2); } // Armour plate (any frame): replaceable slab with four bolt bosses; leaves a seam to its neighbour. -module zh_plate(w, l, t=0.3, zone="secondary") zh_z(zone) { - translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.07); - for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 - 0.25), sy*(l/2 - 0.25), t + 0.05]) cube([0.22, 0.22, 0.12], center=true); +module zh_plate(w, l, t=0.3, zone="secondary") { + zh_z(zone) { + translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.07); + for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 - 0.25), sy*(l/2 - 0.25), t + 0.05]) cube([0.22, 0.22, 0.12], center=true); + } + lp_line("fitting", zh_rect(w, l, t), true); } // Radiator trough (top frame): cast frame round a black floor, split by a spine wall; vanes stand in it (zh_rad_vanes). @@ -155,6 +175,8 @@ module zh_rad_trough(w=3.2, l=2.4, d=0.45, zone="secondary") { cbox([w + 0.7, l + 0.7, d + ZH_SINK], 0.1); cube([w, l, d*3], center=true); } zh_z(zone) translate([0, 0, d*0.6]) cube([0.3, l, d*1.2 + ZH_SINK], center=true); zh_z("recess") translate([0, 0, 0.04]) cube([w, l, 0.08 + ZH_SINK], center=true); + lp_line("fitting", zh_rect(w + 0.7, l + 0.7, d), true); + lp_line("fitting", zh_rect(w, l, d), true); } // radiator-mesh for one half of a trough (side 1 port, -1 starboard): transverse vanes rising out of it module zh_rad_vanes(w=3.2, l=2.4, side=1, n=5, h=1.0) { @@ -173,17 +195,24 @@ module zh_tow_lug(s=ZH_LUG, zone="secondary") { // Ballistic mount (top frame): octagonal ring, battered mantlet block, n thick barrels with box brakes, pointing -Y. module zh_gun(n=2, cal=0.22, len=3.0, base=1.8, zone="secondary") { zh_z(zone) cylinder(r=base*0.56, h=0.35 + ZH_SINK, $fn=8); - zh_mass([for (s = zh_block(0, base*0.95, base, base*0.5, 0.33, base*0.14, 0, 0.22)) s], zone); - zh_z(zone) for (i = [0:n-1]) translate([(i - (n-1)/2)*cal*3.2, -base*0.4, 0.33 + base*0.25]) { - rotate([0, 0, 180]) tube(cal, len, 8); - translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04); + lp_line("fitting", zh_oct(base*0.56, 0.35), true); + zh_mass([for (s = zh_block(0, base*0.95, base, base*0.5, 0.33, base*0.14, 0, 0.22)) s], zone, lines=false); + for (i = [0:n-1]) lp_at(t=[(i - (n-1)/2)*cal*3.2, -base*0.4, 0.33 + base*0.25]) { + zh_z(zone) { + rotate([0, 0, 180]) tube(cal, len, 8); + translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04); + } + lp_line("fitting", [[0, 0, 0], [0, -len, 0]]); } } function zh_gun_muzzle(n=2, cal=0.22, len=3.0, base=1.8) = [0, -base*0.4 - len - cal*1.75, 0.33 + base*0.25]; // ==== TERTIARY: detail =============================================================================================== // Shadow-gap floor (top frame): the black bottom of a reveal between modules. -module zh_reveal(w, l) zh_z("recess") translate([0, 0, 0.03]) cube([w, l, 0.06 + ZH_SINK], center=true); +module zh_reveal(w, l) { + zh_z("recess") translate([0, 0, 0.03]) cube([w, l, 0.06 + ZH_SINK], center=true); + lp_line("seam", zh_rect(w, l, 0.06), true); +} // Cream safety chevrons (any frame, +Z out): n diagonal bars clipped to w x l. module zh_stripes(w, l, n=3) zh_z("marking") intersection() { translate([0, 0, 0.04]) cube([w, l, 0.1], center=true); From 63e71a1f30f4325fc233ff52917bda98acb635af Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:45:04 +0200 Subject: [PATCH 017/230] PilotView tests: sides, tracks, handles, designation, fire-control readouts Co-Authored-By: Claude Sonnet 5.5 --- .../FireControlPerWeaponTests.cs | 44 +++ tests/Aetheria.Shared.Tests/PilotViewTests.cs | 266 ++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/PilotViewTests.cs diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index bd576b16b..2c70cf438 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -400,6 +400,50 @@ public sealed class FireControlPerWeaponTests : IDisposable AssertAimsAtTarget(r); } + // A brain's fire-control readouts are FireControl's own answers on the resolved subject (A-one-sim), and its reveal + // list is the book's weapons in fitted order with Aimable = IsRevealed. Kills: a readout with its own arithmetic, a + // dropped aim, an intercept left at the planar height, a reveal list in book order. + [Fact] + public void ViewReadoutsAreFireControls() + { + var r = Build(velocity: 500f, targetLayerHeight: 40f); + var targetGun = r.Target.Equipment.Single(i => i.Behaviors.OfType().Any()); + r.Shooter.RecordReveal(r.Target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data); + var view = new PilotView(r.Shooter); + var contact = PilotView.Handle(r.Target); + var weapon = r.Gun.Weapon; + var minHit = r.Shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + + Assert.True(FireControl.Designated(weapon, r.Shooter, r.Target, out _)); + Assert.True(view.Designated(r.Shooter, weapon, contact)); + Assert.Equal(FireControl.Refuses(weapon, r.Shooter, r.Target), view.Refuses(r.Shooter, weapon, contact)); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, minHit, AimTarget.None), + view.Fires(r.Shooter, weapon, contact, minHit, null)); + + var reveal = Assert.Single(view.WeaponReveals(r.Shooter, contact)); + Assert.Same(targetGun.Data, reveal.Claimed); + Assert.Equal(FireControl.IsRevealed(r.Shooter, targetGun), reveal.Aimable); + Assert.True(reveal.Aimable); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, minHit, AimTarget.OfItem(targetGun)), + view.Fires(r.Shooter, weapon, contact, minHit, reveal.Id)); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, 0f, AimTarget.OfItem(targetGun)), + view.Fires(r.Shooter, weapon, contact, 0f, reveal.Id)); + Assert.Equal(FireControl.AgentFires(weapon, r.Shooter, r.Target, 2f, AimTarget.OfItem(targetGun)), + view.Fires(r.Shooter, weapon, contact, 2f, reveal.Id)); + + view.SelectItem(r.Shooter, reveal.Id); + view.SelectItem(r.Shooter, null); + PilotApplier.Apply(r.Shooter); + + var expected = FireControl.PredictedIntercept(weapon, r.Shooter, r.Target); + r.Target.Position = new float3(r.Target.Position.x, 0, r.Target.Position.z); + expected = FireControl.PredictedIntercept(weapon, r.Shooter, r.Target); + var planarY = expected.y; + expected.y = r.Target.RideHeight(expected.xz); + Assert.NotEqual(planarY, expected.y); + Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact)); + } + private static void AssertAimsAtTarget(Rig r) { var toTarget = r.Target.Position - r.Shooter.Position; diff --git a/tests/Aetheria.Shared.Tests/PilotViewTests.cs b/tests/Aetheria.Shared.Tests/PilotViewTests.cs new file mode 100644 index 000000000..5c371de49 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PilotViewTests.cs @@ -0,0 +1,266 @@ +using System; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// A side's brain reaches other ships only through its PilotView (Agents/PilotView.cs): handles it issued, tracks only +// of what a member tracks, picture only of what a member's sensors hold hostile. Driven on the IffAndCombatTests world, +// like FlightTests; perception is set directly, a grudge is a real IFF override. +public sealed class PilotViewTests : IDisposable +{ + private readonly IffAndCombatTests _world = new IffAndCombatTests(); + private readonly ItemManager _items; + private readonly Zone _zone; + + public PilotViewTests() => (_items, _zone) = _world.BuildWorld(); + + public void Dispose() => _world.Dispose(); + + private Ship Pilot(Faction faction, float x = 0, float z = 0) + { + var ship = _world.NewShip(_items, _zone, faction, piloted: true); + ship.Position = new float3(x, 0, z); + return ship; + } + + private Ship Subject(float x = 100, float z = 0) + { + var ship = _world.NewShip(_items, _zone, new Faction { Name = "Other" }); + ship.Position = new float3(x, 0, z); + return ship; + } + + private static void See(Ship member, Entity subject) + { + member.EntityInfoGathered[subject] = 1; + if (!member.VisibleEntities.Contains(subject)) member.VisibleEntities.Add(subject); + member.VisibleEnemies.Add(subject); + } + + private PilotView ViewOf(Ship ship) => _zone.Agents.Single(a => a.Ship == ship).View; + + private void Tick(float dt) + { + foreach (var flight in _zone.Flights.ToArray()) flight.Update(dt); + foreach (var entity in _zone.Entities.ToArray()) PilotApplier.Apply(entity); + } + + [Fact] + public void ASideNeverListsItsOwnMember() + { + var a = Pilot(null, -200); + var b = Pilot(null, 200); + a.SetIff(b, true); + b.SetIff(a, true); + See(a, b); + See(b, a); + + Assert.Equal(2, _zone.Flights.Count()); + Assert.Equal(new[] { PilotView.Handle(b) }, ViewOf(a).Picture()); + Assert.Equal(new[] { PilotView.Handle(a) }, ViewOf(b).Picture()); + for (var step = 0; step < 600; step++) + { + Tick(.1f); + foreach (var member in new[] { a, b }) + Assert.NotEqual(PilotView.Handle(member), (_zone.Agents.Single(x => x.Ship == member).Task as FollowTask)?.Anchor); + } + } + + [Fact] + public void AFactionsPictureLeavesOutItsOwnMembers() + { + var faction = new Faction { Name = "Squabblers" }; + var a = Pilot(faction, -200); + var b = Pilot(faction, 200); + var other = Subject(); + See(a, b); + See(b, a); + See(b, other); + + Assert.Equal(new[] { PilotView.Handle(other) }, ViewOf(a).Picture()); + } + + [Fact] + public void APictureIsTheMembersEnemiesInMemberOrderEachOnce() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(100); + var y = Subject(200); + var z = Subject(300); + See(a, x); + See(a, y); + See(b, y); + See(b, z); + + Assert.Equal(new[] { x, y, z }.Select(PilotView.Handle), ViewOf(a).Picture()); + } + + [Fact] + public void ATrackIsOnlyWhatTheMemberTracks() + { + var member = Pilot(null); + var subject = Subject(120, 30); + subject.Velocity = new float2(3, -4); + subject.Direction = new float2(0, -1); + var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; + var view = ViewOf(member); + var handle = PilotView.Handle(subject); + + member.EntityInfoGathered[subject] = threshold / 2; + Assert.Null(view.TrackOf(member, handle)); + Assert.Equal(threshold / 2, view.Info(member, handle)); + + member.EntityInfoGathered[subject] = threshold * 2; + var track = view.TrackOf(member, handle); + Assert.NotNull(track); + Assert.Equal(subject.Position, track.Value.Position); + Assert.Equal(subject.Velocity, track.Value.Velocity); + Assert.Equal(subject.Direction, track.Value.Direction); + Assert.Equal(threshold * 2, view.Info(member, handle)); + } + + [Fact] + public void ASideTrackComesFromATrackingMember() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(100); + var y = Subject(200); + See(b, x); + var view = ViewOf(a); + + Assert.Null(view.TrackOf(a, PilotView.Handle(x))); + Assert.Equal(x.Position, view.SideTrack(PilotView.Handle(x)).Value.Position); + Assert.Null(view.SideTrack(PilotView.Handle(y))); + } + + [Fact] + public void AViewAnswersIffOverridesAndTrespassPerMember() + { + var member = Pilot(null); + var subject = Subject(); + var view = ViewOf(member); + var handle = PilotView.Handle(subject); + + Assert.Null(view.IffOverride(member, handle)); + member.SetIff(subject, true); + Assert.True(view.IffOverride(member, handle)); + member.SetIff(subject, false); + Assert.False(view.IffOverride(member, handle)); + Assert.Equal(member.BelievesTrespassing(subject), view.BelievesTrespassing(member, handle)); + } + + [Fact] + public void SeeingIsTheMembersOwnEnemyList() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(); + See(b, x); + var view = ViewOf(a); + + Assert.True(view.Sees(b, PilotView.Handle(x))); + Assert.False(view.Sees(a, PilotView.Handle(x))); + } + + [Fact] + public void ATargetIsThePendingDesignationElseTheAppliedTarget() + { + var member = Pilot(null); + var x = Subject(100); + var y = Subject(200); + See(member, x); + See(member, y); + var view = ViewOf(member); + + Assert.Null(view.TargetOf(member)); + Assert.Null(view.AppliedTargetOf(member)); + + view.Designate(member, PilotView.Handle(x)); + Assert.Equal(PilotView.Handle(x), view.TargetOf(member)); + Assert.Null(view.AppliedTargetOf(member)); + + PilotApplier.Apply(member); + Assert.Equal(PilotView.Handle(x), view.AppliedTargetOf(member)); + Assert.Equal(PilotView.Handle(x), view.TargetOf(member)); + + view.Designate(member, PilotView.Handle(y)); + Assert.Equal(PilotView.Handle(y), view.TargetOf(member)); + Assert.Equal(PilotView.Handle(x), view.AppliedTargetOf(member)); + } + + [Fact] + public void DesignatingTheAppliedTargetAgainWritesNothing() + { + var member = Pilot(null); + var x = Subject(100); + See(member, x); + var view = ViewOf(member); + view.Designate(member, PilotView.Handle(x)); + PilotApplier.Apply(member); + + view.Designate(member, PilotView.Handle(x)); + Assert.Null(member.Command.Designated); + + view.Designate(member, null); + Assert.True(member.Command.Designated.Value.IsNone); + PilotApplier.Apply(member); + view.Designate(member, null); + Assert.Null(member.Command.Designated); + } + + [Fact] + public void AHandleIsTheContactAcrossItsAbsence() + { + var member = Pilot(null); + var x = Subject(100); + var y = Subject(200); + var view = ViewOf(member); + See(member, x); + var before = view.Picture().Single(); + + member.VisibleEnemies.Remove(x); + member.VisibleEntities.Remove(x); + Assert.Empty(view.Picture()); + See(member, x); + + Assert.Equal(before, view.Picture().Single()); + Assert.Equal(PilotView.Handle(x), before); + Assert.True(before == PilotView.Handle(x)); + Assert.Equal(PilotView.Handle(x).GetHashCode(), before.GetHashCode()); + Assert.NotEqual(PilotView.Handle(y), before); + Assert.True(PilotView.Handle(y) != before); + } + + [Fact] + public void ACombatingPilotFliesOnlyOnATrackedTarget() + { + var member = Pilot(null); + var subject = Subject(500, 300); + member.SetTarget(subject); + member.Position = new float3(500, 0, 0); + var agent = new Minion(member, FactionDoctrine.Default(_items.GameplaySettings).Combatant) + { Task = new PatrolOrbitsTask { Circuit = new CultRecordKey[1] } }; + + for (var i = 0; i < 4; i++) + { + agent.Update(.1f); + PilotApplier.Apply(member); + } + Assert.True((member.Command.Aim?.z ?? 0) < 100, "an untracked target is not flown on"); + + See(member, subject); + member.SetTarget(subject); + for (var i = 0; i < 4; i++) + { + agent.Update(.1f); + PilotApplier.Apply(member); + } + Assert.Equal(300f, member.Command.Aim.Value.z, 1); + } +} From b982fb9cd082c2abd451e964fcf12e872cb0876d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:57:44 +0200 Subject: [PATCH 018/230] hull_source: kit lines echo, SVG silhouette projector, SchematicLine, three line checks Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 168 ++++++++++++++-- tools/blender/tests/test_hull_source.py | 200 +++++++++++++++++++- 2 files changed, 347 insertions(+), 21 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index fb2aa246a..ca929560f 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -11,11 +11,16 @@ with it (a hull that redefines or shadows an included language fails a check): (every included language file echoes its own, and so does factions/.scad run alone; a Pirate language has no body zone and no proportion) anchors ECHO anchor = [id, role, [x, y, z], yaw, parent] one line per anchor, yaw 0 nose, 90 port + lines ECHO line = [layer, cyclic, [[x, y, z], ...]] one line per kit part that draws one, points in the Ship Root + frame (lowpoly.scad lp_line under lp_at frames); layer is one of KIT_LAYERS the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. +Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone and anchor mesh, +read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with +lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back. @@ -49,9 +54,18 @@ OPENSCAD_TIMEOUT = 180 # seconds, per part SILHOUETTE_CELL = 0.25 # metres, the top-view raster the symmetry check compares ANCHOR_ROLES = ("weapon-mount", "weapon-muzzle", "thruster-emitter", "radiator-mesh") MESH_ROLES = {"thruster-emitter": "emit:", "radiator-mesh": "rad:"} -ECHO_PARTS = ("brief", "lang", "anchors") # parts that make no geometry; their ECHO lines are the answer +ECHO_PARTS = ("brief", "lang", "anchors", "lines") # parts that make no geometry; their ECHO lines are the answer LANGUAGE_NAME = re.compile(r"[a-z][a-z0-9_]*") ECHO_LINE = re.compile(r"^ECHO: (\w+) = (.*)$", re.MULTILINE) +KIT_LAYERS = ("outline", "step", "seam", "fitting") # layers a kit's lines may name; "silhouette" is hull_source's own +LINE_STYLE = {"silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.75), "fitting": (1.0, 1.0, 1.0, 0.6), + "step": (1.0, 1.0, 1.0, 0.5), "seam": (1.0, 1.0, 1.0, 0.35)} +MIN_SILHOUETTE_AREA = 0.25 # m2, below which a projected contour is a sliver, not the hull +LINE_EDGE_TOLERANCE = 0.3 # m, how far outside the silhouette's edge a kit line's point may lie +DUPLICATE_CELL = 0.001 # m, kit lines whose points all agree to this are the same line + +# A projector answers a list of Meshes with the contours (lists of (x, y)) of their top-view union. +Projector = Callable[[list], "list[list[tuple]]"] # A runner answers one part of the hull source with (OFF text, ECHO text). OFF text is "" when the part made no geometry. Runner = Callable[[str], "tuple[str, str]"] @@ -109,6 +123,16 @@ class Anchor: parent: str +@dataclass(frozen=True) +class SchematicLine: + layer: str # "silhouette" or one of KIT_LAYERS + cyclic: bool + points: tuple # ((x, y, z), ...) in the Ship Root frame + + def rgba(self): + return LINE_STYLE[self.layer] + + @dataclass(frozen=True) class Check: name: str @@ -129,6 +153,7 @@ class HullBuild: body_zone: str # the zone key the collider is made from hull_type: str # HullData.HullType of the bound package ("Ship", "Station", "Turret"), as the caller read it checks: list = field(default_factory=list) + lines: list = field(default_factory=list) # SchematicLine: the silhouette contours first, then the kit's lines def failed(self): return [check for check in self.checks if not check.ok] @@ -250,6 +275,47 @@ def read_anchors(text): return anchors +def read_lines(text): + """The kit's schematic lines, in echo order. A layer outside KIT_LAYERS, fewer than two points or a point that is not + three finite numbers is refused.""" + lines = [] + for raw in parse_echoes(text, "line"): + if not isinstance(raw, list) or len(raw) != 3 or type(raw[1]) is not bool or not isinstance(raw[2], list): + raise HullSourceError("A schematic line has the wrong shape") + layer = _text(raw[0]) + if layer not in KIT_LAYERS: + raise HullSourceError("A schematic line names a layer a kit does not draw") + if len(raw[2]) < 2: + raise HullSourceError("A schematic line has fewer than two points") + points = tuple(_vector(p, 3) for p in raw[2]) + if not all(math.isfinite(c) for p in points for c in p): + raise HullSourceError("A schematic line has a point that is not a finite number") + lines.append(SchematicLine(layer, raw[1], points)) + return lines + + +def to_off(mesh): + """The OFF text of a Mesh.""" + rows = ["OFF", f"{len(mesh.vertices)} {len(mesh.triangles)} 0"] + [" ".join(repr(float(c)) for c in v) for v in mesh.vertices] + return "\n".join(rows + [f"3 {a} {b} {c}" for a, b, c in mesh.triangles]) + "\n" + + +def parse_svg_contours(text): + """The contours of an OpenSCAD SVG (a path of 'M x,y L x,y ... z' pieces) as lists of model (x, y): the SVG's y is the + model's -y. A contour under MIN_SILHOUETTE_AREA is dropped (the zero-area slivers a projection leaves).""" + contours = [] + for path in re.findall(r'= 3 and area >= MIN_SILHOUETTE_AREA: + contours.append(ring) + return contours + + def _exactly_one(values, what): if len(values) != 1: raise HullSourceError(f"The hull source must echo exactly one {what}") @@ -289,9 +355,36 @@ def _language(library, faction): return language -def build(runner, library, hull_type): +def _point_in_ring(x, y, ring): + inside = False + for (ax, ay), (bx, by) in zip(ring, ring[1:] + ring[:1]): + if (ay > y) != (by > y) and x < ax + (y - ay) * (bx - ax) / (by - ay): + inside = not inside + return inside + + +def _edge_distance(x, y, ring): + best = float("inf") + for (ax, ay), (bx, by) in zip(ring, ring[1:] + ring[:1]): + dx, dy = bx - ax, by - ay + along = ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy) if dx or dy else 0.0 + along = max(0.0, min(1.0, along)) + best = min(best, math.hypot(x - (ax + along * dx), y - (ay + along * dy))) + return best + + +def over_silhouette(point, contours): + """Whether the point's top view lies inside the silhouette (even-odd over every contour) or within + LINE_EDGE_TOLERANCE of a contour's edge.""" + x, y = point[0], point[1] + return (sum(_point_in_ring(x, y, ring) for ring in contours) % 2 == 1 + or any(_edge_distance(x, y, ring) <= LINE_EDGE_TOLERANCE for ring in contours)) + + +def build(runner, library, hull_type, projector): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with - (the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller + (the ECHO text of that faction's own language file). projector (see Projector) answers the top-view contours of every + zone and anchor mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" @@ -314,10 +407,16 @@ def build(runner, library, hull_type): if len(set(ids)) != len(ids) or any(a.role not in ANCHOR_ROLES for a in anchors): raise HullSourceError("The hull source's anchors repeat an id or name a role a hull source does not make") anchor_meshes = {a.id: parse_off(runner(MESH_ROLES[a.role] + a.id)[0]) for a in anchors if a.role in MESH_ROLES} + kit_lines = read_lines(runner("lines")[1]) judge = donor or own body_zone = f"{donor.faction}:{donor.body}" if donor else own.body result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, own.glow, own.radiator, body_zone, hull_type) + meshes = [m for m in list(zones.values()) + list(anchor_meshes.values()) if m.triangles] + if meshes: + top = max(v[2] for m in meshes for v in m.vertices) + result.lines = [SchematicLine("silhouette", True, tuple((x, y, top) for x, y in contour)) for contour in projector(meshes)] + result.lines += kit_lines by_faction = lambda language: language.faction result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] @@ -366,6 +465,14 @@ def _checks(hull, own, judge): f"{width} x {height} cells, at most {hull_grid.MAX_CELLS} a side")) except ValueError: checks.append(Check("the top view fits the layout grid", False, "no geometry")) + silhouette = [line for line in hull.lines if line.layer == "silhouette"] + kit = [line for line in hull.lines if line.layer != "silhouette"] + contours = [[p[:2] for p in line.points] for line in silhouette] + checks.append(Check("the top view yields a silhouette", bool(silhouette), f"{len(silhouette)} contours")) + outside = [line for line in kit if not all(over_silhouette(p, contours) for p in line.points)] if silhouette else kit + checks.append(Check("every kit line lies over the hull's top view", not outside, f"{len(outside)} outside")) + keys = [tuple(tuple(round(c / DUPLICATE_CELL) for c in p) for p in line.points) for line in kit] + checks.append(Check("no two kit lines are the same", len(set(keys)) == len(keys), f"{len(keys) - len(set(keys))} repeated")) triangles = sum(len(m.triangles) for m in every) checks.append(Check("the triangle count lies inside the brief's budget", brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}")) @@ -380,6 +487,25 @@ def _checks(hull, own, judge): return checks +def _run_openscad(command, on_start, **options): + """Runs one OpenSCAD process to completion and answers its output. The refusals are fixed text.""" + try: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **options) + except OSError: + raise HullSourceError("OpenSCAD could not be started") from None + if on_start: + on_start(process.pid) + try: + output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise HullSourceError("OpenSCAD did not finish a part in time") from None + if process.returncode != 0: + raise HullSourceError("OpenSCAD refused a part of the hull source") + return output + + def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): """A Runner that runs OpenSCAD (Manifold backend, full render) on source, one process at a time. on_start(pid) is called with each process's id. OPENSCADPATH is the language folder, so a hull source includes .""" @@ -390,20 +516,7 @@ def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): target = Path(folder) / ("part.echo" if part in ECHO_PARTS else "part.off") command = [str(executable), "--backend", "Manifold", "--render", "all", "-D", f'part="{part}"', "-o", str(target), str(source)] - try: - process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) - except OSError: - raise HullSourceError("OpenSCAD could not be started") from None - if on_start: - on_start(process.pid) - try: - output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT) - except subprocess.TimeoutExpired: - process.kill() - process.communicate() - raise HullSourceError("OpenSCAD did not finish a part in time") from None - if process.returncode != 0: - raise HullSourceError("OpenSCAD refused a part of the hull source") + output = _run_openscad(command, on_start, env=env) written = target.read_text(encoding="utf-8", errors="replace") if target.exists() else "" return ("", written + output) if part in ECHO_PARTS else (written, output) @@ -420,3 +533,24 @@ def language_library(executable, library=FACTIONS_DIR, on_start=None): return openscad_runner(executable, file, library=library, on_start=on_start)("lang")[1] return read + + +def openscad_projector(executable, on_start=None): + """A Projector that has OpenSCAD (Manifold backend) project the union of the meshes onto the XY plane and export it as + SVG, one process, with the same timeout and refusal shape as openscad_runner.""" + def project(meshes): + with tempfile.TemporaryDirectory() as folder: + names = [] + for i, mesh in enumerate(meshes): + (Path(folder) / f"m{i}.off").write_text(to_off(mesh), encoding="utf-8") + names.append(f'import("m{i}.off");') + wrapper = Path(folder) / "silhouette.scad" + wrapper.write_text("projection() union() { " + " ".join(names) + " }\n", encoding="utf-8") + target = Path(folder) / "silhouette.svg" + command = [str(executable), "--backend", "Manifold", "-o", str(target), str(wrapper)] + _run_openscad(command, on_start, cwd=folder) + if not target.exists(): + raise HullSourceError("OpenSCAD refused a part of the hull source") + return parse_svg_contours(target.read_text(encoding="utf-8", errors="replace")) + + return project diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index bd534d83b..a1d8d163b 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -67,8 +67,18 @@ class Fixture: + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]) + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])), "emit:Th.A": (box(0.5, 1.5, 5, 5.1, 0.5, 1.5), ""), + "lines": ("", ""), } + @staticmethod + def projector(meshes): + """A top view that is the bounding rectangle of every mesh it is given.""" + lows = [m.bounds()[0] for m in meshes] + highs = [m.bounds()[1] for m in meshes] + x0, y0 = min(v[0] for v in lows), min(v[1] for v in lows) + x1, y1 = max(v[0] for v in highs), max(v[1] for v in highs) + return [[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]] + def runner(self): return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.parts[part] @@ -78,7 +88,7 @@ class Fixture: return self.library[faction] def build(self): - return hs.build(self.runner(), self.read_library, self.hull_type) + return hs.build(self.runner(), self.read_library, self.hull_type, self.projector) def failed(self): return [c.name for c in self.build().failed()] @@ -206,7 +216,7 @@ class HullTypeTests(unittest.TestCase): def test_build_has_no_default_hull_type(self): fixture = Fixture() with self.assertRaises(TypeError): - hs.build(fixture.runner(), fixture.read_library) + hs.build(fixture.runner(), fixture.read_library, projector=fixture.projector) def test_build_refuses_a_hull_type_that_is_not_a_hulltype_name_before_reading_the_source(self): for hull_type in ("ship", "SHIP", "Ship ", "", None, "Stations", 0, SECRET): @@ -214,7 +224,7 @@ class HullTypeTests(unittest.TestCase): fixture, read = Fixture(), [] runner = fixture.runner() with self.assertRaises(hs.HullSourceError) as caught: - hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type) + hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type, fixture.projector) self.assertEqual(read, []) self.assertNotIn(SECRET, str(caught.exception)) @@ -479,7 +489,7 @@ class LanguageOwnershipTests(unittest.TestCase): def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self): asked = [] fixture = DonorTests.pirate() - hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship") + hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship", fixture.projector) self.assertEqual(sorted(asked), ["pirate", "rnd"]) @@ -667,5 +677,187 @@ class RefusalTests(unittest.TestCase): self.assertEqual(source.count("subprocess.Popen"), 1) +LINES_OVER = "every kit line lies over the hull's top view" +LINES_DISTINCT = "no two kit lines are the same" +SILHOUETTE = "the top view yields a silhouette" + + +def line(layer="seam", points=((-1, -1, 2), (1, 1, 2)), cyclic=False): + return echo("line", [layer, cyclic, [list(p) for p in points]]) + + +class LineReadingTests(unittest.TestCase): + def test_lines_are_read_in_echo_order_with_their_layer_flag_and_points(self): + text = line("step", ((0, 0, 1), (1, 0, 1), (1, 1, 1)), True) + line("fitting") + read = hs.read_lines(text) + self.assertEqual([(l.layer, l.cyclic, len(l.points)) for l in read], [("step", True, 3), ("fitting", False, 2)]) + self.assertEqual(read[0].points[1], (1, 0, 1)) + + def test_a_layer_outside_the_kit_layers_is_refused_without_echoing_it(self): + for layer in ("silhouette", "outlines", "", SECRET): + with self.subTest(layer=layer), self.assertRaises(hs.HullSourceError) as caught: + hs.read_lines(line(layer)) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_a_line_of_one_point_or_a_point_that_is_not_finite_is_refused(self): + for text in (line(points=((0, 0, 0),)), line(points=()), 'ECHO: line = ["seam", false, [[0, 0, NaN], [1, 1, 1]]]\n', + 'ECHO: line = ["seam", false, [[0, 0, Infinity], [1, 1, 1]]]\n', line(points=((0, 0), (1, 1)))): + with self.subTest(text=text), self.assertRaises(hs.HullSourceError): + hs.read_lines(text) + + def test_a_cyclic_flag_that_is_not_a_boolean_is_refused(self): + with self.assertRaises(hs.HullSourceError): + hs.read_lines(echo("line", ["seam", 1, [[0, 0, 0], [1, 1, 1]]])) + + +SVG = """ + + + +""" + + +class SvgContourTests(unittest.TestCase): + def test_contours_come_back_in_model_coordinates_with_y_negated(self): + contours = hs.parse_svg_contours(SVG) + self.assertEqual(contours[0], [(0.0, 4.0), (4.0, 4.0), (4.0, 1.0), (0.0, 1.0)]) + self.assertEqual(contours[1][0], (-1e-05, -2.0)) + + def test_a_contour_under_a_quarter_square_metre_is_a_sliver_and_dropped(self): + self.assertEqual(len(hs.parse_svg_contours(SVG)), 2) + + def test_a_drawing_with_no_path_has_no_contours(self): + self.assertEqual(hs.parse_svg_contours(""), []) + + +class LineBuildTests(unittest.TestCase): + def built(self, kit="", **parts): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + fixture.parts.update(parts) + return fixture, fixture.build() + + def test_a_source_with_no_lines_has_the_silhouette_alone_and_passes_every_check(self): + _, built = self.built() + self.assertEqual([(l.layer, l.cyclic) for l in built.lines], [("silhouette", True)]) + self.assertEqual(built.failed(), []) + + def test_the_silhouette_is_first_cyclic_and_at_the_top_of_the_geometry(self): + _, built = self.built(line("outline", ((-1, 0, 1), (1, 0, 1)))) + self.assertEqual([l.layer for l in built.lines], ["silhouette", "outline"]) + self.assertEqual({p[2] for p in built.lines[0].points}, {3.0}) + self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.1), (-3.0, 5.1)}) + + def test_each_layer_carries_its_fixed_style(self): + kit = "".join(line(layer) for layer in hs.KIT_LAYERS) + _, built = self.built(kit) + self.assertEqual([l.rgba() for l in built.lines], [hs.LINE_STYLE[l.layer] for l in built.lines]) + self.assertEqual(len({l.rgba() for l in built.lines}), 5) + self.assertEqual(hs.LINE_STYLE["silhouette"], (1.0, 1.0, 1.0, 1.0)) + + def test_the_projector_is_given_every_zone_and_anchor_mesh_that_has_geometry(self): + fixture, given = Fixture(), [] + hs.build(fixture.runner(), fixture.read_library, "Ship", + lambda meshes: given.append(len(meshes)) or fixture.projector(meshes)) + self.assertEqual(given, [3]) # primary, secondary, emit:Th.A + + def test_a_kit_line_off_the_hull_fails_the_over_the_top_view_check_and_nothing_else(self): + _, built = self.built(line(points=((0, 0, 2), (20, 0, 2)))) + self.assertEqual([c.name for c in built.failed()], [LINES_OVER]) + + def test_a_kit_line_a_little_off_the_edge_still_lies_over_the_hull(self): + _, built = self.built(line(points=((3.2, 0, 2), (3.2, 1, 2)))) + self.assertEqual(built.failed(), []) + _, built = self.built(line(points=((3.4, 0, 2), (3.4, 1, 2)))) + self.assertEqual([c.name for c in built.failed()], [LINES_OVER]) + + def test_the_inside_of_a_hole_in_the_silhouette_is_not_over_the_hull(self): + hole = [(-1, -1), (1, -1), (1, 1), (-1, 1)] + outer = [(-3, -5), (3, -5), (3, 5), (-3, 5)] + self.assertTrue(hs.over_silhouette((2, 0, 0), [outer, hole])) + self.assertFalse(hs.over_silhouette((0, 0, 0), [outer, hole])) + + def test_two_equal_kit_lines_fail_the_distinct_check_and_nothing_else(self): + _, built = self.built(line() + line("fitting")) + self.assertEqual([c.name for c in built.failed()], [LINES_DISTINCT]) + + def test_lines_that_differ_by_a_millimetre_or_more_are_distinct(self): + _, built = self.built(line(points=((0, 0, 2), (1, 0, 2))) + line(points=((0, 0.002, 2), (1, 0, 2)))) + self.assertEqual(built.failed(), []) + + def test_no_contour_fails_the_silhouette_check(self): + fixture = Fixture() + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: []) + self.assertEqual([c.name for c in built.failed()], [SILHOUETTE]) + self.assertEqual(built.lines, []) + + def test_a_kit_line_with_no_silhouette_to_lie_over_fails_both_checks(self): + fixture = Fixture() + fixture.parts["lines"] = ("", line()) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: []) + self.assertEqual(sorted(c.name for c in built.failed()), sorted([SILHOUETTE, LINES_OVER])) + + def test_a_source_with_no_geometry_asks_for_no_silhouette(self): + fixture = Fixture() + fixture.parts.update({"primary": ("", ""), "secondary": ("", ""), "emit:Th.A": ("", "")}) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: self.fail("projected nothing")) + self.assertEqual(built.lines, []) + + def test_a_refused_line_is_refused_without_echoing_it(self): + fixture = Fixture() + fixture.parts["lines"] = ("", line(SECRET)) + with self.assertRaises(hs.HullSourceError) as caught: + fixture.build() + self.assertNotIn(SECRET, str(caught.exception)) + + +class WrapperOpenScad(FakeOpenScad): + """Keeps the wrapper scad and the OFF files OpenSCAD is given, which vanish with the temp folder.""" + + def __call__(self, command, **kwargs): + wrapper = Path(command[-1]) + self.wrapper = wrapper.read_text(encoding="utf-8") + self.imports = {f.name: f.read_text(encoding="utf-8") for f in wrapper.parent.glob("*.off")} + return super().__call__(command, **kwargs) + + +class ProjectorTests(unittest.TestCase): + def project(self, openscad, meshes): + with mock.patch.object(hs.subprocess, "Popen", openscad): + return hs.openscad_projector("scad")(meshes) + + def test_the_meshes_are_unioned_projected_and_the_svg_read_back(self): + a, b = hs.parse_off(box(0, 1, 0, 1, 0, 1)), hs.parse_off(box(2, 3, 0, 1, 0, 1)) + openscad = WrapperOpenScad(off=SVG) + contours = self.project(openscad, [a, b]) + self.assertEqual(openscad.wrapper.split(), ["projection()", "union()", "{", 'import("m0.off");', 'import("m1.off");', "}"]) + self.assertEqual(sorted(openscad.imports), ["m0.off", "m1.off"]) + self.assertEqual(hs.parse_off(openscad.imports["m1.off"]).bounds(), b.bounds()) + command = openscad.calls[0][0] + self.assertEqual(command[command.index("--backend") + 1], "Manifold") + self.assertTrue(command[command.index("-o") + 1].endswith(".svg")) + self.assertEqual(len(contours), 2) + + def test_a_failed_or_silent_openscad_is_refused_without_echoing_its_output(self): + for openscad in (FakeOpenScad(returncode=1, output=SECRET), FakeOpenScad(output=SECRET)): + with self.assertRaises(hs.HullSourceError) as caught: + self.project(openscad, [hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_the_process_is_reported_to_on_start(self): + started = [] + with mock.patch.object(hs.subprocess, "Popen", WrapperOpenScad(off=SVG)): + hs.openscad_projector("scad", on_start=started.append)([hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertEqual(started, [4242]) + + def test_the_lines_part_answers_the_echo(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad(output=line())): + self.assertEqual(hs.openscad_runner("scad", "hull.scad")("lines"), ("", line())) + + if __name__ == "__main__": unittest.main() From 905074defb43412a9f430b3833e498d56a110201 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:59:56 +0200 Subject: [PATCH 019/230] Add-on: schematic lines are hers (one Grease Pencil) else Build's from the kit; smoke and C# fact Co-Authored-By: Claude Sonnet 5.5 --- .../NakovalnyaPackageTests.cs | 21 +++++ tools/blender/aetheria_ships/__init__.py | 54 ++++++++---- tools/blender/aetheria_ships/ship_cc.py | 2 +- tools/blender/tests/smoke_hull_source.py | 84 +++++++++++++++++++ tools/blender/tests/test_ship_cc.py | 2 +- 5 files changed, 147 insertions(+), 16 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs index d2b08f8ba..29d15db31 100644 --- a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -120,4 +120,25 @@ public sealed class NakovalnyaPackageTests .Select(row => $"{row.Item1} refuses the {row.Item2}").ToArray(); Assert.Empty(refused); } + + // The package's schematic is the kit's (ruling agent-hull-schematic-from-kit): hull_source's silhouette first, then + // the kit's lines; Studio fits their top view onto the hull Shape's whole cell rect. + [Fact] + public void SchematicLinesComeFromTheKit() + { + using var cache = Catalog(); + var hull = Hull(cache); + var ship = cache.Get(new CultRecordRef(ShipModCatalog.AuthoringKey(Id))); + var kitLayers = new[] { "outline", "step", "seam", "fitting" }; + + Assert.Equal("silhouette", ship.SchematicLines[0].Layer); + Assert.True(ship.SchematicLines[0].Cyclic); + Assert.True(ship.SchematicLines.Count(line => kitLayers.Contains(line.Layer)) > 20); + Assert.All(ship.SchematicLines, line => Assert.Contains(line.Layer, kitLayers.Append("silhouette"))); + ShipAuthoringStore.Validate(hull, ship); + + var fit = SchematicUnderlay.Fit(ship.SchematicLines, hull.Shape).SelectMany(line => line).ToArray(); + Assert.Equal((19, 30), (hull.Shape.Width, hull.Shape.Height)); + Assert.Equal((0f, 0f, 19f, 30f), (fit.Min(p => p.x), fit.Min(p => p.y), fit.Max(p => p.x), fit.Max(p => p.y))); + } } diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 17ad00afc..08daa095c 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -533,12 +533,11 @@ class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): bl_idname = "aetheria.capture_ship_lines" bl_label = "Capture Ship Lines" - bl_description = "Replace only the line slot in the selected typed ship .cc record" + bl_description = "Replace only the line slot in the bound ship .cc record: from the Grease Pencil object, else from the hull source" bl_options = {"REGISTER"} def execute(self, context): try: - obj = context.active_object collection = _ship_collection(context) if not collection.get("aetheria.ship_cc"): raise ValueError("The ship collection has no bound .cc path") @@ -546,10 +545,9 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): cultlib = _brokkr_cultlib(context) if read(path, cultlib).ship.body[0] != collection["aetheria.id"]: raise ValueError("The bound collection ID does not match its .cc ship ID") - lines = capture_grease_pencil( - obj, context.evaluated_depsgraph_get(), context.scene.frame_current, - evaluated=context.scene.aetheria_capture_evaluated_lines, - ) + lines = _schematic_lines(context, collection) + if lines is None: + raise ValueError("No schematic lines were captured; the ship record was not changed") count = replace_lines(path, cultlib, lines) self.report({"INFO"}, f"Captured {count} lines") return {"FINISHED"} @@ -1492,6 +1490,7 @@ def _frame_ship(context, collection, root, path, saved_shape): HULL_MAKER = "aetheria.hull_maker" # on the collection: the Faction key of the language Build judged the hull against +HULL_LINES = "aetheria.hull_lines" # on the collection: [{layer, cyclic, points (flat), rgba}] the hull source's kit drew, in Ship Root's frame HULL_BUILT = "aetheria.hull_built" # on the collection: {object name: fingerprint} of what Build From Hull Source made HULL_AGENT = "agent" # HS rows H5, H7-H10, H12: Build's judged body or record governs; Package checks the maker HULL_ART = "art" # HS rows H4, H6, H11: her meshes replace Build's; Package ships them and retires the hull source @@ -1577,7 +1576,7 @@ def _build_hull_source(context, collection): executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD hull_type = read_hull_type(bpy.path.abspath(collection["aetheria.ship_cc"]), _brokkr_cultlib(context)) hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable), - hull_type) + hull_type, hull_source.openscad_projector(executable)) broken = hull.failed() if broken: raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken)) @@ -1629,9 +1628,38 @@ def _build_hull_source(context, collection): collider["aetheria.role"], collider["aetheria.id"] = role, anchor_id collection[HULL_BUILT] = {obj.name: _fingerprint(obj) for obj in made} collection[HULL_MAKER] = hull.languages[hull.brief.faction].maker + collection[HULL_LINES] = [{"layer": line.layer, "cyclic": line.cyclic, "points": [c for p in line.points for c in p], + "rgba": list(line.rgba())} for line in hull.lines] return hull +def _schematic_lines(context, collection): + """The ShipAuthoring.SchematicLines rows Package and Capture write, and the only source of them: her Grease Pencil + object if the collection has one (two or more are refused), else the lines Build recorded from the hull source's kit, + mapped through Ship Root's matrix_world (so after _frame_ship, the frame her strokes are captured in), else None and + the slot is left as it is.""" + pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] + if len(pencils) > 1: + raise ValueError("Keep one Grease Pencil object: it is the ship's schematic") + scene = context.scene + if pencils: + return capture_grease_pencil(pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, + evaluated=scene.aetheria_capture_evaluated_lines) + record = collection.get(HULL_LINES) + if record is None: + return None + root = _ship_root(collection) + world = root.matrix_world if root is not None else Matrix.Identity(4) + rows = [] + for line in record: + flat = list(line["points"]) + points = [] + for x, y, z in zip(flat[0::3], flat[1::3], flat[2::3]): + points.extend(float(c) for c in world @ Vector((x, y, z))) + rows.append([line["layer"], "", points, None, None, bool(line["cyclic"]), [float(c) for c in line["rgba"]]]) + return rows + + def _hull_state(collection): """The one owner of a ship collection's hull state (map HS), decided in this order: an untouched Build zone mesh keeps the hull agent's; her meshes beside Build's record or hull.scad make it art; then Build's record is agent, @@ -1659,7 +1687,7 @@ def _retire_hull_source(collection): source = _hull_source_file(collection) if source is not None: source.unlink() - for record in (HULL_BUILT, HULL_MAKER): + for record in (HULL_BUILT, HULL_MAKER, HULL_LINES): if record in collection: del collection[record] return True @@ -1708,7 +1736,7 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): bl_idname = "aetheria.package_ship" bl_label = "Package Ship" bl_description = ("Put the grid's centre of mass at the world origin, derive the missing map icon, shield and " - "tractor, write the anchors and ship.glb from the bound collection, capture its LineArt, and " + "tractor, write the anchors and ship.glb from the bound collection, capture its schematic lines, and " "run AetherDb's validator") bl_options = {"REGISTER"} @@ -1738,11 +1766,9 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): anchors = _anchors(collection) _export_glb(context, collection, str(Path(path).with_name(MODEL_ASSET))) replace_visual(path, cultlib, ship_id, MODEL_ASSET, anchors) - pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] - if len(pencils) == 1: - replace_lines(path, cultlib, capture_grease_pencil( - pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, - evaluated=scene.aetheria_capture_evaluated_lines)) + lines = _schematic_lines(context, collection) + if lines is not None: + replace_lines(path, cultlib, lines) code, message = _aetherdb(context, "validate", path, ship_cc=path) except _EXPECTED as exc: scene.aetheria_package_report = f"Package failed: {_failure_text(exc)}" diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 079296be4..f3742dd4f 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -173,7 +173,7 @@ def _stamped(record: Record, msgpack: Any): def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> int: file = read(path, cultlib_packages) if not lines: - raise ValueError("No Grease Pencil strokes were captured; the ship record was not changed") + raise ValueError("No schematic lines were captured; the ship record was not changed") file.ship.body[SCHEMATIC_LINES_SLOT] = lines file.store.push(_stamped(file.ship, file.msgpack)) return len(lines) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 06f2db715..b57dfe5af 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -36,6 +36,10 @@ the hull.scad into the package and runs the panel's operators: or a product by another maker than the language Build judged it against (hull-names-its-own-faction); with a product by that maker it accepts, with hull.scad in the package or not (the guard keys on the hull's state), and a refused Package changes nothing in the scene. Her art needs no product. + Schematic lines: after Build and Package the ship.cc's first line is the silhouette and the kit's lines follow (the Pirate + hull's kit draws none); a Grease Pencil object in the collection replaces them with its own; a second one is refused by + Package and Capture Ship Lines, and the slot is as it was. The Nakovalnya's hull.scad, read with the real kit, has a + silhouette whose box is the zones' top-view box and a fitting octagon at every flak mount anchor. Build and Package refuse copies that share an aetheria.id, selecting the original and the copies, and delete nothing. Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw. The ship.cc HullData.HullType decides whether the language's proportions judge the hull: a body far wider than the @@ -366,6 +370,37 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require(saved == [(mount_id, cell) for mount_id, _, cell in rows], f"A row is not as authored after Package: {saved}") print(f"PASS {number}.5 Package accepts the built ship and keeps hull.scad") + # Schematic lines: Build's, else hers, and never two of hers. + packaged = ship_lines(ship_cc) + require(packaged and packaged[0][0] == "silhouette" and packaged[0][5] is True, f"The first line is not the silhouette: {packaged[:1]}") + kit = packaged[1:] + require(all(row[0] in hull_source.KIT_LAYERS and row[3] is None and row[4] is None and row[1] == "" for row in kit), + "A kit line row is not shaped as Package writes it") + require(bool(kit) == (fixture == "zh-ammo-tender"), f"The kit drew {len(kit)} lines for {fixture}") + require(all(approx(row[6], hull_source.LINE_STYLE[row[0]]) for row in packaged), "A line lost its style") + shipped_z = {round(c, 3) for row in packaged for c in row[2][2::3]} + require(len(shipped_z) >= 1, "The lines have no height") + pencil = grease_pencil(collection, "her lines", [(0, 0, 0), (1, 0, 0), (1, 1, 0)]) + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"}, f"Package refused a ship with one Grease Pencil: {message!r}") + hers = ship_lines(ship_cc) + require(len(hers) == 1 and hers[0][0] == "hers" and len(hers[0][2]) == 9, f"Her Grease Pencil did not replace the kit's lines: {hers}") + second = grease_pencil(collection, "second lines", [(0, 0, 0), (2, 0, 0)]) + before = ship_lines(ship_cc) + for operator in (bpy.ops.aetheria.package_ship, bpy.ops.aetheria.capture_ship_lines): + select(box) + result, message = run(operator) + require(result == {"CANCELLED"} and "Keep one Grease Pencil object" in (message + scene.aetheria_package_report), + f"Two Grease Pencil objects were not refused: {message!r}") + require(ship_lines(ship_cc) == before, "A refused schematic changed the line slot") + for obj in (pencil, second): + bpy.data.objects.remove(obj) + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"} and len(ship_lines(ship_cc)) == len(packaged), f"Without Grease Pencil the kit's lines are back: {message!r}") + print(f"PASS {number}.5b the kit's lines ship, her Grease Pencil wins, two are refused") + # What Package did to the built objects is not an edit: Build still replaces them. select(box) result, message = run(bpy.ops.aetheria.build_hull_source) @@ -566,6 +601,54 @@ def clear(collection): bpy.data.objects.remove(obj) +def ship_lines(ship_path): + """The ShipAuthoring.SchematicLines rows of a ship.cc, as Package wrote them.""" + return sys.modules["aetheria_ships"].read(ship_path, PACKAGES).ship.body[sys.modules["aetheria_ships"].ship_cc.SCHEMATIC_LINES_SLOT] + + +def grease_pencil(collection, name, points): + """A Grease Pencil object in the collection holding one stroke, layer named 'hers'.""" + data = bpy.data.grease_pencils.new(name) + material = bpy.data.materials.new(name) + bpy.data.materials.create_gpencil_data(material) + data.materials.append(material) + layer = data.layers.new("hers") + drawing = layer.frames.new(1).drawing + drawing.add_strokes([len(points)]) + for point, position in zip(drawing.strokes[0].points, points): + point.position = position + obj = bpy.data.objects.new(name, data) + collection.objects.link(obj) + return obj + + +def nakovalnya_frames(executable): + """The Nakovalnya's hull.scad with the real kit: the silhouette is the zones' top view and a fitting octagon sits at + every flak mount (the frame the kit placed the barrel in is the frame the anchor is in).""" + source = REPO / "GameData" / "Mods" / "aetheria.nakovalnya" / "hull.scad" + built = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable), + "Ship", hull_source.openscad_projector(executable)) + require(not built.failed(), f"The Nakovalnya breaks a check: {[c.name for c in built.failed()]}") + silhouette = [row for row in built.lines if row.layer == "silhouette"] + require(built.lines[0].layer == "silhouette" and len(silhouette) == 1, "The Nakovalnya silhouette is not one first contour") + xs = [v[0] for mesh in built.zones.values() for v in mesh.vertices] + ys = [v[1] for mesh in built.zones.values() for v in mesh.vertices] + box = (min(xs), max(xs), min(ys), max(ys)) + flat = silhouette[0].points + got = (min(p[0] for p in flat), max(p[0] for p in flat), min(p[1] for p in flat), max(p[1] for p in flat)) + require(approx(box, got, 1e-3), f"The silhouette box {got} is not the zones' top-view box {box}") + octagons = [row.points for row in built.lines if row.layer == "fitting" and row.cyclic and len(row.points) == 8] + centres = [(sum(p[0] for p in o) / 8, sum(p[1] for p in o) / 8) for o in octagons] + mounts = [a for a in built.anchors if a.id.startswith("Gun.F") and a.role == "weapon-mount"] + require(len(mounts) == 10, f"The Nakovalnya has {len(mounts)} flak mounts") + for mount in mounts: + near = min(math.hypot(mount.position[0] - c[0], mount.position[1] - c[1]) for c in centres) + require(near <= 0.3, f"{mount.id} is {near:.2f} m from the nearest fitting octagon") + kinds = {row.layer for row in built.lines} + require({"silhouette", "outline", "step", "seam", "fitting"} <= kinds, f"The Nakovalnya lacks a layer: {kinds}") + print(f"PASS frames the Nakovalnya's {len(built.lines)} lines are in the frame of its geometry") + + def aetherdb(ship_path, *args): """Runs one AetherDb ship-authoring command through the add-on's own runner; returns its exit code.""" return sys.modules["aetheria_ships"]._aetherdb(bpy.context, *args, ship_cc=ship_path)[0] @@ -797,6 +880,7 @@ def main(): clear(agent_guards(scene)) clear(hull_type_gate(scene)) never_built(scene) + nakovalnya_frames(EXECUTABLE) created = not MODS.exists() diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 828352438..ac01b2faa 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -368,7 +368,7 @@ class ReplaceLinesTests(ShipFileCase): def test_refuses_an_empty_capture_and_changes_nothing(self): before = self.bytes() - with self.assertRaisesRegex(ValueError, "No Grease Pencil strokes"): + with self.assertRaisesRegex(ValueError, "No schematic lines"): ship_cc.replace_lines(self.path, PACKAGES, []) self.assertEqual(before, self.bytes()) From d5d94fb48316b91ae7a35fca7fa9c057a456c1cb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:00:08 +0200 Subject: [PATCH 020/230] Staged Approach: the pilot starts knowing its anchor, through the sensors' own info state (ruling stage-states-pilot-knowledge) Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/RunStart.cs | 6 ++ Assets/Scripts/ServerShared/ScenarioStage.cs | 6 +- .../ApproachKnowledgeTests.cs | 60 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index 2e1245bab..20269e84c 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -55,6 +55,12 @@ public static class RunStart player.SetIff(placed.Entity, hostile); arena.Admit(placed.Entity, placed.Piloted, placed.Task); } + + // A start state includes what a pilot knows (ruling stage-states-pilot-knowledge): its anchor is tracked from the first + // step, by the sensors' own path (info gathered past the detection threshold), so they can lose it like any track. + foreach (var placed in stage.Placed) + if (placed.Knows != null && placed.Piloted) + placed.Entity.EntityInfoGathered[placed.Knows] = (itemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f; return new Staged { Player = player, Credits = stage.Credits, Entities = stage.Placed.Select(placed => placed.Entity).ToList() }; } diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index f0df352e9..a9e2db5d0 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -50,6 +50,9 @@ public sealed class ScenarioStage public ScenarioStance Stance; public bool Piloted; public AgentTask Task; + // What the pilot already knows at the start: an entity its sensors hold a track of (Approach). Seeded through the + // sensors' own state at admission, so they may lose it honestly. + public Entity Knows; } // A hull fitted slot by slot: each slot a design or a product, the hull cell it goes in, and its rotation. A name is @@ -137,7 +140,7 @@ public sealed class ScenarioStage } // The task a piloted entity starts on: it closes on the target and holds there (the flight's follow task, standoff - // nothing), in place of the patrol circuit the zone would draw for it. A check that rests on where a pilot goes states + // nothing), in place of the patrol circuit the zone would draw for it. The pilot starts knowing it (RunStart seeds a sensor track). A check that rests on where a pilot goes states // it here, so the pilot's course is the scenario's and not a draw. An entity that is not placed and piloted, or a // target that is not placed, is a failure. public void Approach(Entity entity, Entity target) @@ -146,6 +149,7 @@ public sealed class ScenarioStage if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; } if (target == null) { _failures.Add("approach: no target to close on"); return; } placement.Task = new FollowTask { Anchor = PilotView.Handle(target), Standoff = 0f }; + placement.Knows = target; } // Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs new file mode 100644 index 000000000..98f857913 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -0,0 +1,60 @@ +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// A staged Approach states what its pilot already knows (ruling stage-states-pilot-knowledge): the anchor is a sensor +// track from the first step, and the pilot follows it only as long as the sensors keep it. +public sealed partial class RunStartTests +{ + private (Zone arena, Ship east, Ship west) StagedApproach() + { + Ship east = null, west = null; + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + east = stage.Place(EvLonginus(stage), float2(450, 0), facing: float2(-1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship; + stage.Approach(east, west); + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + return (arena, east, west); + } + + [Fact] + public void AStagedApproachFollowerClosesOnItsAnchorFromTheFirstStep() + { + var (arena, east, west) = StagedApproach(); + var view = arena.Agents.Single(a => a.Ship == east).View; + var startGap = east.Position.x - west.Position.x; + + arena.Update(1f / 60f); + arena.Update(1f / 60f); + + Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); + Assert.True(east.Velocity.x < -.5f, $"the follower thrusts toward its anchor from the first steps (vx {east.Velocity.x})"); + for (var i = 0; i < 120; i++) arena.Update(1f / 60f); + Assert.True(east.Position.x - west.Position.x < startGap - 20f, "the follower closed on its anchor"); + } + + [Fact] + public void AStagedApproachFollowerStopsFollowingOnceTheSensorsLoseTheTrack() + { + var (arena, east, west) = StagedApproach(); + var view = arena.Agents.Single(a => a.Ship == east).View; + var sentinel = new float3(0, 0, 12345); + for (var i = 0; i < 3; i++) arena.Update(1f / 60f); + + east.Command.Aim = sentinel; + arena.Update(1f / 60f); + Assert.NotEqual(sentinel, east.Command.Aim.Value); + + east.EntityInfoGathered[west] = 0f; + Assert.Null(view.TrackOf(east, PilotView.Handle(west))); + east.Command.Aim = sentinel; + arena.Update(1f / 60f); + Assert.Equal(sentinel, east.Command.Aim.Value); + } +} From 5bb4ad5f0f2579f9484638dfb443363b1d082ece Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:02:53 +0200 Subject: [PATCH 021/230] Smoke: the silhouette may be several contours, all before the kit's lines Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index b57dfe5af..ba5a12ea6 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -373,8 +373,10 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett # Schematic lines: Build's, else hers, and never two of hers. packaged = ship_lines(ship_cc) require(packaged and packaged[0][0] == "silhouette" and packaged[0][5] is True, f"The first line is not the silhouette: {packaged[:1]}") - kit = packaged[1:] - require(all(row[0] in hull_source.KIT_LAYERS and row[3] is None and row[4] is None and row[1] == "" for row in kit), + kit = [row for row in packaged if row[0] != "silhouette"] + drawn = [row[0] for row in packaged] + require(drawn[:len(packaged) - len(kit)] == ["silhouette"] * (len(packaged) - len(kit)), "The silhouette contours are not first") + require(all(row[3] is None and row[4] is None and row[1] == "" for row in packaged), "A kit line row is not shaped as Package writes it") require(bool(kit) == (fixture == "zh-ammo-tender"), f"The kit drew {len(kit)} lines for {fixture}") require(all(approx(row[6], hull_source.LINE_STYLE[row[0]]) for row in packaged), "A line lost its style") From 1a2a24ceb1c1726ba4eef9fce2ff8211707c7369 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:05:39 +0200 Subject: [PATCH 022/230] Staged knowledge: repin the two-AI digest for fighters staged knowing each other; LockOnSituation drops the no-track premise Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 5 +++-- tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs | 6 +++--- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index e3ab326f0..6ff03b525 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -24,8 +24,9 @@ public sealed partial class RunStartTests // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them // (ruling replays-per-host-class), and arenas run on Linux. private const string TwoAiPinnedHostClass = "linux-x64"; - // Repinned once, for the side split: the two fighters are unaffiliated, were one side and each followed itself. - private const string TwoAiPinnedDigest = "1b2ffe51f799e762199717a3bc003e880a80106023463aabc40bd2e4f746697c"; + // Repinned twice. First for the side split: the two fighters are unaffiliated, were one side and each followed itself. + // Then for staged knowledge (ruling repin-for-staged-knowledge): the fighters are staged knowing each other, so they engage at step 0. + private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33"; private const int TwoAiSteps = 3600; [Fact] diff --git a/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs b/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs index 603a6bea0..e88abc220 100644 --- a/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs @@ -23,10 +23,10 @@ public sealed partial class RunStartTests var toPlayer = normalize(player.Position.xz - hostile.Position.xz); Assert.True(dot(normalize(hostile.Direction), toPlayer) >= cos(radians(5f)), "the hostile faces the player"); - // The first real sensing tick: the hostile holds no track of the player, and the sim's own passive sensing, asked for - // the player's signature at the staged place, settles below the detection threshold: the staging is out of its sight. + // The hostile is staged knowing the player (ruling stage-states-pilot-knowledge): it starts with a track of it. The sim's + // own passive sensing, asked for the player's signature at the staged place, settles below the detection threshold: the + // staging is out of its sight, so the staged track is all that carries it in. arena.Update(.1f); - Assert.False(hostile.VisibleEnemies.Any(x => ReferenceEquals(x, player)), "the player is out of sight"); Assert.DoesNotContain(player.IncomingPaints, paint => paint.Mode == PaintMode.Track); Assert.True(player.Visibility > 0f, "the player shows a signature to be seen by"); var threshold = player.ItemManager.GameplaySettings.TargetDetectionInfoThreshold; diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 3bfeb9f42..64eb493d8 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -462,7 +462,7 @@ public sealed partial class RunStartTests var aim = normalize(east.Command.Aim.Value.xz); var toThird = dot(aim, normalize(third.Position.xz - east.Position.xz)); var toWest = dot(aim, normalize(west.Position.xz - east.Position.xz)); - Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest}"); + Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest} tracked {east.VisibleEntities.Contains(third)}"); } [Fact] From 66c69cdc90c7fd6bed79d32da028d75803da9a9c Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:06:19 +0200 Subject: [PATCH 023/230] Smoke: sideways-facing kit parts draw no lines (octagon and line counts of the Nakovalnya) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index ba5a12ea6..c60967253 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -646,6 +646,9 @@ def nakovalnya_frames(executable): for mount in mounts: near = min(math.hypot(mount.position[0] - c[0], mount.position[1] - c[1]) for c in centres) require(near <= 0.3, f"{mount.id} is {near:.2f} m from the nearest fitting octagon") + # Parts that face sideways (the belt collars and turning blocks, the wall hatches) draw nothing: no top view sees them. + require(len(octagons) == 20, f"{len(octagons)} octagons: ten flak drums and ten flak rings, and no belt collar's") + require(sum(row.layer != "silhouette" for row in built.lines) == 102, "The Nakovalnya kit's line count changed") kinds = {row.layer for row in built.lines} require({"silhouette", "outline", "step", "seam", "fitting"} <= kinds, f"The Nakovalnya lacks a layer: {kinds}") print(f"PASS frames the Nakovalnya's {len(built.lines)} lines are in the frame of its geometry") From fd1188a797dbef1fd61703decb1aac0b434404ec Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:06:59 +0200 Subject: [PATCH 024/230] hull_source: the silhouette is the zones' top view, what Rasterise reads Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 6 +++--- tools/blender/tests/test_hull_source.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index ca929560f..2689a7230 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -18,7 +18,7 @@ with it (a hull that redefines or shadows an included language fails a check): Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. -Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone and anchor mesh, +Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone mesh, the geometry Rasterise reads, read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS @@ -384,7 +384,7 @@ def over_silhouette(point, contours): def build(runner, library, hull_type, projector): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with (the ECHO text of that faction's own language file). projector (see Projector) answers the top-view contours of every - zone and anchor mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller + zone mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" @@ -412,7 +412,7 @@ def build(runner, library, hull_type, projector): body_zone = f"{donor.faction}:{donor.body}" if donor else own.body result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, own.glow, own.radiator, body_zone, hull_type) - meshes = [m for m in list(zones.values()) + list(anchor_meshes.values()) if m.triangles] + meshes = [m for m in zones.values() if m.triangles] # the meshes Rasterise reads, so the underlay registers on the grid if meshes: top = max(v[2] for m in meshes for v in m.vertices) result.lines = [SchematicLine("silhouette", True, tuple((x, y, top) for x, y in contour)) for contour in projector(meshes)] diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index a1d8d163b..173b7f551 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -750,7 +750,7 @@ class LineBuildTests(unittest.TestCase): _, built = self.built(line("outline", ((-1, 0, 1), (1, 0, 1)))) self.assertEqual([l.layer for l in built.lines], ["silhouette", "outline"]) self.assertEqual({p[2] for p in built.lines[0].points}, {3.0}) - self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.1), (-3.0, 5.1)}) + self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.0), (-3.0, 5.0)}) def test_each_layer_carries_its_fixed_style(self): kit = "".join(line(layer) for layer in hs.KIT_LAYERS) @@ -759,11 +759,11 @@ class LineBuildTests(unittest.TestCase): self.assertEqual(len({l.rgba() for l in built.lines}), 5) self.assertEqual(hs.LINE_STYLE["silhouette"], (1.0, 1.0, 1.0, 1.0)) - def test_the_projector_is_given_every_zone_and_anchor_mesh_that_has_geometry(self): + def test_the_projector_is_given_every_zone_mesh_that_has_geometry_and_no_anchor_mesh(self): fixture, given = Fixture(), [] hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: given.append(len(meshes)) or fixture.projector(meshes)) - self.assertEqual(given, [3]) # primary, secondary, emit:Th.A + self.assertEqual(given, [2]) # primary and secondary; the emitter mesh is not part of the layout's top view def test_a_kit_line_off_the_hull_fails_the_over_the_top_view_check_and_nothing_else(self): _, built = self.built(line(points=((0, 0, 2), (20, 0, 2)))) From 03502552373687a9bc67cf4b5c214174bdcd3bfb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:07:35 +0200 Subject: [PATCH 025/230] PilotSeamTests: drop the probe text Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 64eb493d8..3bfeb9f42 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -462,7 +462,7 @@ public sealed partial class RunStartTests var aim = normalize(east.Command.Aim.Value.xz); var toThird = dot(aim, normalize(third.Position.xz - east.Position.xz)); var toWest = dot(aim, normalize(west.Position.xz - east.Position.xz)); - Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest} tracked {east.VisibleEntities.Contains(third)}"); + Assert.True(toThird > toWest + .2f, $"aims at the designated target: toThird {toThird} toWest {toWest}"); } [Fact] From b064051efd2222d3da3b8c74f4db7e7d737ccd8d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:11:03 +0200 Subject: [PATCH 026/230] Retarget test: east is staged knowing the third ship (ruling retarget-test-stages-the-track) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/PilotSeamTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs index 3bfeb9f42..a1d8870dc 100644 --- a/tests/Aetheria.Shared.Tests/PilotSeamTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotSeamTests.cs @@ -454,6 +454,8 @@ public sealed partial class RunStartTests Assert.IsType(SeamState(agent)); Assert.Same(west, east.Target.Value.Entity); + // East is staged knowing the third ship, as an Approach follower knows its anchor: a sensor track by the sensors' own path. + east.EntityInfoGathered[third] = (east.ItemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f; east.SetTarget(TargetRef.None); east.Command.Designate(new TargetRef(third)); agent.Update(SeamDt); From 0cb78d26108ccbbe4681999abb60f5fe3ed7ddc5 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:11:53 +0200 Subject: [PATCH 027/230] hull_source tests: pin style, echo parts, contour area, even-odd and edge tolerance, process refusals, check details Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 164 ++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 173b7f551..928bee976 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -7,6 +7,7 @@ real OpenSCAD and the real language files are exercised by smoke_hull_source.py. hull_source is loaded by path because the package __init__ imports bpy. """ +import dataclasses import importlib.util import json import sys @@ -859,5 +860,168 @@ class ProjectorTests(unittest.TestCase): self.assertEqual(hs.openscad_runner("scad", "hull.scad")("lines"), ("", line())) +class LineStyleTests(unittest.TestCase): + def test_the_style_table_is_the_one_the_underlay_reads(self): + self.assertEqual(hs.LINE_STYLE, { + "silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.75), "fitting": (1.0, 1.0, 1.0, 0.6), + "step": (1.0, 1.0, 1.0, 0.5), "seam": (1.0, 1.0, 1.0, 0.35)}) + self.assertEqual(hs.KIT_LAYERS, ("outline", "step", "seam", "fitting")) + + def test_a_schematic_line_cannot_be_changed_after_it_is_read(self): + read = hs.read_lines(line())[0] + with self.assertRaises(dataclasses.FrozenInstanceError): + read.layer = "step" + + def test_the_four_echo_parts_are_written_as_echo_files_and_geometry_parts_as_off(self): + for part, suffix in (("brief", ".echo"), ("lang", ".echo"), ("anchors", ".echo"), ("lines", ".echo"), ("primary", ".off")): + with self.subTest(part=part): + openscad = FakeOpenScad() + with mock.patch.object(hs.subprocess, "Popen", openscad): + hs.openscad_runner("scad", "hull.scad")(part) + command = openscad.calls[0][0] + self.assertTrue(command[command.index("-o") + 1].endswith(suffix)) + + +class LineRefusalTextTests(unittest.TestCase): + def refusal(self, text): + with self.assertRaises(hs.HullSourceError) as caught: + hs.read_lines(text) + return str(caught.exception) + + def test_each_refusal_says_one_fixed_thing(self): + self.assertEqual(self.refusal(echo("line", ["seam", False])), "A schematic line has the wrong shape") + self.assertEqual(self.refusal(echo("line", ["seam", 1, []])), "A schematic line has the wrong shape") + self.assertEqual(self.refusal(line("nope")), "A schematic line names a layer a kit does not draw") + self.assertEqual(self.refusal(line(points=((0, 0, 0),))), "A schematic line has fewer than two points") + self.assertEqual(self.refusal('ECHO: line = ["seam", false, [[0, 0, NaN], [1, 1, 1]]]\n'), + "A schematic line has a point that is not a finite number") + + +class ContourAreaTests(unittest.TestCase): + @staticmethod + def svg(*rings): + paths = "\n".join("M " + " L ".join(f"{x},{-y}" for x, y in ring) + " z" for ring in rings) + return f'' + + def test_a_contour_is_kept_from_a_quarter_square_metre_whichever_way_it_winds_or_where_it_lies(self): + square = [(3, 7), (3.5, 7), (3.5, 7.5), (3, 7.5)] + triangle = [(10, 10), (11, 10), (10, 11)] + for ring in (square, square[::-1], triangle, triangle[::-1]): + with self.subTest(ring=ring): + self.assertEqual(len(hs.parse_svg_contours(self.svg(ring))), 1) + + def test_a_contour_just_under_a_quarter_square_metre_is_dropped(self): + for ring in ([(3, 7), (3.6, 7), (3.6, 7.4), (3, 7.4)], [(0, 0), (1, 0), (0, 0.49)]): + with self.subTest(ring=ring): + self.assertEqual(hs.parse_svg_contours(self.svg(ring)), []) + + def test_a_contour_of_fewer_than_three_points_is_dropped(self): + self.assertEqual(hs.parse_svg_contours(self.svg([(0, 0), (9, 9)])), []) + + +class OverSilhouetteTests(unittest.TestCase): + TRIANGLE = [(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)] + + def over(self, x, y, contours=None, z=99.0): + return hs.over_silhouette((x, y, z), [self.TRIANGLE] if contours is None else contours) + + def test_inside_and_far_outside(self): + self.assertTrue(self.over(2, 2)) + self.assertFalse(self.over(8, 8)) + self.assertFalse(self.over(-5, 3)) + + def test_the_height_of_a_point_does_not_matter(self): + self.assertTrue(self.over(2, 2, z=-50.0)) + self.assertFalse(self.over(8, 8, z=2.0)) + + def test_a_point_outside_but_within_the_tolerance_of_a_slanted_edge_is_over_the_hull(self): + # the hypotenuse x + y = 10; (5.1, 5.1) is 0.14 m outside it, (5.3, 5.3) is 0.42 m outside + self.assertTrue(self.over(5.1, 5.1)) + self.assertFalse(self.over(5.3, 5.3)) + + def test_a_point_beyond_the_end_of_an_edge_is_measured_to_its_end(self): + self.assertTrue(self.over(10.25, 0)) + self.assertFalse(self.over(10.35, 0)) + self.assertTrue(self.over(-0.25, -0.0)) + self.assertFalse(self.over(0, -0.35)) + + def test_a_ray_through_a_vertex_counts_the_edge_once(self): + diamond = [(0.0, -2.0), (2.0, 0.0), (0.0, 2.0), (-2.0, 0.0)] + self.assertTrue(self.over(0.5, 0, [diamond])) + self.assertTrue(self.over(-0.5, 0, [diamond])) + self.assertFalse(self.over(3.0, 0, [diamond])) + self.assertFalse(self.over(0.0, 3.0, [diamond])) + + def test_the_even_odd_rule_alternates_through_nested_contours(self): + rings = [[(-s, -s), (s, -s), (s, s), (-s, s)] for s in (10.0, 7.0, 4.0, 1.0)] + self.assertTrue(self.over(8.5, 0, rings)) # in the outer ring only + self.assertFalse(self.over(5.5, 0, rings)) # a hole + self.assertTrue(self.over(2.5, 0, rings)) # island in the hole + + def test_three_nested_contours_put_the_centre_back_on_the_hull(self): + rings = [[(-s, -s), (s, -s), (s, s), (-s, s)] for s in (10.0, 6.0, 2.0)] + self.assertTrue(self.over(0.0, 0.0, rings)) + + +class ProcessRefusalTests(unittest.TestCase): + def test_openscad_that_cannot_start_is_refused_in_fixed_words(self): + with mock.patch.object(hs.subprocess, "Popen", side_effect=OSError(SECRET)): + for make in (lambda: hs.openscad_runner("scad", "hull.scad")("primary"), + lambda: hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])): + with self.assertRaises(hs.HullSourceError) as caught: + make() + self.assertEqual(str(caught.exception), "OpenSCAD could not be started") + + def test_openscad_that_does_not_finish_is_killed_and_refused_in_fixed_words(self): + class Slow(FakeOpenScad): + killed = False + + def communicate(self, timeout=None): + if not self.killed: + raise hs.subprocess.TimeoutExpired("scad", timeout) + return "", None + + def kill(self): + self.killed = True + + slow = Slow() + with mock.patch.object(hs.subprocess, "Popen", slow): + with self.assertRaises(hs.HullSourceError) as caught: + hs.openscad_runner("scad", "hull.scad")("primary") + self.assertEqual((str(caught.exception), slow.killed), ("OpenSCAD did not finish a part in time", True)) + + def test_a_projection_openscad_never_wrote_is_refused(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad()): + with self.assertRaises(hs.HullSourceError) as caught: + hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertEqual(str(caught.exception), "OpenSCAD refused a part of the hull source") + + def test_a_refusing_openscad_says_so_in_fixed_words(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad(returncode=2)): + with self.assertRaises(hs.HullSourceError) as caught: + hs.openscad_runner("scad", "hull.scad")("primary") + self.assertEqual(str(caught.exception), "OpenSCAD refused a part of the hull source") + + +class LineCheckDetailTests(unittest.TestCase): + def details(self, kit, projector=Fixture.projector): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", projector) + return {c.name: c.detail for c in built.checks} + + def test_the_details_count_what_the_checks_found(self): + got = self.details(line(points=((0, 0, 2), (20, 0, 2))) + line("step") + line("step")) + self.assertEqual((got[SILHOUETTE], got[LINES_OVER], got[LINES_DISTINCT]), ("1 contours", "1 outside", "1 repeated")) + + def test_a_silhouette_is_not_a_kit_line_so_two_equal_contours_are_not_a_repeat(self): + twice = lambda meshes: Fixture.projector(meshes) * 2 + got = self.details("", twice) + self.assertEqual((got[SILHOUETTE], got[LINES_DISTINCT]), ("2 contours", "0 repeated")) + fixture = Fixture() + built = hs.build(fixture.runner(), fixture.read_library, "Ship", twice) + self.assertEqual(built.failed(), []) + + if __name__ == "__main__": unittest.main() From 970155a8aca6da8b5ad53ba0550a24c56d5078ce Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:12:17 +0200 Subject: [PATCH 028/230] Nakovalnya package rebuilt through the add-on: schematic lines from the kit (silhouette plus 102 kit lines) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Mods/aetheria.nakovalnya/ship.cc | 4 ++-- GameData/Mods/aetheria.nakovalnya/ship.glb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc index 2de9f3458..ebe05cd33 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.cc +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2390998c0f36f647fbd7b12098a726445a747fc2dfd57c0e68b9fcc7e9116338 -size 12701 +oid sha256:87d8cbcca252460d1ec2bfeb895c1447ab185172a4a4449b237a297b83ea5057 +size 40059 diff --git a/GameData/Mods/aetheria.nakovalnya/ship.glb b/GameData/Mods/aetheria.nakovalnya/ship.glb index 27ed4683b..3dac76cc9 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.glb +++ b/GameData/Mods/aetheria.nakovalnya/ship.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c0f2e8bdb820330a1ba2b49c9615484b971f96634e7108ed675e788c38c3c8ba -size 843456 +oid sha256:90a503473e3b12251c225b7fe283b2fc5b45a25e6e47839d1c3d78e3ac61b00c +size 843452 From 4a6ddaa413b5a3ae803baa5f814d53f389033bdf Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:17:00 +0200 Subject: [PATCH 029/230] hull_source tests: a non-UTF-8 byte in the drawing, an unreadable number Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 928bee976..48a401404 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -1023,5 +1023,25 @@ class LineCheckDetailTests(unittest.TestCase): self.assertEqual(built.failed(), []) +class BytesOpenScad(FakeOpenScad): + """Writes raw bytes where -o points: an SVG with a byte that is not UTF-8 in its title.""" + + def __call__(self, command, **kwargs): + self.calls.append((command, kwargs.get("env"))) + Path(command[command.index("-o") + 1]).write_bytes(bytes([255]) + SVG.encode("utf-8")) + return self + + +class SilhouetteReadTests(unittest.TestCase): + def test_a_drawing_with_a_byte_that_is_not_utf8_still_reads(self): + with mock.patch.object(hs.subprocess, "Popen", BytesOpenScad()): + self.assertEqual(len(hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])), 2) + + def test_a_number_that_is_not_a_number_is_refused_in_fixed_words(self): + with self.assertRaises(hs.HullSourceError) as caught: + hs.parse_svg_contours('') + self.assertEqual(str(caught.exception), "OpenSCAD wrote a silhouette that is not readable") + + if __name__ == "__main__": unittest.main() From c1d55952eb0730228fe0578fc4b732ba3dbb7398 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:31:11 +0200 Subject: [PATCH 030/230] Smoke: Build records the kit lines and retiring the hull source deletes them Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index c60967253..7084274bb 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -493,7 +493,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett report = scene.aetheria_package_report require(result == {"CANCELLED"} and "The package has no product" in report, f"Package took a hull with no zones and no art: {report!r} {message!r}") require((package / "ship.glb").read_bytes() == glb_before, "A refused Package changed ship.glb") - require("aetheria.hull_maker" in collection and "aetheria.hull_built" in collection and source.is_file() == (number % 2 == 1), + require("aetheria.hull_maker" in collection and "aetheria.hull_built" in collection and "aetheria.hull_lines" in collection and source.is_file() == (number % 2 == 1), "A refused Package changed Build's records or hull.scad") print(f"PASS {number}.9a Build's zones deleted with nothing in their place stay under the guard") art_mesh = bpy.data.meshes.new("author hull") @@ -510,7 +510,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett report = scene.aetheria_package_report require(result == {"FINISHED"} and "retired the agent hull source" in report, f"Package did not retire the hull source: {report!r} {message!r}") require(not source.exists(), "hull.scad is still there after the author's art was packaged") - require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection, + require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection and "aetheria.hull_lines" not in collection, "The record of Build's objects or its maker outlived hull.scad") require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input") select(box) From 2b053b34c2ea7500f6cac534002824c2b282bd4b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:52:17 +0200 Subject: [PATCH 031/230] Smoke: a retired hull's lines are not hers, and Capture writes the kit's lines in Package's frame (red) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 7084274bb..a3676b0c7 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -403,6 +403,21 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require(result == {"FINISHED"} and len(ship_lines(ship_cc)) == len(packaged), f"Without Grease Pencil the kit's lines are back: {message!r}") print(f"PASS {number}.5b the kit's lines ship, her Grease Pencil wins, two are refused") + # Capture's kit branch writes what Package wrote, and also when Save Layout has moved the centre of mass since the last + # Package (here: Ship Root is off the origin), because Capture frames the ship as Package does. + shipped = ship_lines(ship_cc) + select(box) + result, message = run(bpy.ops.aetheria.capture_ship_lines) + require(result == {"FINISHED"} and same_lines(ship_lines(ship_cc), shipped), f"Capture did not write the kit's lines as Package did: {message!r}") + root.location.x += 1.7 + root.location.y -= 0.9 + bpy.context.view_layer.update() + select(box) + result, message = run(bpy.ops.aetheria.capture_ship_lines) + require(result == {"FINISHED"} and same_lines(ship_lines(ship_cc), shipped), + f"Capture, with Ship Root off its Package frame, wrote lines out of the packaged model's frame: {message!r}") + print(f"PASS {number}.5c Capture writes the kit's lines in Package's frame") + # What Package did to the built objects is not an edit: Build still replaces them. select(box) result, message = run(bpy.ops.aetheria.build_hull_source) @@ -513,6 +528,15 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection and "aetheria.hull_lines" not in collection, "The record of Build's objects or its maker outlived hull.scad") require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input") + # The retired hull's kit lines are not hers: the slot is empty (Studio draws no underlay, the preview no line mesh), + # Capture has nothing to capture, and a second Package leaves it empty. + require(ship_lines(ship_cc) == [], f"The retired hull's {len(ship_lines(ship_cc))} lines ship over her art") + select(box) + result, message = run(bpy.ops.aetheria.capture_ship_lines) + require(result == {"CANCELLED"} and ship_lines(ship_cc) == [], f"Capture wrote lines for a hull with no source: {message!r}") + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"} and ship_lines(ship_cc) == [], f"A second Package brought lines back: {message!r} {ship_lines(ship_cc)[:1]}") select(box) result, message = run(bpy.ops.aetheria.build_hull_source) require(result == {"CANCELLED"} and bpy.data.objects.get("author hull") is art, f"Build ran after takeover: {message!r}") @@ -608,6 +632,13 @@ def ship_lines(ship_path): return sys.modules["aetheria_ships"].read(ship_path, PACKAGES).ship.body[sys.modules["aetheria_ships"].ship_cc.SCHEMATIC_LINES_SLOT] +def same_lines(a, b, tolerance=1e-4): + """Whether two SchematicLines slots hold the same rows: layer, cyclic, colour and every point within tolerance.""" + return len(a) == len(b) and all( + x[0] == y[0] and x[5] == y[5] and len(x[2]) == len(y[2]) and approx(x[6], y[6], tolerance) + and all(abs(p - q) <= tolerance for p, q in zip(x[2], y[2])) for x, y in zip(a, b)) + + def grease_pencil(collection, name, points): """A Grease Pencil object in the collection holding one stroke, layer named 'hers'.""" data = bpy.data.grease_pencils.new(name) From 87dc0f013f4efb083be86e6341399e1df1161be9 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:54:13 +0200 Subject: [PATCH 032/230] Capture Ship Lines frames the ship as Package does, so kit and Grease Pencil lines land in ship.glb's frame Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 08daa095c..d12c19fc8 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -545,6 +545,10 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): cultlib = _brokkr_cultlib(context) if read(path, cultlib).ship.body[0] != collection["aetheria.id"]: raise ValueError("The bound collection ID does not match its .cc ship ID") + saved_shape, _, _ = read_layout(path, cultlib) + root = _ship_root(collection) + if root is not None: # the frame Package would write the model in, so the lines match ship.glb + _frame_ship(context, collection, root, path, saved_shape) lines = _schematic_lines(context, collection) if lines is None: raise ValueError("No schematic lines were captured; the ship record was not changed") From dab415ad872a3b956adebdf24e2ebf859bba72dd Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:55:50 +0200 Subject: [PATCH 033/230] Package: her art's takeover leaves no retired hull lines in the .cc (state decides the source; the slot is cleared) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 13 ++++++++----- tools/blender/aetheria_ships/ship_cc.py | 10 ++++++++++ tools/blender/tests/test_ship_cc.py | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index d12c19fc8..821529244 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -23,7 +23,7 @@ import numpy from mathutils import Matrix, Vector from . import hull_grid, hull_source -from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, +from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, clear_lines, decode_hardpoint, encode_hardpoint, layout_revision, read, read_hull_type, read_layout, replace_layout, replace_lines, product_makers, replace_visual, valid_ship_id) @@ -1639,9 +1639,10 @@ def _build_hull_source(context, collection): def _schematic_lines(context, collection): """The ShipAuthoring.SchematicLines rows Package and Capture write, and the only source of them: her Grease Pencil - object if the collection has one (two or more are refused), else the lines Build recorded from the hull source's kit, - mapped through Ship Root's matrix_world (so after _frame_ship, the frame her strokes are captured in), else None and - the slot is left as it is.""" + object if the collection has one (two or more are refused), else the lines Build recorded from the hull source's kit + while the hull is still Build's (not _hull_state art), mapped through Ship Root's matrix_world (so after _frame_ship, + the frame her strokes are captured in), else None and the slot is left as it is. Package alone clears the slot, at + the moment her art takes over.""" pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] if len(pencils) > 1: raise ValueError("Keep one Grease Pencil object: it is the ship's schematic") @@ -1650,7 +1651,7 @@ def _schematic_lines(context, collection): return capture_grease_pencil(pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, evaluated=scene.aetheria_capture_evaluated_lines) record = collection.get(HULL_LINES) - if record is None: + if record is None or _hull_state(collection) == HULL_ART: # her art took over: Build's lines are a retired hull's return None root = _ship_root(collection) world = root.matrix_world if root is not None else Matrix.Identity(4) @@ -1773,6 +1774,8 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): lines = _schematic_lines(context, collection) if lines is not None: replace_lines(path, cultlib, lines) + elif _hull_state(collection) == HULL_ART: # the lines in the slot are the retired hull's, not hers + clear_lines(path, cultlib) code, message = _aetherdb(context, "validate", path, ship_cc=path) except _EXPECTED as exc: scene.aetheria_package_report = f"Package failed: {_failure_text(exc)}" diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index f3742dd4f..dd3ca78be 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -179,6 +179,16 @@ def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> i return len(lines) +def clear_lines(path: str, cultlib_packages: str) -> int: + """Empties the line slot and nothing else; returns how many lines it held. replace_lines refuses an empty capture so a + failed capture never wipes a ship; this is the one deliberate way to leave a ship without lines.""" + file = read(path, cultlib_packages) + held = len(file.ship.body[SCHEMATIC_LINES_SLOT] or []) + file.ship.body[SCHEMATIC_LINES_SLOT] = [] + file.store.push(_stamped(file.ship, file.msgpack)) + return held + + def replace_visual(path: str, cultlib_packages: str, expected_id: str, model_asset: str, anchors: list[list[Any]]) -> int: """Writes the visual's model asset and anchor list (ShipAuthoring slots 2 and 3) and nothing else. Each anchor row is diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index ac01b2faa..0b4da5677 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -344,6 +344,22 @@ class ReplaceLinesTests(ShipFileCase): if slot != ship_cc.SCHEMATIC_LINES_SLOT: self.assertEqual(before[slot], after[slot], f"slot {slot} changed") + def test_clear_lines_empties_only_the_line_slot(self): + ship_cc.replace_lines(self.path, PACKAGES, LINES) + before = self.ship() + self.assertEqual(len(LINES), ship_cc.clear_lines(self.path, PACKAGES)) + after = self.ship() + self.assertEqual([], after[ship_cc.SCHEMATIC_LINES_SLOT]) + for slot in range(len(before)): + if slot != ship_cc.SCHEMATIC_LINES_SLOT: + self.assertEqual(before[slot], after[slot], f"slot {slot} changed") + + def test_replace_lines_still_refuses_an_empty_capture(self): + ship_cc.replace_lines(self.path, PACKAGES, LINES) + with self.assertRaises(ValueError): + ship_cc.replace_lines(self.path, PACKAGES, []) + self.assertEqual(LINES, self.ship()[ship_cc.SCHEMATIC_LINES_SLOT]) + def test_a_save_refreshes_the_stored_at_of_the_record_it_writes_and_only_that_one(self): stale = "2000-01-01T00:00:00+00:00" envelopes = self.envelopes() From 71b12d594b7d7498c7c23090b85ae9eb7dd38c77 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:09:04 +0200 Subject: [PATCH 034/230] Kit lines tests: a line drawn outside its geometry's frame fails Build; side-facing fittings emit no line (red) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 62 +++++++++++++++- tools/blender/tests/test_kit_openscad.py | 92 ++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 tools/blender/tests/test_kit_openscad.py diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 48a401404..46e153a3f 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -51,6 +51,19 @@ def brief(faction="zhestokost", donor="", length=(8, 14), triangles=(10, 100)): return echo("brief", [faction, donor, "tender", list(length), list(triangles), ["a read"]]) +def marks_off(points, size=0.002): + """An OFF text of one tiny cube per point, as the kit's marks part is.""" + rows, faces = [], [] + for i, (x, y, z) in enumerate(points): + h = size / 2 + rows += [(x + dx * h, y + dy * h, z + dz * h) for dz in (-1, 1) for dy in (-1, 1) for dx in (-1, 1)] + b = 8 * i + faces += [(b, b + 1, b + 3, b + 2), (b + 4, b + 6, b + 7, b + 5), (b, b + 2, b + 6, b + 4), + (b + 1, b + 5, b + 7, b + 3), (b, b + 4, b + 5, b + 1), (b + 2, b + 3, b + 7, b + 6)] + return "\n".join(["OFF", f"{len(rows)} {len(faces)} 0"] + [" ".join(map(repr, r)) for r in rows] + + ["4 " + " ".join(map(str, f)) for f in faces]) + "\n" + + class Fixture: """A hull source as the fake runner sees it: part -> (OFF, ECHO). library holds the ECHO text of each faction's own language file; the hull's own `lang` echo is the files it includes, unless hull_lang says otherwise.""" @@ -70,6 +83,7 @@ class Fixture: "emit:Th.A": (box(0.5, 1.5, 5, 5.1, 0.5, 1.5), ""), "lines": ("", ""), } + self.marks = None # the "marks" part when a test sets one; else a mark at each kit line's first point @staticmethod def projector(meshes): @@ -81,7 +95,13 @@ class Fixture: return [[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]] def runner(self): - return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.parts[part] + return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.mark_part() if part == "marks" else self.parts[part] + + def mark_part(self): + """What the kit's lp_line draws in the geometry's own frame: a tiny cube at each line's first point.""" + if self.marks is not None: + return self.marks + return (marks_off([l.points[0] for l in hs.read_lines(self.parts["lines"][1])]), "") def read_library(self, faction): if faction not in self.library: @@ -680,6 +700,7 @@ class RefusalTests(unittest.TestCase): LINES_OVER = "every kit line lies over the hull's top view" LINES_DISTINCT = "no two kit lines are the same" +LINES_FRAME = "every kit line is drawn in the frame of its geometry" SILHOUETTE = "the top view yields a silhouette" @@ -687,6 +708,45 @@ def line(layer="seam", points=((-1, -1, 2), (1, 1, 2)), cyclic=False): return echo("line", [layer, cyclic, [list(p) for p in points]]) +class FrameCheckTests(unittest.TestCase): + """The kit draws a mark where each line starts, in the frame the geometry is drawn in; the line is echoed in the frame + lp_at tracks. A part placed by anything but lp_at has its mark and its line in different places.""" + + def built(self, kit, marks=None): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + if marks is not None: + fixture.marks = (marks_off(marks), "") + return fixture.build() + + def test_marks_where_the_lines_start_pass_and_the_check_is_listed(self): + built = self.built(line() + line("step", ((0, 0, 2), (1, 1, 2)))) + self.assertEqual(built.failed(), []) + self.assertIn(LINES_FRAME, names(built)) + + def test_a_mark_three_metres_from_its_line_fails_the_frame_check_and_nothing_else(self): + built = self.built(line(points=((-1, -1, 2), (1, 1, 2))), marks=[(-1, 2, 2)]) + self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) + + def test_a_line_with_no_mark_fails(self): + built = self.built(line() + line("step", ((0, 0, 2), (1, 1, 2))), marks=[(-1, -1, 2)]) + self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) + + def test_a_mark_with_no_line_fails(self): + built = self.built(line(), marks=[(-1, -1, 2), (2, 2, 2)]) + self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) + + def test_a_mark_a_millimetre_off_is_the_same_place(self): + self.assertEqual(self.built(line(), marks=[(-1, -1, 2.001)]).failed(), []) + + def test_a_source_with_no_lines_and_no_marks_passes(self): + self.assertEqual(self.built("", marks=[]).failed(), []) + + def test_the_detail_counts_the_lines_and_marks_that_disagree(self): + built = self.built(line(), marks=[(5, 5, 2)]) + self.assertEqual({c.name: c.detail for c in built.checks}[LINES_FRAME], "1 lines and 1 marks disagree") + + class LineReadingTests(unittest.TestCase): def test_lines_are_read_in_echo_order_with_their_layer_flag_and_points(self): text = line("step", ((0, 0, 1), (1, 0, 1), (1, 1, 1)), True) + line("fitting") diff --git a/tools/blender/tests/test_kit_openscad.py b/tools/blender/tests/test_kit_openscad.py new file mode 100644 index 000000000..6867b429f --- /dev/null +++ b/tools/blender/tests/test_kit_openscad.py @@ -0,0 +1,92 @@ +"""The Zhestokost kit through the real OpenSCAD: where its schematic lines are drawn relative to its geometry, and which +frames draw none. Skipped unless SMOKE_OPENSCAD names the OpenSCAD executable (nightly with the Manifold backend). + + SMOKE_OPENSCAD=\\openscad.com python -m unittest discover -s tools/blender/tests -p "test_kit_openscad.py" + +hull_source is loaded by path because the package __init__ imports bpy. +""" + +import importlib.util +import os +import sys +import tempfile +import unittest +from pathlib import Path + +_spec = importlib.util.spec_from_file_location( + "hull_source", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_source.py") +hs = importlib.util.module_from_spec(_spec) +sys.modules["hull_source"] = hs +_spec.loader.exec_module(hs) + +OPENSCAD = os.environ.get("SMOKE_OPENSCAD") +REPO = Path(__file__).resolve().parents[3] +NAKOVALNYA = REPO / "GameData" / "Mods" / "aetheria.nakovalnya" / "hull.scad" +FRAME = "every kit line is drawn in the frame of its geometry" + +# Placement drifts (finding line-drift-inside-footprint): a raw translate or rotate around a part that draws lines, not an +# lp_at. Each moves the geometry and leaves the line where lp_at's frame says. +DRIFTS = { + "spine mass 3 m aft": ("zh_mass(T2);", "translate([0, 3, 0]) zh_mass(T2);"), + "radiator troughs 4 m forward": ("for (s = [-1, 1]) zh_on_top(M, RAD_Y, s*RAD_U) zh_rad_trough(RAD_W, RAD_L, 0.45);", + "translate([0, -4, 0]) for (s = [-1, 1]) zh_on_top(M, RAD_Y, s*RAD_U) zh_rad_trough(RAD_W, RAD_L, 0.45);"), + "apron reveals 3 m aft": ("for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0);", + "translate([0, 3, 0]) for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0);"), + "flak ring rotated 18 degrees": ("for (f = FLAK) lp_at(t=[f[0], f[1], DECK - ZH_SINK]) flak(f[2]);", + "rotate([0, 0, 18]) for (f = FLAK) lp_at(t=[f[0], f[1], DECK - ZH_SINK]) flak(f[2]);"), +} + +# A fitting on every frame the kit mates to. Only the dorsal one faces up, so only it is seen from above. +HEAD = """include +part = "primary"; +M = [zh_s(-16, 32, 5.0, -2.25), zh_s(-15, 34, 5.5, -2.5), zh_s(5, 33, 5.5, -2.5), zh_s(14, 27, 5.5, -2.5)]; +""" +SIDEWAYS = { + "wall": "zh_on_wall(M, 0, 1, 0.45) zh_hatch(2.6, 3.0);", + "belt": "zh_on_belt(M, 0, 1, 0.5) zh_hatch(2.6, 3.0);", + "belly": "zh_on_belly(M, 0, 0) zh_hatch(2.6, 3.0);", + "stern": "zh_on_stern(M, 0, 0.5) zh_plate(4.0, 1.4, 0.3);", + "nose": "zh_on_nose(M, 0, 0.5) zh_plate(4.0, 1.4, 0.3);", +} +DORSAL = "zh_on_top(M, 0, 0) zh_hatch(2.6, 3.0);" + + +@unittest.skipUnless(OPENSCAD, "SMOKE_OPENSCAD names no OpenSCAD") +class KitThroughOpenScad(unittest.TestCase): + def build(self, source_text): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / "hull.scad" + source.write_text(source_text, encoding="utf-8") + return hs.build(hs.openscad_runner(OPENSCAD, source), hs.language_library(OPENSCAD), "Ship", + hs.openscad_projector(OPENSCAD)) + + def lines(self, body): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / "hull.scad" + source.write_text(HEAD + body, encoding="utf-8") + return hs.read_lines(hs.openscad_runner(OPENSCAD, source)("lines")[1]) + + def test_the_nakovalnya_draws_every_line_in_the_frame_of_its_geometry(self): + built = self.build(NAKOVALNYA.read_text(encoding="utf-8")) + self.assertEqual([c.name for c in built.failed()], []) + self.assertIn(FRAME, [c.name for c in built.checks]) + + def test_a_placement_that_drifts_from_its_lines_fails_the_frame_check(self): + text = NAKOVALNYA.read_text(encoding="utf-8") + for label, (old, new) in DRIFTS.items(): + with self.subTest(label): + self.assertEqual(text.count(old), 1) + self.assertIn(FRAME, [c.name for c in self.build(text.replace(old, new)).failed()]) + + def test_a_fitting_on_a_frame_a_top_view_cannot_see_emits_no_line(self): + for label, body in SIDEWAYS.items(): + with self.subTest(label): + self.assertEqual(self.lines(body), []) + + def test_the_gate_is_the_frame_and_not_the_fitting(self): + self.assertEqual(len(self.lines(DORSAL)), 1) + self.assertEqual(len(self.lines(DORSAL + " ".join(SIDEWAYS.values()))), 1) + + +if __name__ == "__main__": + unittest.main() From 98b4f4f9469ffd9ba5b8fda230af4700e01b066e Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:11:35 +0200 Subject: [PATCH 035/230] Build checks that every kit line is drawn in the frame of its geometry: lp_line's marks part meets its lines part Co-Authored-By: Claude Sonnet 5.5 --- .../aetheria_ships/factions/lowpoly.scad | 10 ++++-- tools/blender/aetheria_ships/hull_source.py | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/tools/blender/aetheria_ships/factions/lowpoly.scad b/tools/blender/aetheria_ships/factions/lowpoly.scad index ef3063c6c..23e82918b 100644 --- a/tools/blender/aetheria_ships/factions/lowpoly.scad +++ b/tools/blender/aetheria_ships/factions/lowpoly.scad @@ -33,7 +33,13 @@ module lp_at(t=[0,0,0], r=[0,0,0], mx=false) { multmatrix(M) children(); } // One schematic line: layer (outline, step, seam or fitting), points in the local frame, cyclic when it closes. +// Part "marks" draws a LP_MARK cube at the line's first point through the CTM, the frame the geometry is drawn in, where +// "lines" echoes the same point through $lp_m; hull_source compares the two, so a part placed by anything but lp_at +// (a raw translate, rotate or mirror) shows as a line away from its geometry. +LP_MARK = 0.002; module lp_line(layer, pts, cyclic=false) { - if (!is_undef(part) && part == "lines" && ($lp_m * [0, 0, 1, 0])[2] > 0.5) - echo(line = [layer, cyclic, [for (p = pts) lp_ap($lp_m, p)]]); + if (!is_undef(part) && ($lp_m * [0, 0, 1, 0])[2] > 0.5) { + if (part == "lines") echo(line = [layer, cyclic, [for (p = pts) lp_ap($lp_m, p)]]); + if (part == "marks") translate(pts[0]) cube(LP_MARK, center=true); + } } diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index 2689a7230..f004486e9 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -63,6 +63,7 @@ LINE_STYLE = {"silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.7 MIN_SILHOUETTE_AREA = 0.25 # m2, below which a projected contour is a sliver, not the hull LINE_EDGE_TOLERANCE = 0.3 # m, how far outside the silhouette's edge a kit line's point may lie DUPLICATE_CELL = 0.001 # m, kit lines whose points all agree to this are the same line +MARK_TOLERANCE = 0.01 # m, how far a mark of the kit may lie from the start of its line # A projector answers a list of Meshes with the contours (lists of (x, y)) of their top-view union. Projector = Callable[[list], "list[list[tuple]]"] @@ -347,6 +348,35 @@ def _triangles_xy(mesh): return [tuple(mesh.vertices[v][:2] for v in tri) for tri in mesh.triangles] +def mark_centres(mesh): + """The centre of each connected piece of mesh: the kit's "marks" part draws one tiny cube per line start.""" + parent = list(range(len(mesh.vertices))) + + def root(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + for triangle in mesh.triangles: + for corner in triangle[1:]: + parent[root(corner)] = root(triangle[0]) + pieces = {} + for i in {corner for triangle in mesh.triangles for corner in triangle}: + pieces.setdefault(root(i), []).append(mesh.vertices[i]) + return [tuple((min(v[a] for v in vs) + max(v[a] for v in vs)) / 2 for a in range(3)) for vs in pieces.values()] + + +def _frame_disagreements(kit, marks): + """(lines with no mark at their first point, marks that start no line). The kit echoes a line in the frame lp_at tracks + and draws its mark in the frame the geometry is drawn in; they agree unless a part was placed by anything but lp_at.""" + starts = [line.points[0] for line in kit] + centres = mark_centres(marks) + near = lambda a, b: all(abs(x - y) <= MARK_TOLERANCE for x, y in zip(a, b)) + return (sum(not any(near(start, c) for c in centres) for start in starts), + sum(not any(near(c, start) for start in starts) for c in centres)) + + def _language(library, faction): """The language of factions/.scad, which owns it: exactly one echo, naming that faction.""" (language,) = _exactly_one(read_languages(library(faction)), "language") @@ -421,6 +451,9 @@ def build(runner, library, hull_type, projector): result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] result.checks += _checks(result, own, judge) + unmarked, stray = _frame_disagreements(kit_lines, parse_off(runner("marks")[0])) + result.checks.append(Check("every kit line is drawn in the frame of its geometry", not (unmarked or stray), + f"{unmarked} lines and {stray} marks disagree")) return result From 8d58bca27a77a1a7086923e6b58cc73621027491 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:11:38 +0200 Subject: [PATCH 036/230] Nakovalnya: casemate guns and plate seams emit their lines from the frame that draws them; the copied offsets are gone (geometry and lines unchanged) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Mods/aetheria.nakovalnya/hull.scad | 28 +++++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/hull.scad b/GameData/Mods/aetheria.nakovalnya/hull.scad index 062285157..66353ca69 100644 --- a/GameData/Mods/aetheria.nakovalnya/hull.scad +++ b/GameData/Mods/aetheria.nakovalnya/hull.scad @@ -52,16 +52,21 @@ zh_mass(KEEL, lines=false); // under the hull: no top view for (s = [-1, 1]) lp_at(t=[s*DX, 0, 0]) zh_mass(DRV, "secondary"); // ---- the casemate guns: lighter cast-steel barrels out of charcoal mantlets ----------------------------------------- +MANTLET = [4.0, 1.2, 3.4]; module casemate_gun() { - zh_z("secondary") { - translate([0, GY - 0.3, GZ]) cbox([4.0, 1.2, 3.4], 0.2); // mantlet - translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR*1.55, 1.8, 8); // barrel collar - translate([0, GY - 0.7 - BL, GZ]) cbox([BR*2.7, 1.6, BR*2.7], 0.12); // muzzle block + // mantlet frame: its fitting outline is drawn in the frame the mantlet is + lp_at(t=[0, GY - 0.3, GZ]) { + zh_z("secondary") cbox(MANTLET, 0.2); + lp_line("fitting", zh_rect(MANTLET.x, MANTLET.y, MANTLET.z/2), true); + } + // barrel frame: local +Y runs forward from the collar; the barrel's top line is drawn in the frame the barrel is + lp_at(t=[0, GY - 0.7, GZ], r=[0, 0, 180]) { + zh_z("secondary") tube(BR*1.55, 1.8, 8); // barrel collar + zh_z("primary") tube(BR, BL, 8); + zh_z("secondary") translate([0, BL, 0]) cbox([BR*2.7, 1.6, BR*2.7], 0.12); // muzzle block + zh_z("recess") translate([0, BL + 0.81, 0]) cube([BR*1.2, 0.05, BR*1.2], center=true); + lp_line("fitting", [[0, 0, BR], [0, BL, BR]]); } - zh_z("primary") translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR, BL, 8); - zh_z("recess") translate([0, GY - 0.7 - BL - 0.81, GZ]) cube([BR*1.2, 0.05, BR*1.2], center=true); - lp_line("fitting", [for (p = zh_rect(4.0, 1.2, GZ + 1.7)) p + [0, GY - 0.3, 0]], true); - lp_line("fitting", [[0, GY - 0.7, GZ + BR], [0, GY - 0.7 - BL, GZ + BR]]); } for (x = CX) lp_at(t=[x, 0, 0]) casemate_gun(); for (x = CX) lp_at(t=[x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); @@ -90,9 +95,10 @@ zh_on_top(T2, -3.6, 0) zh_serial(0.7, 4); // ---- dorsal magazine hatches: two rows of four on the mid-deck, clear of the rim ----------------------------------- for (y = [-8.0, -3.4, 1.2, 5.6], u = [-0.78, 0.78]) zh_on_top(T1, y, u) zh_hatch(2.8, 3.0, dogs=false); -for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) { // plate seams - zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); - lp_line("seam", [[-4.2, 0, 0.16], [4.2, 0, 0.16]]); +PLATE_SEAM = [8.4, 0.5, 0.2]; +for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) lp_at(t=[0, 0, 0.06]) { // plate seams + zh_z("secondary") cube(PLATE_SEAM, center=true); + lp_line("seam", [[-PLATE_SEAM.x/2, 0, PLATE_SEAM.z/2], [PLATE_SEAM.x/2, 0, PLATE_SEAM.z/2]]); } // Feed collar for an Oboz: a lighter cast-steel flange ring standing proud of the flank round the kit collar. From 66c38d9506caf6b45b3d98bc6751f3d3d8ee0f16 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:17:40 +0200 Subject: [PATCH 037/230] Build asks for the marks part only when the kit drew lines (OpenSCAD refuses a part that draws nothing); hull_source documents marks Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 8 ++++++-- tools/blender/tests/test_hull_source.py | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index f004486e9..2d6d0ca16 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -15,12 +15,14 @@ with it (a hull that redefines or shadows an included language fails a check): frame (lowpoly.scad lp_line under lp_at frames); layer is one of KIT_LAYERS the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor + marks OFF, a tiny cube at the first point of each kit line, drawn through the frame the geometry is drawn in (lp_line) Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone mesh, the geometry Rasterise reads, read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with -lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. +lp_at so its line is emitted in the frame the geometry is, and Build checks it: a line whose mark (the marks part) lies elsewhere +was placed by something else. A source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back. @@ -451,7 +453,9 @@ def build(runner, library, hull_type, projector): result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] result.checks += _checks(result, own, judge) - unmarked, stray = _frame_disagreements(kit_lines, parse_off(runner("marks")[0])) + # A source with no kit lines has no marks, and OpenSCAD refuses to export a part that drew nothing. + marks = parse_off(runner("marks")[0]) if kit_lines else Mesh([], []) + unmarked, stray = _frame_disagreements(kit_lines, marks) result.checks.append(Check("every kit line is drawn in the frame of its geometry", not (unmarked or stray), f"{unmarked} lines and {stray} marks disagree")) return result diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 46e153a3f..e77ec11b2 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -739,8 +739,13 @@ class FrameCheckTests(unittest.TestCase): def test_a_mark_a_millimetre_off_is_the_same_place(self): self.assertEqual(self.built(line(), marks=[(-1, -1, 2.001)]).failed(), []) - def test_a_source_with_no_lines_and_no_marks_passes(self): - self.assertEqual(self.built("", marks=[]).failed(), []) + def test_a_source_with_no_lines_asks_for_no_marks_because_openscad_refuses_a_part_that_draws_nothing(self): + fixture = Fixture() + runner = fixture.runner() + asked = [] + built = hs.build(lambda part: asked.append(part) or runner(part), fixture.read_library, "Ship", fixture.projector) + self.assertNotIn("marks", asked) + self.assertEqual(built.failed(), []) def test_the_detail_counts_the_lines_and_marks_that_disagree(self): built = self.built(line(), marks=[(5, 5, 2)]) From d3cd7813d7458a4825a44a093248db2e015d46db Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:31:08 +0200 Subject: [PATCH 038/230] Catalog subtraction: commodity records lose DemandProfile and both stored categories; the trade menu stops filtering by them Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Enums.cs | 21 ----- Assets/Scripts/ServerShared/ItemData.cs | 10 +- Assets/Scripts/UI/Menu/TradeMenu.cs | 91 +------------------ .../CommodityRecordsTests.cs | 34 +++++++ 4 files changed, 39 insertions(+), 117 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs diff --git a/Assets/Scripts/ServerShared/Enums.cs b/Assets/Scripts/ServerShared/Enums.cs index 63d2674db..0a6449dc1 100644 --- a/Assets/Scripts/ServerShared/Enums.cs +++ b/Assets/Scripts/ServerShared/Enums.cs @@ -152,27 +152,6 @@ public enum MegaPlacementType Isolated = 4 } -public enum SimpleCommodityCategory -{ - Minerals = 0, - Metals = 1, - Alloys = 2, - Compounds = 3, - Organics = 4, - Ammo = 5, - Consumer = 6 -} - -public enum CompoundCommodityCategory -{ - Wearables = 0, - Consumables = 1, - Luxuries = 2, - Tools = 3, - Manufacturing = 4, - Assemblies = 5 -} - public enum LocationType { Station, diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index ca1484e36..dcb38456a 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -322,13 +322,11 @@ public abstract class ItemData [CultDocument("aetheria.simplecommoditydata", "1"), Inspectable, MessagePackObject] public class SimpleCommodityData : ItemData { - // MessagePack keys 6, 7, 8 and 11 belonged to removed resource-distribution fields; do not reuse them. + // MessagePack keys 6, 7, 8, 10 and 11 belonged to removed resource-distribution fields; do not reuse them. [Inspectable, JsonProperty("maxStackSize"), Key(9)] public int MaxStack = 10; - [Inspectable, JsonProperty("category"), Key(10)] - public SimpleCommodityCategory Category; } [JsonObject(MemberSerialization.OptIn)] @@ -352,11 +350,7 @@ public class ItemRole [CultDocument("aetheria.compoundcommoditydata", "1"), Inspectable, MessagePackObject] public class CompoundCommodityData : CraftedItemData { - [Inspectable, CultReference(typeof(PersonalityAttribute), many: true), JsonProperty("demandProfile"), Key(10)] - public Dictionary, float> DemandProfile = new Dictionary, float>(); - - [Inspectable, JsonProperty("category"), Key(11)] - public CompoundCommodityCategory Category; + // MessagePack keys 10 and 11 belonged to the removed demand weights and classification; do not reuse them. } [CultDocument("aetheria.consumableitemdata", "1"), Inspectable, MessagePackObject] diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 1adadcf15..3d448f64d 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -28,8 +28,6 @@ public class TradeMenu : MonoBehaviour // False lists the station's wares to buy; true lists the selected bay's goods to sell. private bool _selling; private (ItemFilter filter, HardpointType type) _hardpointFilter; - private (ItemFilter filter, SimpleCommodityCategory type) _commodityFilter; - private (ItemFilter filter, CompoundCommodityCategory type) _compoundCommodityFilter; private List<(ItemFilter filter, Type type)> _behaviorFilters = new List<(ItemFilter filter, Type type)>(); public EquippedCargoBay Inventory { get; set; } @@ -62,14 +60,6 @@ public class TradeMenu : MonoBehaviour if (_hardpointFilter.filter != null) hardpointTypes = hardpointTypes.Where(x => x != _hardpointFilter.type); - IEnumerable commodityTypes = (SimpleCommodityCategory[]) Enum.GetValues(typeof(SimpleCommodityCategory)); - if (_commodityFilter.filter != null) - commodityTypes = commodityTypes.Where(x => x != _commodityFilter.type); - - IEnumerable compoundCommodityTypes = (CompoundCommodityCategory[]) Enum.GetValues(typeof(CompoundCommodityCategory)); - if (_compoundCommodityFilter.filter != null) - compoundCommodityTypes = compoundCommodityTypes.Where(x => x != _compoundCommodityFilter.type); - ContextMenu.AddDropdown("Gear Type", hardpointTypes .Select(x => (Enum.GetName(typeof(HardpointType), x), () => { @@ -82,75 +72,10 @@ public class TradeMenu : MonoBehaviour Populate(); }; } - if(_commodityFilter.filter) - { - _commodityFilter.filter.GetComponent().ReturnToPool(); - _commodityFilter.filter = null; - } - if(_compoundCommodityFilter.filter) - { - _compoundCommodityFilter.filter.GetComponent().ReturnToPool(); - _compoundCommodityFilter.filter = null; - } - _hardpointFilter.filter.Label.text = $"Hardpoint: {Enum.GetName(typeof(HardpointType), x)}"; _hardpointFilter.type = x; Populate(); }, true))); - ContextMenu.AddDropdown("Simple Commodity", commodityTypes - .Select(x => (Enum.GetName(typeof(SimpleCommodityCategory), x), () => - { - if (!_commodityFilter.filter) - { - _commodityFilter.filter = FilterPrototype.Instantiate(); - _commodityFilter.filter.OnDisable += () => - { - _commodityFilter.filter = null; - Populate(); - }; - } - if(_hardpointFilter.filter) - { - _hardpointFilter.filter.GetComponent().ReturnToPool(); - _hardpointFilter.filter = null; - } - if(_compoundCommodityFilter.filter) - { - _compoundCommodityFilter.filter.GetComponent().ReturnToPool(); - _compoundCommodityFilter.filter = null; - } - - _commodityFilter.filter.Label.text = $"Hardpoint: {Enum.GetName(typeof(SimpleCommodityCategory), x)}"; - _commodityFilter.type = x; - Populate(); - }, true))); - ContextMenu.AddDropdown("Compound Commodity", compoundCommodityTypes - .Select(x => (Enum.GetName(typeof(CompoundCommodityCategory), x), () => - { - if (!_compoundCommodityFilter.filter) - { - _compoundCommodityFilter.filter = FilterPrototype.Instantiate(); - _compoundCommodityFilter.filter.OnDisable += () => - { - _compoundCommodityFilter.filter = null; - Populate(); - }; - } - if(_hardpointFilter.filter) - { - _hardpointFilter.filter.GetComponent().ReturnToPool(); - _hardpointFilter.filter = null; - } - if(_commodityFilter.filter) - { - _commodityFilter.filter.GetComponent().ReturnToPool(); - _commodityFilter.filter = null; - } - - _compoundCommodityFilter.filter.Label.text = $"Hardpoint: {Enum.GetName(typeof(CompoundCommodityCategory), x)}"; - _compoundCommodityFilter.type = x; - Populate(); - }, true))); ContextMenu.AddDropdown("Item Behavior", typeof(BehaviorData).GetAllChildClasses() .Where(x=> x.GetCustomAttribute() != null && _behaviorFilters.All(f => f.type != x)) .Select(x=> (x.Name.FormatTypeName(), () => @@ -199,19 +124,15 @@ public class TradeMenu : MonoBehaviour columns.Add(("Type", 2, x => () => { - if (x.data is SimpleCommodityData s) return Enum.GetName(typeof(SimpleCommodityCategory), s.Category); - if(x.data is CompoundCommodityData c) return Enum.GetName(typeof(CompoundCommodityCategory), c.Category); + if (x.data is SimpleCommodityData || x.data is CompoundCommodityData) return "Commodity"; if(x.data is EquippableItemData e) return Enum.GetName(typeof(HardpointType), e.HardpointType); return "None"; }, x => { var data = x.data; - if (data is SimpleCommodityData s) return (int) s.Category; - var offset = Enum.GetValues(typeof(SimpleCommodityCategory)).Length; - if(data is CompoundCommodityData c) return (int) c.Category + offset; - offset += Enum.GetValues(typeof(CompoundCommodityCategory)).Length; - if(data is EquippableItemData e) return (int) e.HardpointType + offset; + if (data is SimpleCommodityData || data is CompoundCommodityData) return 0; + if (data is EquippableItemData e) return 1 + (int) e.HardpointType; return 0; })); columns.Add(("Mass", 1, @@ -237,12 +158,6 @@ public class TradeMenu : MonoBehaviour !(MaximumSizeFilter.Width.text.Length > 0 && i.data.Shape.Width > int.Parse(MaximumSizeFilter.Width.text) || MaximumSizeFilter.Height.text.Length > 0 && i.data.Shape.Height > int.Parse(MaximumSizeFilter.Height.text))); - if(_commodityFilter.filter != null) - items = items.Where(i => i.data is SimpleCommodityData s && s.Category == _commodityFilter.type); - - if(_compoundCommodityFilter.filter != null) - items = items.Where(i => i.data is CompoundCommodityData c && c.Category == _compoundCommodityFilter.type); - if (_hardpointFilter.filter != null) items = items.Where(i => i.data is EquippableItemData e && e.HardpointType == _hardpointFilter.type); diff --git a/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs b/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs new file mode 100644 index 000000000..7f1c376c3 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CommodityRecordsTests.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using System.Reflection; +using MessagePack; +using Xunit; + +// Commodity records carry no stored classification and no demand weights (aetheria-living-world, cut +// catalog-subtraction): stage, component-ness and consumer-ness are derived later from recipes, roles and needs. +public sealed class CommodityRecordsTests +{ + // The shipped bytes still hold values at the retired keys; MessagePack skips them and every record loads by name. + [Fact] + public void ShippedCatalogOpensWithTheRetiredKeysGone() + { + using var cache = RestoredHullsTests.OpenCatalog(); + Assert.Equal(13, cache.GetAll().Count()); + Assert.Equal(51, cache.GetAll().Count()); + Assert.Contains(cache.GetAll(), c => c.Name == "Ammo"); + Assert.All(cache.GetAll(), c => Assert.False(string.IsNullOrEmpty(c.Name))); + } + + // Keys 10 and 11 are retired: no commodity member may reuse them for a different meaning. + [Fact] + public void RetiredKeysStayRetired() + { + int[] KeysOf(Type type) => type.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Select(f => f.GetCustomAttribute()?.IntKey).Where(k => k.HasValue).Select(k => k.Value).ToArray(); + Assert.Equal(new[] { 9 }, KeysOf(typeof(SimpleCommodityData))); + Assert.Empty(KeysOf(typeof(CompoundCommodityData))); + Assert.Null(typeof(SimpleCommodityData).GetField("Category")); + Assert.Null(typeof(CompoundCommodityData).GetField("Category")); + Assert.Null(typeof(CompoundCommodityData).GetField("DemandProfile")); + } +} From ae52857a9154e1e4eb1d744fbb7542a295ab7a95 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:01:23 +0200 Subject: [PATCH 039/230] SimpleCommodityData: name key 10 as the removed stored Category (Soul s1 simple-key10-comment-misnamed) Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ItemData.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index dcb38456a..170c2421d 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -322,7 +322,7 @@ public abstract class ItemData [CultDocument("aetheria.simplecommoditydata", "1"), Inspectable, MessagePackObject] public class SimpleCommodityData : ItemData { - // MessagePack keys 6, 7, 8, 10 and 11 belonged to removed resource-distribution fields; do not reuse them. + // MessagePack keys 6, 7, 8 and 11 belonged to removed resource-distribution fields, and key 10 to the removed stored Category; do not reuse them. [Inspectable, JsonProperty("maxStackSize"), Key(9)] public int MaxStack = 10; From c40a6e8229ff1dc2c81bb18bb1ecc384303dc2e8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:21:12 +0200 Subject: [PATCH 040/230] View seam tests: per-shooter aim, view gates, sees-hostile, honest track loss (red) Co-Authored-By: Claude Sonnet 5.5 --- .../ApproachKnowledgeTests.cs | 19 +++--- .../FireControlPerWeaponTests.cs | 46 ++++++++++++++ .../PerShooterAimTests.cs | 60 +++++++++++++++++++ tests/Aetheria.Shared.Tests/PilotViewTests.cs | 53 ++++++++++++++++ 4 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/PerShooterAimTests.cs diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs index 98f857913..cd48907a7 100644 --- a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -9,14 +9,14 @@ using float3 = CultMath.float3; // track from the first step, and the pilot follows it only as long as the sensors keep it. public sealed partial class RunStartTests { - private (Zone arena, Ship east, Ship west) StagedApproach() + private (Zone arena, Ship east, Ship west) StagedApproach(float westX = -450f) { Ship east = null, west = null; var (_, arena, _, failures) = Launch(new Scripted(false, stage => { stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); east = stage.Place(EvLonginus(stage), float2(450, 0), facing: float2(-1, 0), piloted: true) as Ship; - west = stage.Place(EvLonginus(stage), float2(-450, 0), facing: float2(1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(westX, 0), facing: float2(1, 0), piloted: true) as Ship; stage.Approach(east, west); })); Assert.True(failures.Count == 0, string.Join("; ", failures)); @@ -39,22 +39,27 @@ public sealed partial class RunStartTests Assert.True(east.Position.x - west.Position.x < startGap - 20f, "the follower closed on its anchor"); } + // The anchor is staged 60 km off, far outside sensor reach: the staged knowledge decays through the sensors like any + // track (the Sensor is the only writer after staging), and the follower stops once they have dropped it. Nothing + // here writes the pilot's knowledge after the stage. [Fact] public void AStagedApproachFollowerStopsFollowingOnceTheSensorsLoseTheTrack() { - var (arena, east, west) = StagedApproach(); + var (arena, east, west) = StagedApproach(westX: -60000f); var view = arena.Agents.Single(a => a.Ship == east).View; var sentinel = new float3(0, 0, 12345); - for (var i = 0; i < 3; i++) arena.Update(1f / 60f); + Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); east.Command.Aim = sentinel; - arena.Update(1f / 60f); + arena.Update(.1f); Assert.NotEqual(sentinel, east.Command.Aim.Value); - east.EntityInfoGathered[west] = 0f; + var steps = 0; + while (view.TrackOf(east, PilotView.Handle(west)) != null && steps++ < 1200) arena.Update(.1f); Assert.Null(view.TrackOf(east, PilotView.Handle(west))); + east.Command.Aim = sentinel; - arena.Update(1f / 60f); + arena.Update(.1f); Assert.Equal(sentinel, east.Command.Aim.Value); } } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 2c70cf438..5c0aa19a5 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -444,6 +444,52 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact)); } + // A view answers fire control only for a member that tracks the contact: the shooter outside the view, or having lost + // the track, gets no intercept, no reveals, no designated weapon, a refusal, and no shot; a write for a non-member + // changes nothing. The member that tracks is the positive control, answering as FireControl does. Kills: any of + // Intercept, WeaponReveals, Designated, Refuses, Fires or SelectItem answering without the member's own track or + // membership. + public enum Standing { TrackingMember, NotAMember, LostTheTrack } + + [Theory] + [InlineData(Standing.TrackingMember)] + [InlineData(Standing.NotAMember)] + [InlineData(Standing.LostTheTrack)] + public void AViewAnswersFireControlOnlyForAMemberThatTracksTheContact(Standing standing) + { + var r = Build(velocity: 500f); + var targetGun = r.Target.Equipment.Single(i => i.Behaviors.OfType().Any()); + r.Shooter.RecordReveal(r.Target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data); + var view = standing == Standing.NotAMember ? new PilotView() : new PilotView(r.Shooter); + var contact = PilotView.Handle(r.Target); + var weapon = r.Gun.Weapon; + var reveal = new PilotView(r.Shooter).WeaponReveals(r.Shooter, contact).Single(); + if (standing == Standing.LostTheTrack) r.Shooter.VisibleEntities.Remove(r.Target); + var minHit = 0f; + + var intercept = view.Intercept(r.Shooter, weapon, contact); + var reveals = view.WeaponReveals(r.Shooter, contact); + view.SelectItem(r.Shooter, reveal.Id); + if (standing == Standing.TrackingMember) + { + Assert.NotNull(intercept); + Assert.Single(reveals); + Assert.True(view.Designated(r.Shooter, weapon, contact)); + Assert.False(view.Refuses(r.Shooter, weapon, contact)); + Assert.True(view.Fires(r.Shooter, weapon, contact, minHit, null)); + Assert.Single(r.Shooter.Command.Selections); + } + else + { + Assert.Null(intercept); + Assert.Empty(reveals); + Assert.False(view.Designated(r.Shooter, weapon, contact)); + Assert.True(view.Refuses(r.Shooter, weapon, contact)); + Assert.False(view.Fires(r.Shooter, weapon, contact, minHit, null)); + if (standing == Standing.NotAMember) Assert.Empty(r.Shooter.Command.Selections); + } + } + private static void AssertAimsAtTarget(Rig r) { var toTarget = r.Target.Position - r.Shooter.Position; diff --git a/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs b/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs new file mode 100644 index 000000000..e284abf56 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PerShooterAimTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using System.Reflection; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Fire control is per shooter (ruling per-shooter-fire-control): a pilot flies, aims and fires on what its own sensors +// track, not on a track only a sidemate holds. +public sealed partial class RunStartTests +{ + // East and west are one side, one view; only west tracks x. East is designated on x: it must not aim or pull on west's + // track (Combat reads TrackOf for the shooter, not SideTrack). The positive control has east track x as well, and it aims. + // Kills: Combat flying on SideTrack. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AMemberDesignatedOnAContactOnlyItsSidemateTracksNeitherAimsNorFires(bool eastTracks) + { + Ship east = null, west = null, x = null; + var (_, arena, _, failures) = Launch(new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + east = stage.Place(EvLonginus(stage), float2(100, 0), facing: float2(-1, 0), piloted: true) as Ship; + west = stage.Place(EvLonginus(stage), float2(-100, 0), facing: float2(1, 0), piloted: true) as Ship; + x = stage.Place(EvLonginus(stage), float2(0, 300), facing: float2(0, -1)) as Ship; + })); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + var known = (east.ItemManager.GameplaySettings.TargetDetectionInfoThreshold + 1f) / 2f; + east.SetIff(x, true); + west.SetIff(x, true); + var side = new PilotView(east, west); + var agent = arena.Agents.Single(a => a.Ship == east); + agent.View = side; + west.EntityInfoGathered[x] = known; + if (eastTracks) east.EntityInfoGathered[x] = known; + Assert.NotNull(side.SideTrack(PilotView.Handle(x))); + Assert.Equal(eastTracks, side.TrackOf(east, PilotView.Handle(x)) != null); + + var sentinel = new float3(0, 0, -12345); + object state = null; + for (var i = 0; i < 4; i++) + { + east.Command.Designate(new TargetRef(x)); + east.Command.Aim = sentinel; + agent.Update(1f / 60f); + state = typeof(Agent).GetField("_currentState", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(agent); + } + Assert.IsType(state); + var aimed = !east.Command.Aim.Value.Equals(sentinel); + if (eastTracks) Assert.True(aimed, "a member that tracks the contact aims at it"); + else + { + Assert.False(aimed, $"a member flew its aim on a sidemate's track: {east.Command.Aim}"); + Assert.False(east.Weapons.Any(w => east.Command.Pulls(w)), "a member pulled a trigger on a sidemate's track"); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/PilotViewTests.cs b/tests/Aetheria.Shared.Tests/PilotViewTests.cs index 5c371de49..6d47fe3c4 100644 --- a/tests/Aetheria.Shared.Tests/PilotViewTests.cs +++ b/tests/Aetheria.Shared.Tests/PilotViewTests.cs @@ -2,6 +2,7 @@ using System; using System.Linq; using CultMath; using GameCult.Caching; +using UniRx; using Xunit; // A side's brain reaches other ships only through its PilotView (Agents/PilotView.cs): handles it issued, tracks only @@ -263,4 +264,56 @@ public sealed class PilotViewTests : IDisposable } Assert.Equal(300f, member.Command.Aim.Value.z, 1); } + + // The hostile part of Sees is the member's own: a member holding a sidemate's enemy as friendly (a hail it answered, an + // IFF override) does not see it as an enemy, though it tracks it. Kills: Sees reading VisibleEntities. + [Fact] + public void AMemberThatHoldsASidematesEnemyAsFriendlyDoesNotSeeItAsOne() + { + var faction = new Faction { Name = "Plain" }; + var a = Pilot(faction, -10); + var b = Pilot(faction, 10); + var x = Subject(); + See(b, x); + a.EntityInfoGathered[x] = 1; + a.VisibleEntities.Add(x); + a.SetIff(x, false); + var view = ViewOf(a); + var handle = PilotView.Handle(x); + + Assert.NotNull(view.TrackOf(a, handle)); + Assert.True(view.Sees(b, handle)); + Assert.False(view.Sees(a, handle)); + } + + // A view answers for its members only: every per-member read of a ship the view does not hold is empty, however much + // that ship knows, and a write for it changes nothing. One assertion per read, each killing its dropped membership check. + [Fact] + public void AViewAnswersNothingForAShipThatIsNotItsMember() + { + var a = Pilot(null, -200); + var outsider = Pilot(null, 200); + var x = Subject(); + See(outsider, x); + outsider.SetIff(x, true); + x.PresencePermitted = Observable.Return(false).ToReadOnlyReactiveProperty(); + outsider.Command.Designate(new TargetRef(x)); + var handle = PilotView.Handle(x); + var view = ViewOf(a); + Assert.NotNull(outsider.IffOverride(x)); + Assert.True(outsider.BelievesTrespassing(x)); + Assert.True(outsider.VisibleEnemies.Contains(x)); + + Assert.Null(view.TrackOf(outsider, handle)); + Assert.False(view.Sees(outsider, handle)); + Assert.Null(view.IffOverride(outsider, handle)); + Assert.Null(view.Info(outsider, handle)); + Assert.False(view.BelievesTrespassing(outsider, handle)); + Assert.Null(view.TargetOf(outsider)); + PilotApplier.Apply(outsider); + Assert.Null(view.AppliedTargetOf(outsider)); + + view.Designate(outsider, null); + Assert.Null(outsider.Command.Designated); + } } From d38ee7cdd6f81b4f3291bbde93810023fb572ad7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:25:58 +0200 Subject: [PATCH 041/230] Approach track-loss test: warm up before reading the follower's aim (the 60 km anchor is lost by the sensors) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs index cd48907a7..5fdf7bffd 100644 --- a/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs +++ b/tests/Aetheria.Shared.Tests/ApproachKnowledgeTests.cs @@ -49,9 +49,11 @@ public sealed partial class RunStartTests var view = arena.Agents.Single(a => a.Ship == east).View; var sentinel = new float3(0, 0, 12345); + Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); + for (var i = 0; i < 3; i++) arena.Update(1f / 60f); Assert.NotNull(view.TrackOf(east, PilotView.Handle(west))); east.Command.Aim = sentinel; - arena.Update(.1f); + arena.Update(1f / 60f); Assert.NotEqual(sentinel, east.Command.Aim.Value); var steps = 0; From 961bdafade70089beccd2a2e93838f29467da0a1 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:26:03 +0200 Subject: [PATCH 042/230] Determinism ledger: the TwoAiDigestIsPinned comment names both causes of the second repin Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 6ff03b525..65be67d68 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -24,8 +24,12 @@ public sealed partial class RunStartTests // The two-AI fight's digest, captured on Yggdrasil. Replays are trusted only on the host class that recorded them // (ruling replays-per-host-class), and arenas run on Linux. private const string TwoAiPinnedHostClass = "linux-x64"; - // Repinned twice. First for the side split: the two fighters are unaffiliated, were one side and each followed itself. - // Then for staged knowledge (ruling repin-for-staged-knowledge): the fighters are staged knowing each other, so they engage at step 0. + // Repinned twice, and the second repin has two causes (Soul measured the steps: group 1 + seed = f19b9992, + the Follow + // gate = ce81a426; group 2 without the seed = bf586806). + // First for the side split: the two fighters are unaffiliated, were one side and each followed itself (1b2ffe51). + // Then for staged knowledge (ruling repin-for-staged-knowledge), which moved it twice: the fighters are staged knowing + // each other, so they engage at step 0 (f19b9992); and Follow now flies only on an anchor the pilot itself tracks, so + // once the sensors drop the staged anchor the follower stops following it (ce81a426). private const string TwoAiPinnedDigest = "ce81a426c40495e4b4b7b7f83ad23767cc781dce5523d713b4455e0aa5b7fe33"; private const int TwoAiSteps = 3600; From 488b3fa434daa88439c161e88ecbe2657fbfaf5d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:26:07 +0200 Subject: [PATCH 043/230] PilotView enforces its own rules: members only, fire-control readouts only on the member's own track; constructor and View setter internal Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Agents/Agent.cs | 2 +- .../Scripts/ServerShared/Agents/PilotView.cs | 83 +++++++++++++------ .../ServerShared/Agents/States/Combat.cs | 3 +- Assets/Scripts/ServerShared/ScenarioStage.cs | 3 + .../FireControlPerWeaponTests.cs | 2 +- 5 files changed, 65 insertions(+), 28 deletions(-) diff --git a/Assets/Scripts/ServerShared/Agents/Agent.cs b/Assets/Scripts/ServerShared/Agents/Agent.cs index 586438c97..03a76436a 100644 --- a/Assets/Scripts/ServerShared/Agents/Agent.cs +++ b/Assets/Scripts/ServerShared/Agents/Agent.cs @@ -25,7 +25,7 @@ public class Agent public AgentTask Task { get; set; } // What this pilot's side knows of other ships. A ship outside a flight is a side of one: a view over itself, until // Flight.Join sets the flight's. - public PilotView View { get; set; } + public PilotView View { get; internal set; } // Zone-scoped, from 1: the ordinal of this pilot's own dice, set by Zone.CreateAgent as it admits the pilot (0 for // an agent built outside a zone's admission). public uint Id { get; set; } diff --git a/Assets/Scripts/ServerShared/Agents/PilotView.cs b/Assets/Scripts/ServerShared/Agents/PilotView.cs index 8c9a71e65..51cacf32f 100644 --- a/Assets/Scripts/ServerShared/Agents/PilotView.cs +++ b/Assets/Scripts/ServerShared/Agents/PilotView.cs @@ -14,13 +14,22 @@ using CultMath; // within a zone step nothing moves during the agent phase, so the track equals what the Entity would answer. The // fire-control readouts call FireControl's own functions with the resolved subject, so the HUD, the player and the // brains price one model (A-one-sim). Own-ship reads stay the brain's own. +// +// The view enforces its own rules; it does not trust its callers. Every per-member read answers for a member the view +// holds and nothing else, and every per-contact fire-control readout (Intercept, WeaponReveals, Designated, Refuses, +// Fires) answers only while that member itself tracks the contact: a sidemate's track gives a member no aim, no reveals +// and no shot (per-shooter-fire-control). Only Flight builds a view and sets it on its pilots, and the side-of-one view an +// Agent starts with is its own; the constructor and the setter are internal. public sealed class PilotView { private readonly List _members = new List(); private static Func _openContact; private static Func _openReveal; - public PilotView(params Ship[] members) => _members.AddRange(members); + internal PilotView(params Ship[] members) => _members.AddRange(members); + + private bool Holds(Ship member) => _members.Contains(member); + private bool Tracks(Ship member, Entity subject) => Holds(member) && subject != null && member.VisibleEntities.Contains(subject); internal void Add(Ship member) => _members.Add(member); internal void Remove(Ship member) => _members.Remove(member); @@ -69,7 +78,9 @@ public sealed class PilotView } } - // For fixtures: the handle of a subject, as the view would issue it. + // For fixtures, and for the one place the stage states what a pilot already knows (ScenarioStage.Approach seeds an + // anchor handle for a FollowTask before the zone exists: staged knowledge, ruling stage-states-pilot-knowledge). A + // brain never calls this; the sensors, not the handle, decide whether the view answers about the contact. internal static ContactId Handle(Entity subject) => new ContactId(subject); private static Entity Subject(ContactId id) @@ -95,13 +106,13 @@ public sealed class PilotView return picture; } - public bool Sees(Ship member, ContactId contact) => member.VisibleEnemies.Contains(Subject(contact)); + public bool Sees(Ship member, ContactId contact) => Holds(member) && member.VisibleEnemies.Contains(Subject(contact)); // The member's own track of the contact, or null while it does not track it. public Track? TrackOf(Ship member, ContactId contact) { var subject = Subject(contact); - if (subject == null || !member.VisibleEntities.Contains(subject)) return null; + if (!Tracks(member, subject)) return null; return new Track(subject.Position, subject.Velocity, subject.Direction); } @@ -117,45 +128,64 @@ public sealed class PilotView return null; } - public bool? IffOverride(Ship member, ContactId contact) => member.IffOverride(Subject(contact)); + public bool? IffOverride(Ship member, ContactId contact) => Holds(member) ? member.IffOverride(Subject(contact)) : null; public float? Info(Ship member, ContactId contact) => - member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null; + Holds(member) && member.EntityInfoGathered.TryGetValue(Subject(contact), out var info) ? info : (float?) null; - public bool BelievesTrespassing(Ship member, ContactId contact) => member.BelievesTrespassing(Subject(contact)); + public bool BelievesTrespassing(Ship member, ContactId contact) => Holds(member) && member.BelievesTrespassing(Subject(contact)); // The member's pending designation when set, else last step's applied target. public ContactId? TargetOf(Ship member) { + if (!Holds(member)) return null; var subject = (member.Command.Designated ?? member.Target.Value).Entity; return subject == null ? (ContactId?) null : new ContactId(subject); } public ContactId? AppliedTargetOf(Ship member) { + if (!Holds(member)) return null; var subject = member.Target.Value.Entity; return subject == null ? (ContactId?) null : new ContactId(subject); } // The weapons the member believes the contact has, in the contact's equipment order; Aimable is FireControl.IsRevealed. - public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact) => - member.RevealedWeapons(Subject(contact)) - .Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList(); - - public bool Designated(Ship member, Weapon weapon, ContactId contact) => - FireControl.Designated(weapon, member, Subject(contact), out _); - - public bool Refuses(Ship member, Weapon weapon, ContactId contact) => - FireControl.Refuses(weapon, member, Subject(contact)); - - public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim) => - FireControl.AgentFires(weapon, member, Subject(contact), minHitProbability, - aim == null ? AimTarget.None : AimTarget.OfItem(Item(aim.Value))); - - // Where to point to hit the contact with the weapon: the shared prediction, at the height the contact rides. - public float3 Intercept(Ship member, Weapon weapon, ContactId contact) + // Empty unless the member tracks the contact. + public List<(RevealId Id, EquippableItemData Claimed, bool Aimable)> WeaponReveals(Ship member, ContactId contact) { var subject = Subject(contact); + if (!Tracks(member, subject)) return new List<(RevealId, EquippableItemData, bool)>(); + return member.RevealedWeapons(subject) + .Select(r => (new RevealId(r.Item), r.Claimed, FireControl.IsRevealed(member, r.Item))).ToList(); + } + + // The next four answer for a member that tracks the contact: otherwise not designated, refused, no shot, no intercept. + public bool Designated(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + return Tracks(member, subject) && FireControl.Designated(weapon, member, subject, out _); + } + + public bool Refuses(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + return !Tracks(member, subject) || FireControl.Refuses(weapon, member, subject); + } + + public bool Fires(Ship member, Weapon weapon, ContactId contact, float minHitProbability, RevealId? aim) + { + var subject = Subject(contact); + return Tracks(member, subject) && FireControl.AgentFires(weapon, member, subject, minHitProbability, + aim == null ? AimTarget.None : AimTarget.OfItem(Item(aim.Value))); + } + + // Where to point to hit the contact with the weapon: the shared prediction, at the height the contact rides; null + // unless the member tracks the contact. + public float3? Intercept(Ship member, Weapon weapon, ContactId contact) + { + var subject = Subject(contact); + if (!Tracks(member, subject)) return null; var predicted = FireControl.PredictedIntercept(weapon, member, subject); predicted.y = subject.RideHeight(predicted.xz); return predicted; @@ -164,10 +194,13 @@ public sealed class PilotView // Designate the contact, or nothing for null, when that is not already the member's applied target. public void Designate(Ship member, ContactId? contact) { + if (!Holds(member)) return; var wanted = contact == null ? TargetRef.None : new TargetRef(Subject(contact.Value)); if (!member.Target.Value.Equals(wanted)) member.Command.Designate(wanted); } - public void SelectItem(Ship member, RevealId? reveal) => - member.Command.SelectItem(reveal == null ? null : Item(reveal.Value)); + public void SelectItem(Ship member, RevealId? reveal) + { + if (Holds(member)) member.Command.SelectItem(reveal == null ? null : Item(reveal.Value)); + } } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index a424437b3..f8c12baab 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -134,8 +134,9 @@ public class CombatState : BaseState // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with the shot's // own snapshot and the roll's deviation measurement -- two separate predictions here and in the // roll would let an AI aim at one point and get judged for missing a different one. + // The target is tracked here (the guard above), so the intercept is never null. var predictedPosition = view.Intercept(_agent.Ship, testWeapon, target.Value); - toTarget = normalize(predictedPosition - _agent.Ship.Position); + if (predictedPosition != null) toTarget = normalize(predictedPosition.Value - _agent.Ship.Position); } // Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index a9e2db5d0..453ede8ec 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -148,6 +148,9 @@ public sealed class ScenarioStage var placement = _placed.FirstOrDefault(p => ReferenceEquals(p.Entity, entity)); if (placement == null || !placement.Piloted) { _failures.Add($"approach: {entity?.Name ?? "no entity"} is not a placed, piloted ship"); return; } if (target == null) { _failures.Add("approach: no target to close on"); return; } + // The one production handle minted from truth: the stage states the pilot's knowledge of its anchor (ruling + // stage-states-pilot-knowledge), and RunStart seeds the matching sensor track. The view still answers about the + // anchor only while the pilot's sensors hold it. placement.Task = new FollowTask { Anchor = PilotView.Handle(target), Standoff = 0f }; placement.Knows = target; } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 5c0aa19a5..3a06a63f7 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -441,7 +441,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var planarY = expected.y; expected.y = r.Target.RideHeight(expected.xz); Assert.NotEqual(planarY, expected.y); - Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact)); + Assert.Equal(expected, view.Intercept(r.Shooter, weapon, contact).Value); } // A view answers fire control only for a member that tracks the contact: the shooter outside the view, or having lost From 1691f47cc685623e32f26d282b5ebc7f34bd8920 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:09:53 +0200 Subject: [PATCH 044/230] README: front door with simulation entry points, demo in progress, directions and package hulls Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 79f0033f2..8518aba1f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,18 @@ All mechanics are developed to be engine-agnostic, while Unity was chosen as a r It is our ambition to forge a new paradigm of open source game development, as to our knowledge there are no other open source projects of this scope building up a foundation from scratch. Game development is notoriously averse to this model and nobody wants to share their shiny toys for fear of losing their competitive advantage. By building and sharing immersive and compelling experiences like this, we hope to change that. +## Front Door + +- **Value:** a simulation-first tactical ARPG. The game's rules live in a headless .NET build (`Aetheria.Shared`, compiled from `Assets/Scripts/ServerShared`) that needs no Unity; Unity is the shell for graphics and input. Content (hulls, gear, factions, blueprints) is typed CultCache data in `GameData/Aetheria.cc`. +- **Problem:** an AA-scale open source game built from scratch, with simulation depth (fire control, stats and power, heat, item provenance, locomotion) that agents and contributors can inspect, test and extend without opening the editor. +- **Entry points:** + - `dotnet test tests/Aetheria.Shared.Tests` runs the shared simulation tests; `dotnet run --project tools/AetherDb -- census` reads the catalog (commands under Command Line). `CLAUDE.md` explains AetherDb and the pinned CultLib build rule. + - `docs/aetheria-release-target.md`: the demo. `docs/aetheria-arena-target.md`: deterministic headless matches between swappable brains. `docs/moddable-ship-authoring.md`: ship packages. + - Unity client: open the repository root in Unity 6000.3.24f1 and start from the "Main Menu" scene (Testing Locally). +- **In progress:** the demo, one gate, one region and one boss with a fixed faction presence (allied Pirates, antagonist Zhestokost). It is called a demo because full Terminus needs enough faction variety for three procedurally generated regions. +- **Directions, not built:** the strategic arena (`docs/aetheria-arena-target.md` is a target, not a shipped system) and the living-world economy. Neither has a spec in `docs/` yet beyond the arena target. +- **Packages ship hulls:** hulls are delivered as mod-owned `.cc` ship packages authored in Blender and the CultLib tools, with no Unity install. That is a proof lane today; catalog `HullData` records remain live until a complete package ship passes authoring, validation, loading and play. + ## Contact Us If you want to chat, please join [our Discord server](https://discord.gg/trbteNj) for detailed development updates and discussions about the game design, narrative, art direction, and general topics. Shitposting welcome! @@ -57,13 +69,13 @@ As a result of lessons learned, we then focused on the economy system, and built ## Current Work -At the moment we are focused on implementing and polishing the ARPG gameplay as a standalone title, first as a rogue-lite combat demo and then adding a story-driven campaign mode. +At the moment we are focused on implementing and polishing the ARPG gameplay as a standalone title, first as a rogue-lite combat demo (see [the demo target](docs/aetheria-release-target.md)) and then adding a story-driven campaign mode. ## Architecture ### Project Structure -There are two solutions in this repository. One is a Unity project containing the desktop client for Aetheria, and the other is a .NET Core application intended to run on Linux cloud servers. The bulk of the game's code is shared between them, located at [Assets/Scripts/ServerShared](Assets/Scripts/ServerShared), defining a common protocol, world simulation code and data serialization format. +This repository holds a Unity project containing the desktop client for Aetheria and a headless .NET build of the shared simulation (`Aetheria.Shared`, with `tests/Aetheria.Shared.Tests` and `tools/AetherDb`). The bulk of the game's code is shared between them, located at [Assets/Scripts/ServerShared](Assets/Scripts/ServerShared), defining a common protocol, world simulation code and data serialization format. ### Third Party Libraries From 66671cf036f711c8c41fc0ce311ebc715bcd8a7d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:09:30 +0200 Subject: [PATCH 045/230] Cargo tests: quantities below one, give routing and ends, purchase lots (red); soul-tv probes P4-P6, P9 adopted Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 261 +++++++++++++++++- 1 file changed, 259 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index ea92abd40..26f2332f0 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -33,6 +33,12 @@ public sealed class CargoTransferTests : IDisposable _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); + var slot = new Shape(1, 2); + foreach (var cell in slot.AllCoordinates) slot[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Slot", Shape = new Shape(), InteriorShape = slot, Price = 5, Durability = 4 }); + var rod = new Shape(2, 1); + foreach (var cell in rod.AllCoordinates) rod[cell] = true; + _cache.Upsert(new SimpleCommodityData { Name = "Rod", Shape = rod, Price = 1, MaxStack = 1 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); _cache.FlushAsync().Wait(); @@ -59,8 +65,28 @@ public sealed class CargoTransferTests : IDisposable }; } - private SimpleCommodity Commodity(string design, int quantity) => - new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName(design)), Quantity = quantity }; + private SimpleCommodity Commodity(string design, int quantity, ItemRotation rotation = ItemRotation.None) => + new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName(design)), Quantity = quantity, Rotation = rotation }; + + // Bays built directly, so a ship can hold several (TryEquip is not under test). + private Ship ShipWithBays(params string[] bays) + { + var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); + for (var i = 0; i < bays.Length; i++) + ship.CargoBays.Add(new EquippedCargoBay(_items, Mint(bays[i]), new int2(1 + i, 1), ship, $"bay{i}")); + return ship; + } + + // Off the origin, so a distance taken from the wrong sum or difference of positions shows. + private void Place(Ship giver, Ship receiver) + { + giver.Position = new float3(-50, 0, 10); + receiver.Position = giver.Position + new float3(5, 0, 0); + _zone.Admit(giver, piloted: false); + _zone.Admit(receiver, piloted: false); + } + + private static int Stacks(EquippedCargoBay bay) => bay.Cargo.Keys.Count; private Ship ShipWith(string bay) { @@ -518,4 +544,235 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(0, Held(receiver, ore)); Assert.Null(giver.LastGiveEnd); } + + // ---- Quantities below one -------------------------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void ATransferBelowOneUnitChangesNothing(int quantity) + { + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Crate").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + Assert.True(to.TryStore(Commodity("Ore", 3))); + var mass = (from.Mass, to.Mass); + var stacks = (Stacks(from), Stacks(to)); + + Assert.False(from.TryTransferItem(to, ore, quantity)); + + Assert.Equal(10, Units(from, ore)); + Assert.Equal(3, Units(to, ore)); + Assert.Equal(mass, (from.Mass, to.Mass)); + Assert.Equal(stacks, (Stacks(from), Stacks(to))); + } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void ARemovalBelowOneUnitTakesNothing(int quantity) + { + var bay = ShipWith("Crate").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(bay.TryStore(ore)); + var mass = bay.Mass; + + Assert.Null(bay.Remove(ore, quantity)); + + Assert.Equal(10, Units(bay, ore)); + Assert.Equal(mass, bay.Mass); + Assert.Equal(1, Stacks(bay)); + } + + // ---- The give: where units go, and when it ends (the soul-tv probes, adopted) ---------------------------- + + // First receiver bay full, second has room: the unit goes to the second. + [Fact] + public void AGivenCommodityGoesToTheSecondBayWhenTheFirstIsFull() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Pocket", "Crate"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 3); + + Steps(40); + + Assert.Equal(3, Units(receiver.CargoBays[1], ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + // Two open receiver bays: one unit per move, not one per bay. + [Fact] + public void AGiveMovesOneUnitPerMoveWithTwoOpenBays() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate", "Crate"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 10); + + _zone.Update(1f); + + Assert.Equal(2, Held(receiver, ore)); + Assert.Equal(8, Held(giver, ore)); + } + + [Fact] + public void AGivenCraftedItemGoesToTheSecondBayWhenTheFirstIsFull() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Pocket", "Crate"); + var gun = Mint("Gun"); + Assert.True(giver.CargoBays[0].TryStore(gun)); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Place(giver, receiver); + giver.Command.Give(receiver, gun, 1); + + Steps(10); + + Assert.True(receiver.CargoBays[1].Cargo.ContainsKey(gun)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGivenCraftedItemMovesOnceIntoTwoOpenBays() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate", "Crate"); + var gun = Mint("Gun"); + Assert.True(giver.CargoBays[0].TryStore(gun)); + Place(giver, receiver); + giver.Command.Give(receiver, gun, 1); + + Steps(10); + + Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(gun)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + // A refused one-unit move of a whole one-unit stack re-files it at the end of ItemsOfType, so the next Source would be + // a different stack. Rods are 2x1; the receiver's Slot (1x2) takes only the turned one. After the give has ended + // ReceiverFull nothing may move. + [Fact] + public void NothingMovesAfterAGiveEndsReceiverFull() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Slot"); + var flat = Commodity("Rod", 1); + Assert.True(giver.CargoBays[0].TryStore(flat)); + Assert.True(giver.CargoBays[0].TryStore(Commodity("Rod", 1, ItemRotation.Clockwise))); + Place(giver, receiver); + giver.Command.Give(receiver, flat, 2); + + _zone.Update(5f); + + Assert.Equal(GiveEnd.ReceiverFull, giver.LastGiveEnd); + Assert.Equal(0, Held(receiver, flat)); + Assert.Equal(2, Held(giver, flat)); + } + + // Refused moves do not spend the order's units. + [Fact] + public void AnEndedOrderKeepsItsRemainingUnits() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Pocket"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 5); + _zone.Update(.01f); // applies the command, earns no unit + var order = giver.Giving; + Assert.NotNull(order); + + _zone.Update(5f); + + Assert.Equal(GiveEnd.ReceiverFull, giver.LastGiveEnd); + Assert.Equal(5, order.Remaining); + } + + // An empty stack in a hold is no unit to give: the give moves the real units and then has nothing left. + [Fact] + public void AnEmptyStackIsNotAUnitToGive() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate"); + var grit = Commodity("Grit", 2); + Assert.True(giver.CargoBays[0].TryStore(grit)); + Assert.True(giver.CargoBays[0].TryStore(Commodity("Grit", 0))); + Place(giver, receiver); + giver.Command.Give(receiver, grit, 3); + + _zone.Update(5f); + + Assert.Equal(2, Held(receiver, grit)); + Assert.Equal(GiveEnd.NothingLeft, giver.LastGiveEnd); + } + + // Warping and dying both leave through Zone.Remove; docking has already ended the give GiverDocked. + [Fact] + public void AGiveEndsWhenItsGiverLeavesTheZone() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 10); + _zone.Update(.5f); + + Assert.True(_zone.Remove(giver)); + + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.GiverGone, giver.LastGiveEnd); + } + + // ---- The lot a purchase delivers --------------------------------------------------------------------------- + + [Fact] + public void APurchaseChargesForEachLotItDelivers() + { + // Grit stacks hold two. Five units are three lots, and each lot is charged for what it delivers. + var stock = Commodity("Grit", 5); + var credits = 100; + var delivered = new List(); + + var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => { delivered.Add(lot); return true; }); + + Assert.Equal(BuyResult.Bought, result); + Assert.Equal(new[] { 2, 2, 1 }, delivered); + Assert.Equal(100 - 5, credits); + } + + [Fact] + public void ARefusedLotLeavesEarlierLotsBoughtAndChargesNothingMore() + { + var stock = Commodity("Grit", 5); + var credits = 100; + var lots = 0; + + var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => ++lots < 2); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100 - 2, credits); + } + + [Theory] + [InlineData(-5, 1000, 1)] + [InlineData(0, 1000, 1)] + [InlineData(4, 1000, 4)] + [InlineData(50, 1000, 10)] + [InlineData(50, 21, 3)] + [InlineData(5, 3, 1)] + public void TheBuyQuantityIsClampedToWhatIsBuyable(int typed, int credits, int expected) + { + var stock = Commodity("Ore", 10); // 7 credits each + Assert.Equal(expected, StationServices.BuyableQuantity(_items, stock, typed, credits)); + } } From f9a09e0d0a4fc1f24388c2292656c90b3fe93461 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:10:04 +0200 Subject: [PATCH 046/230] Cargo: TryTransferItem and Remove refuse a quantity below one and change nothing Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 721ac31b0..bc8087228 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2596,8 +2596,10 @@ public class EquippedCargoBay : EquippedItem return true; } + // The part removed: null, with the bay as it was, when the item is not here or the quantity is below one unit. public SimpleCommodity Remove(SimpleCommodity item, int quantity) { + if (quantity < 1) return null; if (!Cargo.ContainsKey(item)) { ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!"); @@ -2658,6 +2660,7 @@ public class EquippedCargoBay : EquippedItem public bool TryTransferItem(EquippedCargoBay target, SimpleCommodity item, int quantity) { + if (quantity < 1) return false; if (!Cargo.ContainsKey(item)) { ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!"); From f0d7c12cbdfa0bda23ad04e9f5991e68a62d16b7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:10:07 +0200 Subject: [PATCH 047/230] Trade: the purchase loop moves to StationServices.TryBuyLots, charging each lot it delivers; the Buy Quantity field clamps to at least one Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/StationServices.cs | 22 +++++++++++++++++++ Assets/Scripts/UI/Menu/TradeMenu.cs | 15 +++---------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index a6cb4bfb5..433b0baf1 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -79,6 +79,28 @@ public static class StationServices return BuyResult.Bought; } + // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. `deliver` puts a lot of + // that many units where it goes, and each lot is charged for the units it delivered. The first lot the station + // cannot sell or the player cannot take ends the purchase, with the lots before it bought. + public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func deliver) + { + if (quantity < 1) return BuyResult.NoRoom; + var maxStack = items.GetData(stock).MaxStack; + for (var remaining = quantity; remaining > 0;) + { + var lot = Math.Min(remaining, maxStack); + var result = TryBuy(items, stock, lot, ref credits, () => deliver(lot)); + if (result != BuyResult.Bought) return result; + remaining -= lot; + } + return BuyResult.Bought; + } + + // The quantity a Buy Quantity field may hold: what was typed, at most what the credits cover and what the station + // has, and never below one unit (a price the credits do not cover is then refused as short of credits). + public static int BuyableQuantity(ItemManager items, SimpleCommodity stock, int typed, int credits) => + Math.Max(1, Math.Min(Math.Min(typed, credits / BuyPrice(items, stock)), stock.Quantity)); + // The sale: the item leaves the bay for one of the station's cargo bays and the station pays SellPrice. False, // with the item in its bay and the credits unchanged, when no station bay can take it. public static bool TrySell(EquippedCargoBay from, Entity station, ItemInstance item, ref int credits) diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 3d448f64d..13345f88a 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -270,7 +270,7 @@ public class TradeMenu : MonoBehaviour Dialog.Title.text = $"Buying {GameManager.ItemManager.Title(i.item)}"; Dialog.AddField("Quantity", () => quantity, - q => quantity = min(min(q, GameManager.Credits / StationServices.BuyPrice(GameManager.ItemManager, s)), s.Quantity)); + q => quantity = StationServices.BuyableQuantity(GameManager.ItemManager, s, q, GameManager.Credits)); Dialog.Show(() => { Buy(s,quantity); @@ -343,17 +343,8 @@ public class TradeMenu : MonoBehaviour private void Buy(SimpleCommodity simpleCommodity, int quantity) { - var data = GameManager.ItemManager.GetData(simpleCommodity); - // Up-rounded integer division from https://stackoverflow.com/a/503201 - int lots = (quantity - 1) / data.MaxStack + 1; - int remaining = quantity; - for (int i = 0; i < lots; i++) - { - int q = min(remaining, data.MaxStack); - if (!Bought(StationServices.TryBuy(GameManager.ItemManager, simpleCommodity, q, ref GameManager.Credits, - () => Inventory.TryTransferItem(_targetCargo, simpleCommodity, quantity)))) return; - remaining -= q; - } + Bought(StationServices.TryBuyLots(GameManager.ItemManager, simpleCommodity, quantity, ref GameManager.Credits, + lot => Inventory.TryTransferItem(_targetCargo, simpleCommodity, lot))); } // Refreshes the credits after a purchase, or tells the player why the station refused it. From 4d462c771c8f7bb82bb47b1731de3e82738158cc Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:10:09 +0200 Subject: [PATCH 048/230] Give: Zone.Remove ends the leaving giver's give GiverGone Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 3 ++- Assets/Scripts/ServerShared/Zone.cs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs index 6484bace8..45208d948 100644 --- a/Assets/Scripts/ServerShared/CargoTransfer.cs +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -15,7 +15,8 @@ public enum GiveEnd ReceiverGone, ReceiverFull, NothingLeft, - GiverDocked + GiverDocked, + GiverGone } // A pilot's standing offer of goods to a ship alongside. Runtime only: nothing about a give is saved. diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 8bc152eaa..d395f722a 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -299,11 +299,13 @@ public class Zone if (piloted) Agents.Add(CreateAgent((Ship) entity, task)); } - // The one removal from this zone (death, dock, jump): membership and its death watch leave together. False, and - // nothing changes, when the entity is not a member. The caller deactivates; Remove does not. + // The one removal from this zone (death, dock, jump): membership and its death watch leave together, and so does the + // entity's give, which ends GiverGone unless a dock already ended it. False, and nothing changes, when the entity is + // not a member. The caller deactivates; Remove does not. public bool Remove(Entity entity) { if (!_entities.Remove(entity)) return false; + CargoTransfer.End(entity, GiveEnd.GiverGone); _deathWatches.Remove(entity, out var watch); watch.Dispose(); foreach (var floating in FloatingItems) From 708fbeee5903f1c60188de71cd0342443d4b2c47 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:59:06 +0200 Subject: [PATCH 049/230] Cargo store tests: the bay owns stack quantity, purchases charge units moved, restore drops bad stacks (red) Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 26f2332f0..efcac768c 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -775,4 +775,124 @@ public sealed class CargoTransferTests : IDisposable var stock = Commodity("Ore", 10); // 7 credits each Assert.Equal(expected, StationServices.BuyableQuantity(_items, stock, typed, credits)); } + // ---- The bay owns the quantity that may sit in it --------------------------------------------------------- + + [Theory] + [InlineData("Ore", 0)] + [InlineData("Ore", -3)] + [InlineData("Ore", int.MinValue)] + [InlineData("Grit", 3)] // MaxStack 2 + [InlineData("Ore", 101)] + public void TheBayRefusesAStackBelowOneOrAboveMaxStack(string design, int quantity) + { + var bay = ShipWith("Crate").CargoBays[0]; + var massBefore = bay.Mass; + + Assert.False(bay.TryStore(Commodity(design, quantity))); + Assert.False(bay.TryStore(Commodity(design, quantity), new int2(0, 0))); + + Assert.Empty(bay.Cargo); + Assert.Equal(massBefore, bay.Mass); + } + + [Fact] + public void ANegativeStackNeverMergesIntoAHeldOne() + { + var bay = ShipWith("Crate").CargoBays[0]; + var held = Commodity("Ore", 5); + Assert.True(bay.TryStore(held)); + var at = bay.Cargo[held]; + + Assert.False(bay.TryStore(Commodity("Ore", -3), at)); + + Assert.Equal(5, held.Quantity); + } + + [Fact] + public void AMergeThatOverflowsFillsTheHeldStackToMaxAndLeavesTheRestWithTheCaller() + { + var bay = ShipWith("Crate").CargoBays[0]; + var held = Commodity("Grit", 1); + Assert.True(bay.TryStore(held)); + var incoming = Commodity("Grit", 2); + + Assert.False(bay.TryStore(incoming, bay.Cargo[held])); + + Assert.Equal(2, held.Quantity); + Assert.Equal(1, incoming.Quantity); + } + + [Fact] + public void APurchaseAboveTheStationStockIsRefusedWithoutCharge() + { + // Real bays, Grit stacks hold two: a stock of 3 cannot be bought as 5. Before, the second lot took the 1-unit + // remainder and was charged for 2. + var station = Station().CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Grit", 2); + Assert.True(station.TryStore(stock)); + var second = Commodity("Grit", 1); + Assert.True(station.TryStore(second, new int2(1, 0))); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => station.TryTransferItem(player, stock, lot)); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100, credits); + Assert.Equal(0, Units(player, stock)); + } + + [Fact] + public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing() + { + var station = Station().CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Ore", 0); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, StationServices.BuyableQuantity(_items, stock, 1, credits), ref credits, + lot => station.TryTransferItem(player, stock, lot)); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100, credits); + Assert.Empty(player.Cargo); + } + + [Fact] + public void ThePurchaseOfTheWholeStockChargesTheUnitsTheBayMoved() + { + var station = Station().CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Grit", 2); + Assert.True(station.TryStore(stock)); + var more = Commodity("Grit", 1); + Assert.True(station.TryStore(more, new int2(1, 0))); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => station.TryTransferItem(player, stock, lot)); + + Assert.Equal(BuyResult.Bought, result); + Assert.Equal(98, credits); + Assert.Equal(2, Units(player, stock)); + } + + [Fact] + public void ARestoredBayDropsAStackBelowOneAndLogsWhy() + { + var log = new List(); + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + var items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, log.Add); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var ship = ShipWith("Crate"); + Assert.True(ship.CargoBays[0].TryStore(Commodity("Slag", 4))); + var pack = (ShipPack) EntitySerializer.Pack(ship); + // The typed -5 purchase of old saves: a negative stack in the hold, beside a good one. + pack.CargoContents[0] = pack.CargoContents[0].Concat(new[] { (new int2(3, 3), (ItemInstance) Commodity("Ore", -5)) }).ToArray(); + + var restored = (Ship) EntitySerializer.Unpack(items, zone, pack); + + var bay = restored.CargoBays[0]; + Assert.Equal(new[] { 4 }, bay.Cargo.Keys.OfType().Select(c => c.Quantity).ToArray()); + Assert.Contains(log, l => l.Contains("-5") && l.Contains("Ore")); + } } From ffe43b41e88fe5d7100f4849498628c7132de1a6 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:01:39 +0200 Subject: [PATCH 050/230] Cargo store tests: the empty-stock purchase test reaches the charge (red) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index efcac768c..2495ca2b0 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -845,17 +845,17 @@ public sealed class CargoTransferTests : IDisposable [Fact] public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing() { - var station = Station().CargoBays[0]; - var player = ShipWith("Crate").CargoBays[0]; + // An old save's zero stack, as the station's stock. The field clamps the typed quantity up to one unit. var stock = Commodity("Ore", 0); var credits = 100; + var delivered = 0; var result = StationServices.TryBuyLots(_items, stock, StationServices.BuyableQuantity(_items, stock, 1, credits), ref credits, - lot => station.TryTransferItem(player, stock, lot)); + lot => { delivered += lot; return true; }); Assert.Equal(BuyResult.NoRoom, result); Assert.Equal(100, credits); - Assert.Empty(player.Cargo); + Assert.Equal(0, delivered); } [Fact] From ddae55759dc49530b505706e86b3b9aeca45b7be Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:01:42 +0200 Subject: [PATCH 051/230] Cargo bay owns stack quantity: TryStore refuses below 1 and above MaxStack, TryBuyLots owns the stock limit, restore logs dropped stacks Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 3 +++ Assets/Scripts/ServerShared/EntitySerializer.cs | 13 +++++++++++-- Assets/Scripts/ServerShared/StationServices.cs | 9 +++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index bc8087228..f98ef8c32 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2530,9 +2530,12 @@ public class EquippedCargoBay : EquippedItem // Try to store the given commodity at the given position // If there's a stack at the given position it will be added to // Returns true only when ALL of the items are successfully stored + // The bay decides what quantity may sit in it: a stack below one unit or above its design's MaxStack is refused with + // the bay as it was, so no caller, loot, pickup or restore has to check for it. public bool TryStore(SimpleCommodity item, int2 cargoCoord) { var itemData = ItemManager.GetData(item); + if (item.Quantity < 1 || item.Quantity > itemData.MaxStack) return false; if (ItemFits(item, cargoCoord)) { foreach (var p in itemData.Shape.Coordinates) diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 7f934b937..dc5b27c90 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -47,6 +47,15 @@ public static class EntitySerializer return pack; } + // A saved stack goes back where it was. The bay refuses what cannot sit in it (a stack below one unit, as the typed + // -5 purchase of old saves left, or above MaxStack), and the refusal is logged, never silent. + private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item) + { + if (bay.TryStore(item, position)) return; + var quantity = item is SimpleCommodity commodity ? $" x{commodity.Quantity}" : ""; + itemManager.Log($"Dropped a saved item the bay refused at {position}: {itemManager.GetData(item).Name}{quantity}"); + } + public static Entity Unpack(ItemManager itemManager, Zone zone, EntityPack pack) { return pack switch @@ -103,12 +112,12 @@ public static class EntitySerializer for (var bayIndex = 0; bayIndex < pack.CargoContents.Length; bayIndex++) if(entity.CargoBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.CargoContents[bayIndex]) - entity.CargoBays[bayIndex].TryStore(item, position); + RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item); for (var bayIndex = 0; bayIndex < pack.DockingBayContents.Length; bayIndex++) if(entity.DockingBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.DockingBayContents[bayIndex]) - entity.DockingBays[bayIndex].TryStore(item, position); + RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item); entity.Temperature = pack.Temperature; diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index 433b0baf1..457b57a55 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -79,12 +79,13 @@ public static class StationServices return BuyResult.Bought; } - // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. `deliver` puts a lot of - // that many units where it goes, and each lot is charged for the units it delivered. The first lot the station - // cannot sell or the player cannot take ends the purchase, with the lots before it bought. + // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. The stock is the + // station's: a purchase of more than the stack holds, or of a stack that holds nothing, is refused before anything + // moves, so every lot is units the station has and `deliver` moves whole, and each lot is charged for the units it + // delivered. The first lot the player cannot take ends the purchase, with the lots before it bought. public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func deliver) { - if (quantity < 1) return BuyResult.NoRoom; + if (quantity < 1 || quantity > stock.Quantity) return BuyResult.NoRoom; var maxStack = items.GetData(stock).MaxStack; for (var remaining = quantity; remaining > 0;) { From fa5820f516820c0e865cbe9ec9bc9be010893c9b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:15:41 +0200 Subject: [PATCH 052/230] Cargo give: the bay no longer holds a stack below one, so the give's positive-stack filter and its zero-stack test go Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 2 +- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs index 45208d948..32f49b611 100644 --- a/Assets/Scripts/ServerShared/CargoTransfer.cs +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -110,7 +110,7 @@ public static class CargoTransfer } else if (bay.ItemsOfType.TryGetValue(order.Item.Data.Key, out var stacks)) { - var stack = stacks.OfType().FirstOrDefault(s => s.Quantity > 0); + var stack = stacks.OfType().FirstOrDefault(); if (stack != null) return (bay, stack); } } diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 2495ca2b0..c6c80b398 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -697,15 +697,14 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(5, order.Remaining); } - // An empty stack in a hold is no unit to give: the give moves the real units and then has nothing left. + // A give of more than the hold has moves the real units and then has nothing left. [Fact] - public void AnEmptyStackIsNotAUnitToGive() + public void AGiveOfMoreThanTheHoldHasEndsNothingLeft() { var giver = ShipWithBays("Crate"); var receiver = ShipWithBays("Crate"); var grit = Commodity("Grit", 2); Assert.True(giver.CargoBays[0].TryStore(grit)); - Assert.True(giver.CargoBays[0].TryStore(Commodity("Grit", 0))); Place(giver, receiver); giver.Command.Give(receiver, grit, 3); From f7ba043b1358a4a39672edaec4b7591cbb8db397 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:21:08 +0200 Subject: [PATCH 053/230] Cargo restore test: share the fixture's provenance ledger Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index c6c80b398..58efc2299 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -880,7 +880,7 @@ public sealed class CargoTransferTests : IDisposable { var log = new List(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); - var items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, log.Add); + var items = new ItemManager(1u, _cache, _items.Lots, settings, log.Add); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var ship = ShipWith("Crate"); Assert.True(ship.CargoBays[0].TryStore(Commodity("Slag", 4))); From fd50f02f1b6224759ff56c30b9f07f943debae8e Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:29:24 +0200 Subject: [PATCH 054/230] Cargo purchase tests: a purchase of no units is refused, not bought Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 58efc2299..25245aa90 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -841,6 +841,20 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(0, Units(player, stock)); } + [Theory] + [InlineData(-5)] + [InlineData(0)] + public void APurchaseOfNoUnitsIsRefusedNotBought(int quantity) + { + var stock = Commodity("Ore", 10); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, quantity, ref credits, lot => true); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100, credits); + } + [Fact] public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing() { From d66b2a2c3c2717c321070edf6b7af28e5dcdecc4 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:49:22 +0200 Subject: [PATCH 055/230] Cargo restore tests: an oversize saved stack splits into MaxStack stacks and drops only what fits nowhere (red) Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 25245aa90..f65606916 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -908,4 +908,59 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(new[] { 4 }, bay.Cargo.Keys.OfType().Select(c => c.Quantity).ToArray()); Assert.Contains(log, l => l.Contains("-5") && l.Contains("Ore")); } + + // ---- Saves from before the bay capped a stack ------------------------------------------------------------- + + // A pre-f0d7c12c purchase above MaxStack left a whole oversize stack in the hold. The save holds it as one stack. + private (Ship restored, List log) RestoreWithOversize(string[] bays, int oversize, int fillerPerBay) + { + var log = new List(); + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + var items = new ItemManager(1u, _cache, _items.Lots, settings, log.Add); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); + for (var i = 0; i < bays.Length; i++) Assert.True(ship.TryEquip(Mint(bays[i]), new int2(1 + 2 * i, 1))); + var pack = (ShipPack) EntitySerializer.Pack(ship); + // Bay 0 holds the oversize stack at (3,3) and `fillerPerBay` full Slag stacks in the other cells; later bays only filler. + for (var b = 0; b < bays.Length; b++) + { + var contents = new List<(int2, ItemInstance)>(); + if (b == 0) contents.Add((new int2(3, 3), Commodity("Ore", oversize))); + for (var n = 0; n < fillerPerBay; n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100))); + pack.CargoContents[b] = contents.ToArray(); + } + return ((Ship) EntitySerializer.Unpack(items, zone, pack), log); + } + + [Fact] + public void ARestoredOversizeStackSplitsIntoMaxStackStacksInTheSameBay() + { + var (restored, log) = RestoreWithOversize(new[] { "Crate" }, 250, 0); + + var ore = restored.CargoBays[0].Cargo.Keys.OfType().Select(c => c.Quantity).OrderByDescending(q => q).ToArray(); + Assert.Equal(new[] { 100, 100, 50 }, ore); + Assert.DoesNotContain(log, l => l.Contains("Dropped")); + } + + [Fact] + public void ARestoredOversizeStackSpillsToAnotherBayWhenItsOwnIsFull() + { + // Bay 0 has 15 cells of filler and the oversize stack's own cell: only 100 fits there. + var (restored, log) = RestoreWithOversize(new[] { "Crate", "Crate" }, 250, 15); + + Assert.Equal(new[] { 100, 100, 50 }, restored.CargoBays.SelectMany(b => b.Cargo.Keys.OfType()) + .Where(c => _cache.GetByName("Ore").Name == _items.GetData(c).Name).Select(c => c.Quantity).OrderByDescending(q => q).ToArray()); + Assert.Equal(1, restored.CargoBays[0].Cargo.Keys.OfType().Count(c => _items.GetData(c).Name == "Ore")); + Assert.DoesNotContain(log, l => l.Contains("Dropped")); + } + + [Fact] + public void ARestoredOversizeStackDropsOnlyWhatFitsNowhereAndLogsIt() + { + var (restored, log) = RestoreWithOversize(new[] { "Crate" }, 250, 15); + + Assert.Equal(new[] { 100 }, restored.CargoBays[0].Cargo.Keys.OfType() + .Where(c => _items.GetData(c).Name == "Ore").Select(c => c.Quantity).ToArray()); + Assert.Contains(log, l => l.Contains("Ore") && l.Contains("150")); + } } From b027b36ab46f6d2ca7aecdc7968df43f36cf577b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:50:23 +0200 Subject: [PATCH 056/230] Cargo restore: a saved stack above MaxStack splits into MaxStack stacks, the same bay first, then the others; only what fits nowhere is dropped, logged Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/EntitySerializer.cs | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index dc5b27c90..5b4fb7f3f 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -47,15 +47,51 @@ public static class EntitySerializer return pack; } - // A saved stack goes back where it was. The bay refuses what cannot sit in it (a stack below one unit, as the typed - // -5 purchase of old saves left, or above MaxStack), and the refusal is logged, never silent. - private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item) + // A saved stack goes back where it was. The bay refuses what cannot sit in it, and the refusal is logged, never + // silent: a stack below one unit (the typed -5 purchase of old saves) is dropped. A stack above its design's MaxStack + // (an ordinary purchase before the bay capped stacks, or a catalog MaxStack since lowered) is a legitimate save: its + // first MaxStack units go back where it was and the rest is returned to be placed by SpillOversize once every stack + // has its own cell, so a spill cannot take a cell a later stack was saved in. + private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item, + List<(EquippedCargoBay home, SimpleCommodity rest)> spill) { + if (item is SimpleCommodity commodity && commodity.Quantity > itemManager.GetData(commodity).MaxStack + && itemManager.GetData(commodity).MaxStack >= 1) + { + var maxStack = itemManager.GetData(commodity).MaxStack; + var first = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = maxStack }; + var rest = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = commodity.Quantity - maxStack }; + if (!bay.TryStore(first, position)) rest.Quantity += first.Quantity; + spill.Add((bay, rest)); + return; + } if (bay.TryStore(item, position)) return; - var quantity = item is SimpleCommodity commodity ? $" x{commodity.Quantity}" : ""; + var quantity = item is SimpleCommodity c ? $" x{c.Quantity}" : ""; itemManager.Log($"Dropped a saved item the bay refused at {position}: {itemManager.GetData(item).Name}{quantity}"); } + // Places the units an oversize saved stack left over, in stacks of at most MaxStack: the stack's own bay first, then + // the entity's other bays. Only the units that fit nowhere are dropped, logged with their design and quantity. + private static void SpillOversize(ItemManager itemManager, IReadOnlyList bays, + List<(EquippedCargoBay home, SimpleCommodity rest)> spill) + { + foreach (var (home, rest) in spill) + { + var maxStack = itemManager.GetData(rest).MaxStack; + var remaining = rest.Quantity; + while (remaining > 0) + { + var chunk = new SimpleCommodity { Data = rest.Data, Rotation = rest.Rotation, Quantity = Math.Min(remaining, maxStack) }; + var placed = home.TryStore(chunk) || bays.Where(b => b != home).Any(b => b.TryStore(chunk)); + // A refused store may still have topped up a stack; what is left in the chunk found no room. + remaining -= Math.Min(remaining, maxStack) - (placed ? 0 : chunk.Quantity); + if (placed) continue; + itemManager.Log($"Dropped saved units no bay has room for: {itemManager.GetData(rest).Name} x{remaining}"); + break; + } + } + } + public static Entity Unpack(ItemManager itemManager, Zone zone, EntityPack pack) { return pack switch @@ -109,15 +145,18 @@ public static class EntitySerializer entity.DockingBays[i].DockedShip = entity.Children[pack.DockingBayAssignments[i]] as Ship; } + var spill = new List<(EquippedCargoBay home, SimpleCommodity rest)>(); for (var bayIndex = 0; bayIndex < pack.CargoContents.Length; bayIndex++) if(entity.CargoBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.CargoContents[bayIndex]) - RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item); + RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item, spill); for (var bayIndex = 0; bayIndex < pack.DockingBayContents.Length; bayIndex++) if(entity.DockingBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.DockingBayContents[bayIndex]) - RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item); + RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item, spill); + + SpillOversize(itemManager, entity.CargoBays.Cast().Concat(entity.DockingBays).ToList(), spill); entity.Temperature = pack.Temperature; From 9958fe55ab64c45632e216e7261155a58c0abc13 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:50:24 +0200 Subject: [PATCH 057/230] Cargo purchase: a bay stack is one lot, so the lot loop is deleted; stock above MaxStack from outside a bay is refused up front Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/StationServices.cs | 20 ++++++------------ .../CargoTransferTests.cs | 21 ++++++++++--------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index 457b57a55..153fc595d 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -79,22 +79,14 @@ public static class StationServices return BuyResult.Bought; } - // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. The stock is the - // station's: a purchase of more than the stack holds, or of a stack that holds nothing, is refused before anything - // moves, so every lot is units the station has and `deliver` moves whole, and each lot is charged for the units it - // delivered. The first lot the player cannot take ends the purchase, with the lots before it bought. + // The purchase of `quantity` units of a station stack, delivered as one lot. A bay holds no stack above its + // design's MaxStack, so a stack the station holds is a lot already; a stack above it comes from outside a bay and is + // refused, as is a purchase of more than the stack holds or of a stack that holds nothing. All of that is refused + // before anything moves, so `deliver` moves units the station has and the charge is for the units it delivered. public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func deliver) { - if (quantity < 1 || quantity > stock.Quantity) return BuyResult.NoRoom; - var maxStack = items.GetData(stock).MaxStack; - for (var remaining = quantity; remaining > 0;) - { - var lot = Math.Min(remaining, maxStack); - var result = TryBuy(items, stock, lot, ref credits, () => deliver(lot)); - if (result != BuyResult.Bought) return result; - remaining -= lot; - } - return BuyResult.Bought; + if (quantity < 1 || quantity > stock.Quantity || stock.Quantity > items.GetData(stock).MaxStack) return BuyResult.NoRoom; + return TryBuy(items, stock, quantity, ref credits, () => deliver(quantity)); } // The quantity a Buy Quantity field may hold: what was typed, at most what the credits cover and what the station diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index f65606916..a08df266c 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -735,31 +735,32 @@ public sealed class CargoTransferTests : IDisposable // ---- The lot a purchase delivers --------------------------------------------------------------------------- [Fact] - public void APurchaseChargesForEachLotItDelivers() + public void APurchaseOfAStackIsOneLotChargedForWhatItDelivers() { - // Grit stacks hold two. Five units are three lots, and each lot is charged for what it delivers. - var stock = Commodity("Grit", 5); + // Grit stacks hold two: a station stack of two is one lot. + var stock = Commodity("Grit", 2); var credits = 100; var delivered = new List(); - var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => { delivered.Add(lot); return true; }); + var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => { delivered.Add(lot); return true; }); Assert.Equal(BuyResult.Bought, result); - Assert.Equal(new[] { 2, 2, 1 }, delivered); - Assert.Equal(100 - 5, credits); + Assert.Equal(new[] { 2 }, delivered); + Assert.Equal(100 - 2, credits); } [Fact] - public void ARefusedLotLeavesEarlierLotsBoughtAndChargesNothingMore() + public void AStackAboveMaxStackFromOutsideABayIsRefusedBeforeAnythingMoves() { var stock = Commodity("Grit", 5); var credits = 100; - var lots = 0; + var delivered = 0; - var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => ++lots < 2); + var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => { delivered++; return true; }); Assert.Equal(BuyResult.NoRoom, result); - Assert.Equal(100 - 2, credits); + Assert.Equal(0, delivered); + Assert.Equal(100, credits); } [Theory] From c986adcf20479284cdb0d376f09b3266931d14c8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:54:52 +0200 Subject: [PATCH 058/230] Cargo restore tests: only the oversize stack's own bay is full, so the spill has another bay to go to Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index a08df266c..d057f004c 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -913,7 +913,7 @@ public sealed class CargoTransferTests : IDisposable // ---- Saves from before the bay capped a stack ------------------------------------------------------------- // A pre-f0d7c12c purchase above MaxStack left a whole oversize stack in the hold. The save holds it as one stack. - private (Ship restored, List log) RestoreWithOversize(string[] bays, int oversize, int fillerPerBay) + private (Ship restored, List log) RestoreWithOversize(string[] bays, int oversize, int filler) { var log = new List(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); @@ -922,12 +922,12 @@ public sealed class CargoTransferTests : IDisposable var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); for (var i = 0; i < bays.Length; i++) Assert.True(ship.TryEquip(Mint(bays[i]), new int2(1 + 2 * i, 1))); var pack = (ShipPack) EntitySerializer.Pack(ship); - // Bay 0 holds the oversize stack at (3,3) and `fillerPerBay` full Slag stacks in the other cells; later bays only filler. + // Bay 0 holds the oversize stack at (3,3) and `filler` full Slag stacks in the other cells; later bays are empty. for (var b = 0; b < bays.Length; b++) { var contents = new List<(int2, ItemInstance)>(); if (b == 0) contents.Add((new int2(3, 3), Commodity("Ore", oversize))); - for (var n = 0; n < fillerPerBay; n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100))); + for (var n = 0; n < (b == 0 ? filler : 0); n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100))); pack.CargoContents[b] = contents.ToArray(); } return ((Ship) EntitySerializer.Unpack(items, zone, pack), log); From 8b0b50382a56a86becf6b350821f83f56f2ac281 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:17 +0200 Subject: [PATCH 059/230] WeaponData: drop the dead [Union] list; BehaviorData's union owns behavior arms Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Behaviors/Weapon.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 12166d46c..cb42d6bd4 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -11,12 +11,7 @@ using Newtonsoft.Json; using CultMath; using static CultMath.math; -[Inspectable, - Union(0, typeof(InstantWeaponData)), - Union(1, typeof(LauncherData)), - Union(2, typeof(ConstantWeaponData)), - Union(3, typeof(ChargedWeaponData)), - RuntimeInspectable] +[Inspectable, RuntimeInspectable] public abstract class WeaponData : BehaviorData { [Inspectable, JsonProperty("damageType"), Key(1), RuntimeInspectable] From 1dce24a226277d8570a3a40eb17d282fd04c746d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:23 +0200 Subject: [PATCH 060/230] Pin CultLib 2.0.0 (42dce361) and CultMath 0.5.0 (7a8933b0): manifest tags and build revisions Co-Authored-By: Claude Sonnet 5.5 --- Directory.Build.props | 8 ++++---- Packages/manifest.json | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 190e89780..e17a8e5d5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,12 +2,12 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - - 45c2f4006f5f7dab357426cabd3197e8d0a17f66 + + 42dce3611a81adc5c5fac54cadbaa5637d6022d3 $(CULTMATH_ROOT) $(CultLibRoot) - - 9c1b06cd703bfa44352050e819b0d0659cf5978a + + 7a8933b085468e1fc6f9d3bce4a85e7d101aaca3 C:\Program Files\Git\cmd\git.exe git diff --git a/Packages/manifest.json b/Packages/manifest.json index 2dd2a3bb3..d4dafcbf2 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -52,8 +52,8 @@ "com.unity.modules.vr": "1.0.0", "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0", - "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", - "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.1" + "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", + "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0" } } From bd0293d6836e43f4a26a7d9c6021b0901a401ead Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:27 +0200 Subject: [PATCH 061/230] BehaviorData: LockWeaponData is arm 52; union-closure and null-gear tests Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/Behaviors.cs | 1 + .../CultLibAdoptionTests.cs | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 7150e47a1..6b899ee35 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -198,6 +198,7 @@ public interface IPowerConsumer Union(49, typeof(LaunchSuppressorData)), Union(50, typeof(ListenerData)), Union(51, typeof(ScannerData)), + Union(52, typeof(LockWeaponData)), JsonObject(MemberSerialization.OptIn)] public abstract class BehaviorData { diff --git a/tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs b/tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs new file mode 100644 index 000000000..bfe379d36 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs @@ -0,0 +1,38 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; + +// CultLib 2.0 refuses a union subtype no arm names (it used to write nil). These keep that refusal a build-time failure. +public sealed class CultLibAdoptionTests +{ + [Fact] + public void EveryConcreteSubtypeOfAUnionBaseIsAnArmOfIt() + { + var types = typeof(BehaviorData).Assembly.GetTypes(); + var missing = new System.Collections.Generic.List(); + foreach (var root in types) + { + var arms = root.GetCustomAttributes(false).Select(u => u.SubType).ToHashSet(); + if (arms.Count == 0) continue; + foreach (var subtype in types.Where(t => t != root && !t.IsAbstract && !t.IsInterface && root.IsAssignableFrom(t))) + if (!arms.Contains(subtype)) missing.Add($"{subtype.Name} is not an arm of {root.Name}"); + } + Assert.Empty(missing); + } + + [Fact] + public void NoShippedGearHoldsANullBehavior() + { + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + var gear = cache.GetAll().ToArray(); + Assert.NotEmpty(gear); + var bad = gear.Where(g => g.Behaviors != null && g.Behaviors.Any(b => b == null)).Select(g => g.Name).ToArray(); + Assert.Empty(bad); + } +} From fb8f2ea2fb7211563b3e491afb69ba32199782e5 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:32 +0200 Subject: [PATCH 062/230] ShipAuthoring: Load follows the pull's InvalidDataException, RefuseLegacyEmbeddedHull the nil-stamp NotSupportedException Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 4 ++-- .../ShipAuthoringTests.cs | 23 ++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index eafbacd2d..2f6db9e5c 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -136,7 +136,7 @@ public static class ShipAuthoringStore { RefuseLegacyEmbeddedHull(path); try { return LoadRecords(path); } - catch (MessagePackSerializationException error) + catch (Exception error) when (error is InvalidDataException or MessagePackSerializationException) { throw new InvalidOperationException($"{path}: a record does not decode as its schema: {error.Message}", error); } @@ -152,7 +152,7 @@ public static class ShipAuthoringStore if (bytes.Length == 0) return; CultPersistedStoreSnapshot snapshot; try { snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); } - catch (MessagePackSerializationException error) + catch (Exception error) when (error is MessagePackSerializationException or NotSupportedException) { throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error); } diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index eced4c6f7..35a346fe0 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -173,7 +173,28 @@ public sealed class ShipAuthoringTests RewritePayload(path, "aetheria.hulldata", slots => ((object[])((object[])slots[5])[0])[2] = new object[] { 5, false, true, false }); var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); Assert.Contains(path, error.Message); - Assert.IsAssignableFrom(error.InnerException); + // The pull names the record key and schema; the MessagePack failure is its cause. + var pull = Assert.IsType(error.InnerException); + Assert.Contains("aetheria.hulldata", pull.Message); + Assert.Contains(ShipModCatalog.HullKey("mod.skiff").ToString(), pull.Message); + Assert.IsAssignableFrom(pull.InnerException); + } + + // A store header with a nil format stamp is refused by the runtime with NotSupportedException; Load names the file. + [Fact] + public void LoadNamesTheFileWhoseFormatStampIsNil() + { + using var directory = new TempDirectory(); + var path = Path.Combine(directory.Path, "nilstamp.cc"); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot + { + FormatVersion = null, + SchemaCatalog = Array.Empty(), + Records = Array.Empty(), + })); + var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); + Assert.Contains(path, error.Message); + Assert.IsType(error.InnerException); } // Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again. From fbf88e85c514daaeba06b1b8f18abe704810b3ef Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:37 +0200 Subject: [PATCH 063/230] ship_cc.read owns its unreadable-path refusal, naming no path Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/ship_cc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index dd3ca78be..b7dca307c 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -119,7 +119,10 @@ class ShipFile(NamedTuple): def read(path: str, cultlib_packages: str) -> ShipFile: cultcache, msgpack = _libraries(cultlib_packages) store = cultcache.SingleFileMessagePackBackingStore(path) - envelopes = store.pull_all() + try: + envelopes = store.pull_all() + except OSError as error: + raise ValueError(f"The ship file cannot be read: {error.strerror}") from None ships = [envelope for envelope in envelopes if envelope.type == SCHEMA] hulls = [envelope for envelope in envelopes if envelope.type == HULL_SCHEMA] # Before S1 the hull lived inside the ship record. Saving such a file would preserve the stale hull under a ship that From e518a0b3d161a05029f86d6973d1186d6f9afead Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:37:40 +0200 Subject: [PATCH 064/230] packages-lock: regenerated by Unity at the 2.0.0 / 0.5.0 tags Co-Authored-By: Claude Sonnet 5.5 --- Packages/packages-lock.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 5d26bd587..7345ecc47 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -256,27 +256,29 @@ "url": "https://packages.unity.com" }, "org.gamecult.caching.unity": { - "version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", + "version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.60" + "org.gamecult.cultlib": "2.0.0" }, - "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" + "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", "depth": 0, "source": "git", - "dependencies": {}, - "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" + "dependencies": { + "org.gamecult.cultmath": "0.5.0" + }, + "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultmath": { - "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.1", + "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0", "depth": 0, "source": "git", "dependencies": {}, - "hash": "9c1b06cd703bfa44352050e819b0d0659cf5978a" + "hash": "7a8933b085468e1fc6f9d3bce4a85e7d101aaca3" }, "com.unity.modules.accessibility": { "version": "1.0.0", From f448957f3ad1d1de2c9a96477acacb53047683da Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:39:29 +0200 Subject: [PATCH 065/230] Brush: name AnimatedSimplexBrush and RadialWaveBrush as arms; pull-failure test asserts the record key Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Environment.cs | 2 ++ tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/Environment.cs b/Assets/Scripts/ServerShared/Environment.cs index 737033e4e..9d4994fa3 100644 --- a/Assets/Scripts/ServerShared/Environment.cs +++ b/Assets/Scripts/ServerShared/Environment.cs @@ -72,6 +72,8 @@ public class NoiseSettings [Union(0, typeof(PowerBrush)), Union(1, typeof(SimplexBrush)), + Union(2, typeof(AnimatedSimplexBrush)), + Union(3, typeof(RadialWaveBrush)), Serializable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] public abstract class Brush { diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 35a346fe0..cb4d12d5f 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -175,8 +175,8 @@ public sealed class ShipAuthoringTests Assert.Contains(path, error.Message); // The pull names the record key and schema; the MessagePack failure is its cause. var pull = Assert.IsType(error.InnerException); - Assert.Contains("aetheria.hulldata", pull.Message); - Assert.Contains(ShipModCatalog.HullKey("mod.skiff").ToString(), pull.Message); + Assert.Contains("Record '" + ShipModCatalog.HullKey("mod.skiff") + "'", pull.Message); + Assert.Contains("schema", pull.Message); Assert.IsAssignableFrom(pull.InnerException); } From da5ba653cb78cb0ba2029ba83b924db7b455bf60 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:34:08 +0200 Subject: [PATCH 066/230] Tests (red at 42dce361): a CultLib 1.0.60 run.cc opens; Load names a ship file of a newer store format tests/vectors/cultlib-1.0.60/run.cc is the run store Soul wrote with the base build (cl2-old-run-save-refused). Both new tests fail at the 2.0.0 pin; the first needs 2.0.1, the second needs ShipAuthoringStore.Load. Co-Authored-By: Claude Sonnet 5.5 --- .../RunSaveCompatibilityTests.cs | 33 ++++++++++++++++++ .../ShipAuthoringTests.cs | 19 ++++++++++ tests/vectors/cultlib-1.0.60/run.cc | Bin 0 -> 53949 bytes 3 files changed, 52 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs create mode 100644 tests/vectors/cultlib-1.0.60/run.cc diff --git a/tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs b/tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs new file mode 100644 index 000000000..8b8c98217 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs @@ -0,0 +1,33 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// tests/vectors/cultlib-1.0.60/run.cc is a run.cc written by Aetheria on CultLib 1.0.60 (64 zones, factions, one entity). +// A player's save outlives the runtime that wrote it, so the current runtime must open it as compatible drift. +public sealed class RunSaveCompatibilityTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-runcompat-" + Guid.NewGuid().ToString("N")); + + public RunSaveCompatibilityTests() => Directory.CreateDirectory(_root); + + public void Dispose() => Directory.Delete(_root, true); + + [Fact] + public void ARunWrittenByCultLib1060OpensWithEveryZoneAndFactionResolving() + { + var run = Path.Combine(_root, "run.cc"); + File.Copy(Path.Combine(RunStartTests.FindRepoRoot(), "tests", "vectors", "cultlib-1.0.60", "run.cc"), run); + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(run), AetheriaStores.RunTypes); + var saved = cache.GetGlobal(); + Assert.NotNull(saved); + Assert.NotEmpty(saved.Zones); + Assert.NotEmpty(saved.Factions); + Assert.All(saved.Zones, zone => Assert.NotNull(cache.Get(zone))); + Assert.All(saved.Factions, faction => Assert.NotNull(cache.Get(faction))); + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index cb4d12d5f..850e83dfd 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -197,6 +197,25 @@ public sealed class ShipAuthoringTests Assert.IsType(error.InnerException); } + // A store format this runtime does not know is refused on read with NotSupportedException from the attach; Load names the file. + [Fact] + public async Task LoadNamesTheFileWhoseStoreFormatIsNewer() + { + using var directory = new TempDirectory(); + var path = Path.Combine(directory.Path, "newer.cc"); + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual); + await cache.FlushAsync(); + } + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + snapshot.FormatVersion = "cultcache.store.v9"; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); + Assert.Contains(path, error.Message); + Assert.IsType(error.InnerException); + } + // Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again. private static void RewritePayload(string path, string schemaName, Action edit) { diff --git a/tests/vectors/cultlib-1.0.60/run.cc b/tests/vectors/cultlib-1.0.60/run.cc new file mode 100644 index 0000000000000000000000000000000000000000..fc2a4e76e119257e70ccafe82b3916de79159aef GIT binary patch literal 53949 zcmeHw36vbwb#~99(aIvgpbe?D00OB*?TeOfjYbjzBP2^QI5u8tc_lT>OpERrK*Vtf z1nl_7Sn3%Xfw95Akr)ILn;iqOVIsiBh2z9oFaxqpaDH&GgK@$k@%AgPz3;wTeUF;Gojs;*?jXyuJ?RWtb~ShV?L!x2cj&w%FXwqy z77alr8sSWvS2$iKL^f4P7Ytq$Ox5BzlV=T9BbsIMGA9bQuG%Oq5|IJt|%Ef%_zWU{GrH_CCi)H`5Hp>zK+;OL^ja`EQBEaozb26Z`_lBWw6|zG!xHVjdO)?{ zQ@v5oW&@%<_JQ6`Qcl>|u&;~~(Whh!$)#rg2@f}ng+W1&_$67FSBEr-I>;V#E*S^Jdxnzf3nBdGDfS$ z+_ksqUJB9&OZa*C@Zd9pdz<&UkUMm44Y&w^`I*Cy`bvtAvWExvw%kiW(|?AGm3Rj3 zwBGN7*ifoS!}x>OGtYd>r?UzafvyQ(uZAqu-eB@m$CmWf>P&e&RHtNORx3UA>9B;U z0w&ceO?x6-spd0Ic&v()sMhW&k5o+Bp=HPDxGIyi(DSG6FHE9GD&~%RKhV@g$>wy~ z6b#emOj$83TO=IeY+e^Qm572a@PZ<6HfQjrDO;MQ3LLRy!iT{INd9 z5C#QBKDv&+S%(JxKSehVpl~fjvra7sUov`frE&Uk0DPNw=-Ky_lt5KqpSqSHXX@6majsZ9{4|t=dn!IV*=k{W?TdfC6xcX2TGt0yX1cGc@ zBBm{@dP%eup71PZ36`vJx+p;iNU*#uT{ktwk}Z>y3ilT}FQ7nPyEY8+6NhYeHLI%Cua$q<) z5*eo zon%7lqM{SgCbFnnmTB0ErW=wb={(OvStOipDvE6hMALZSfFu#i)gN_{g0P!}EBwgD1B)c3+YE4~E~OVCyv9OSv(m*2{%5Rfd%$Nzr9PFo>o~yd)SJRx$)p z5}~)qvWda1ss^W+8fUVSXxO&R3ARL7O*VU|EXz^5mKNM}O?QX= zLp6L7Tj`6@Dv9y+#Z(#i>-B=;J(z_xXf0M~6s3W+wjtd`%Gg@Z2n~<-ahGD98(xtM_bi6G^Yq+B~Lp>n0Z-a@{ zaC($#^q|AAD)t^y6*$n>=;oE%GwI&$@PZN+{?eJ?&Q{XX1Mdq3*J zDTv`pRlptHjS|j*iq!53=QVm}4VHe3Z1GmYDQ1-4Zxn@r;&fqlTQ52g%OVv+Y)v}d zNp$Kz8vmZ;0-V@FI^C7a?2gpVP)8PVD}1+6+zJ4o0|P8oMbm7Y@ZfZ}zpso8wU1f}DYV&gzpovP zS(~i`9$Z&Z^C#R(;j_w(wg*CRR^|RgEQ0uOSAdr)r6wH+g!?UG|%9ua;l-Doi~P2q&+_qD(5ZIPG9q8f3pwRYVkuo^W4r>Q;Hv z6Yr%n$@BRZCXibzu=Yic6D)z{BwLqZkr0*FL?ZE`t{6lSG!5Qf*%Dcm2)xAdl4kNc zk!)EZECRA5-YBzyu7jQtvVkmEJhCDiqAD6PmWOzlP(-j5T{1L@w^;?Iy@ACdUK3=| zgwexFwynvqnPfrI2>WtN-!W|<`Z!4OXsbd2cs1OaTc(}`AoZRuW%zbL08#DM6<~fr zUkpK1>8NiAZU)fg3Bw8)Z=~uIfYYXH5C>MBmWD7UFfuyQ-96~5P(M+`*$iEFJP8S- zW@3-g@9*{Vw5qa`es(iMc* zV%^?;dGbrcip|2qFhHUS%|KK&TxcE-cO6xz*|=lIafhf zH9=M-f)4c>-~bAId4)*5fZEbXn{f_z4Yd8h5V|pt*cSWHwe zbNV-`&RWkKR?wW>=8nX+_7BBbfz<>_WqH_YqF`w%!j)xFlx4-1M8y#Ny#k|+`W%|i zv%Ji3EW`8LIBhvEEtkY)GAF2_sw`pIsR}BwL=mn86*0An zAnK;gN*sc*oDO--O-}N3XGYnZm&_Xc<8`<=vrPv7VyHvQqhoK@fa%9(}v1(r;C&Pn|L zi#naeiyP)RiHpDgH~mg>?O(;5Wb0GcJBd3E2~Ohs^kU-0{yrzM@6ew+iQZpa?j&d4 zaVJWyc7pLCLi>a*A%If*wisB`a%4wpVmWD)zN5#+4!rfrJ|w??Fr zscS?s49-&d&_o_>_~dY`);M_Xi>y}jg99ExL-AVU+39%guBQu(#*5VImhm6`qNa^d zBK_#3HBRCW)0<02f2~Fj~sUFAVyRu68gZA*cpQZ9!VGvwPerwiQnE7OjCD; z4mVA6{m!bd{QbSosypu3i+Sb2pgV`0#7taFob|mK?r9(HKYLH15ETKKoIgMPw@%`9 z+b$HR7r{G?x@r-QYD)%$v?OUZOLP_q3q(u6Ki22n_{}>>^Mo~>cOF4Qaf`EbLseM> z$LgY@NV=@S`)p%6YNCqxTW4+4LDQdi2FtRlU~z&DA+H&-hSfa?Y*jH?jzjFB|03g~ z(;$Wz?rv`oj6|p5o_(cs-eF6L&9a&($ucWq!-uY_JOU<>L&CPPmCf|uWW0tm-0zZH zGMqhg_p*B8m zBJrA}N**ANWISJG2vVTv65%*3^N0q|8@!?kSYQ{4p$V1}yvev}Q34j03Vrfi zQ>&{+c{B~hYsn2~-RT>52Ll>HH|7i{pZ7wi`H>QL5>MZhbP~5bCl=MHRd@c;T4&WY zPyWhT`R2ttox~{@o(1J-)}Bu~iC=&GpPj_&^hcaT&b@f*O{o6URkt{aDQ_Nf60dZ9 z6D8-qE<1^>zh3Gjr{1^5NxIb&zg+kwC-KCuP-xEILyeMO=|;(U=bYgr{_~`(oW#nv z00AGP)e}!l`4eZ=59ITl#68dd(3SoW{D{L=6am{{SqORo`XY=j4SQHM)zYv7BFIWJ zUQ!0zXcbTJ7eLb^X((RH7;@I#^M!8&ol+AtDPFF6>VhM~faa!$H~XN8?oCdxepx(s z@iOu6YrEZZpLaE^)p3_H$Oa^MbF*ACdUoy;=R0fle@OQaU9!D1ZRnlLiwTTI8|dY~ z)~Fk*>K0I7{c>Cpc%Ey0>Fy)3)epGw|1|c6VME7U=$H>33!!5%bS#CA<wSK1=~0 zrhpGqz=tUi!W0N$3WP8PLYM*}Oo0%lKnPPHgeefh6bNAo#4rV7m;y0Off%Mh3{xP6 zDGqsgqs@B;iv1a1sdm4_B_vY}hhF&V`+Bu3;+iBT<`Wh@i1 zY{+9~VB7J~BAvQBSX*Q$dNXhkDvXMZXN+xQkFp4JOB1!BMXHE=XPPWCtfi_9l9wrr z&a;GpEry+KH0I8xPqb_uhZ-ZBvcQPEt}q%>Gcd5r*-8UJ6K9x$s9*`WCh>*!@ht+5MZ?R3Vo~ zyOYe9%cE{+c)9F3IiEXoXKw9JVq(*6=y4t)I;<`+oPi$^!vQ_6a*V+vH3lzY0~lxJ zVtb+u4JXMDoVa^vkt`4s`^R`jkvN?ZC6h3SpQO)VS)Dg67DiF?Z9{P!WAmnteP?hO z*&+rNF`+=h3oOE2#1+OkNr7BE9JShx~df#Fmc%)q>2K$6BFKcFE=Dv|^c zeFV%nZ`rMTC?Q#rxjLQ7KwJFHPcRcP3rxaVj4fK27nX^KAR3Cn)>7mlU=>Z#v&LlP917^EH$Wb903Kp`frD4_Q=LuYt6O5wf5 zO`UU+@oVPo$vw1#Af&&0dmLL_yXbIF{OZSl0sWb%tcrcAswN_S3m!ZKzd|G+P)k#Z zBuf)PjsWIK=v4-`xD5fq@eKC8N{j^OJtDbm7Bxpej`r|Fx!nE8^M$=4$Oh#k|Md1$ z?m?QdQQuDD*n$gpKd>Cas~@r*k>%O zETTD`5 z>df27_;e;^h>CwJ~Hq@L)qdu7I|D06U&(H!=vHRHB-OYm%ER2rFvZH zcKYOrxq%EZNec1~D=BCEI`&GHoUo6k-A`czel;+Y7jtUUbMtuD9YcaZ@A|r8-ZUc2}s_8%<*02W7C`dA)i8^4E z=jY?$V=NaJe}5>VTwH*MtGbDZYp4Y_Pz(rV08C_#V+4Vdb?EZQQ^6MJ6{p#nVKOYT z8bO>v(1LpcgPAH>yoTK_hUp~Fdtpf+7ZU@CODg1I;=wZ^B_%EUjq`-9PB;v$!rF_nqUfBY3+CdNPgQi)7#efRa+ zGVv_XG`2D^@uIvxqD(xSGGuIJ;*xE*hGb&mjeC9{mWgxE)~d@yYJSz0iHkw+7|X=j z@<549O#JKwrd%dY-+UMN6hTKlH>ymGVqH6lH(x%ZR3?5hx!RYBmtVgaql<$D0o|E} zQy8!<8lsJf$nmNrDcBjOorV30p*M2_Jv%bJNVyL5S|~o1yYCu316#~; zk|*8qRrty^lj9Lk1;AI3Oit7}izm8_co3}QBB*F7?JFaqbc)tjQ?Gu~mrku4ZVgMP z#FPK<17Io)A0UJzB7{XY;S(?o0~#Ar<-ww{2+M#bfuqVdlq(jwV9<>K48(iDUxe5+ zx(3aOTA>@GVQSkSrcvocFQC zRD}idGdlJaF_OeW(dP}F*9t0ZG%aKLFMkn|PRYsF?h8q$#1mH@t}dMtgSk&(vLV6^ z4lN7m|71AlY!xegutg97qheLrV$TP?5+T@73hx4v>_8L6_+(THwkixuEC>pCZ^IpI zkt9HJ`r?zT4Ytis{yaR`R%bpF47S8~ZkPi}ij-9FznMr5Wb3fX(9IfAU|tKBXd%{* z1+5m+{@B<{%b1#qX-6saQ7f6V0uQr%a$+m~TX9r&}{ z{_ga3*LA|dH~IYO2+)DI1-p0^SWJctE5YDQm?LmXfDpQsi~~_J3Ac^|2l6gn1!2quAM!5@n- zHP8hPsRgf5G+r&{yGwPL_IH=UMS}#A@I_%ITEarU1`P_CEFs!hm_-^kIEx@8GWU8!gHdK8L0Uy$sLSOPyYfmZk zNiA6)`Xuq9+R!J1Sqj1dWjcD0%L>M(0=KztC?W&n7fu_b<|KT<*wo>_fd0!eaDKt7 zpo+i1vz~hsG80+uB%~##zA*wIv*3P!Jc5ga z#f*i3hV)hyI#$ONcaw9kZVuzF@Xj^0aaRQyBf#C-3ldJ^C!1faA_^04fBw9%D4g-p zdqSe{owgqi#bJnYEEY;^nEHZ%=p!UOL&9k!KtqmL4JHq0jf}g|ruQk?ull%q-hFSD z;O_G?=GC?emV>4-#@(gPmm}it3Osy_areZX{~p5K#P4oC7{cA;^yZDj;BL=NU#pF~ zD?#rV<8H@gk5?IN$%_^}93E_^tW5@kEh%Xqt&O{@Kr69Xzy%x#8a_J&g$lZlVE}<` zP9`*KF@XcRsE|gO7)dUDO9}3te0YnQuZ9hYQ{|keU5w2EZ%N=0W7-Mg1%a0>q?`k|avb`Hk zmNO^iAvdG=0Q*CMDM38$X!B82k7b75slr!Z3}8GnzklnF-tJv`4l!wW!~C4^)?NM0 zGv_bLHBFc>7^W)-7aKpi6oo6&H820bW%~B*dfffZIZ5vP=bgk?`sLh&wmsO=&>xPZ zX#~r#oaeJ_pd;1QMZZx4rrq(xY2e&<7WK8nr9DkerGzztXBy{iADNdwusM_JBJpkb z0m~M(eZon8;GLGfiKjDrnwv{WZ5ldigN4O z{U@J#+KgONOBvTfo6|;_&uRD>0MA#@o$K{Xd@H^*1kxV*(m%rf{LoE(C#_8GX>Bd# znb-)P@m_54!u-K)I|wE~rYpVypL9cywq4lfB;I=A-M-Ui&dW_`!2}IYgHQncIP%R4 z=vg9vUEiEV z%L~#s+_^yv^*GK~#be+6{K1R$?Z`ljXp8k&2*ubtXV=qCvgOE+bFHUW2otRlY*W3h z7r?9yySAqgY=a^9i}g<8sVO(~PdoXHW$4eA63l9ZkVEjDk68mB?(W3LZYaMJKVP*J zu|_AH*B?7==HiO}ce;u6aVKIN=-7wVLJ)xtaNW zH0rMhWXT6lb&@B1<{P=Gr=yd*_k;PT~i5-`qFtv}JqfYz@gcz5xIR z-<>CY&&==Jrg!OCQ1#p2y#vj6)~9caHJ^Ce2Qb)5k(F-%c7ZSt_!s3L+NfW94T9X_ z8wky50uqxg%_m{+$E>ct56rGm=J*D{7Ik_S?JXta)zrX!T9heHTzWdqVV`I&dXZohko?qFg4Zti7QPoAB&Cfr$ zLGQHV8;PFn%~0`n%4f0L`kwe-_RT$KsXG%wHee&;iq>uv?Hn1k-`U*X)W@^~9u;_QLtEIf<`- zeOaz;VYwGVXb5`o-sY*Ko*!7(-oYsNIy?x|zP8XwyzpQ9bJ&M)bnor(O4iz9jF9&l zn>OQKCW>K`W7OVaSbOJw?Ys~*cd*}Cd($!)Qfv2k?Ys^(H!arA1yDQJMD1PiC{b(Y zaP6!)H8TX=mWv&UoxcC0q&wBK+Z_njou{!JKa;+1; zTzeBp@sVqukI_dqAI;^aHsgs=A_rRv)E29@#6WD+s!f$u+af--vg(8;RE@Hqqx4-T zwB~Xxv}%;a1+VG`mrWCoVvW2B?2Y8|F4pb1&aK<#)@`BBjj*+$Sa)rqZtKy(zG>5r z#C8XHKAetQupc+VkGn0q>?cAsw@6SeYK`ONB6Z69j!h%)>7Yh%6!o?I8Wp3O3Kb{9 zkKaOQMntIQ776M`t#Q0uq)vI?v1#Nz9n=VpqP~`2qcEy-hY!<#&pcE8U*T?)GQ+iS z{Ca@{QFs`k%;27*djRjTSPt4u%}unPVo=bZCLP_gd-oo9Gjy~K=K0<3Uu~82Xv|r6 zhSqtwpjB?8U$*Ii4iRPNODTynj^0_+D(k5WwTJ3>QT76QkDcbKJu_Q!xe4`Gty8m{ z1*LRiRHd|3pQTk>sG1ffLd|>E!YG&xExKg_WmAMLccoS5Kq+mdRikVU@v0V-(lb%D zsTEa6uFuY+1VaI@xvk85;!d|u2_bhiy}_5J zg>u!31lQcc!Rh<+C<2iYUfM5G!NvA_pgWcQ_@xV6Y>(3-Z&&%DH{8O7BF8KjD&m%_ z^XDE)Mzy-b(dQP9;jdV2xN>fh;HQiw?x}ybz{M(9?c;ML%;Hw%#j(9o^$}G`axV_< z2L6l;fFRI|5L7EuFd&GsYxEE_e-scjAEL^IpeWleJ#keKkPAErs;_FD5VWv<2%=T% zgrG(BL(p0EL(tjvL(t;-An4ptK+rkIA?O%R3STk~kGp=6;}CQlf`EIYhoD+%_k9Rz zqQ3RDSERbTQ_y&$?7JyqLD7?X-q9nmnVuaKVb@N1?Od*T5*{37r;P_f&$~MF5`fU; z`m450rM5wYy{e^8T?*;i=K7o?Y$+|R%0Q;KQHvzX#uTq=0dSY0>I6z~lszI|)txSz z&Ig+&)nB!Zu9`&IW!XGP|6ETamOt2L5KNZ@>Z>}j{;I9iaEtQP(&Z0Ia4Tg~eN`t% zeQK#`OrJVA>QgJLww)M}U~fniW)6L7GhHQ$vQgEmS{R5NM$^>#tGZTol)W&eqlu^L z8yyn$Rh?Xa)v2^!>w9VwWz)z#2qFYbGo-`_JqQw280k7%lszlOK6P!7!9kg>qfMZ7 zqip#s)?GXKdb-RhP|fee=|^I36_guakqMm+>pQ4c_}JHmZK~|Xv0Dl5mQ_3UX;5w0 zqhmJ^;ci*W5o$y&60{t(M$~d{%TWve?<+UfuOD>O*tA^MUT`|1t_Z3{IEvbGgr=$$ z8MgUqg-fs1l2z?--xpSTRN72Px($1y-EF`%XWXOVeq#8G-k#e^&}c+!soFCNPD{7F zVSj?=s}w0~wB0QdkQSUC`&yM}1T9x>)NT3LKS86V4|iJ*xoc@FYy6*(U-Q__yLU@l z9-l_5Jt!(^0T28dG5k60?&&e94;2Z@4c8vp^gP``VfPu;eyX(6?w5<^?ioM%7Itto!^g?_Q;@ZjOxX#H5q zsQg$8wc6@)Myd7tv1rvg{a9N4eyrv7`>|Hk@5fqMzaNWMb^TaTvX+#39#(mNtoo|D zeysYcx_+!ERZIO?lwjA7RbN%tj}@hAsWXZ`)%9c5SJm}nMXBmJqY8d3`c&7C6(#YD zS2gc?MH3hjt{4{G>1%4C^{g6W-UTJ2xyVpwn1BGjT z;TZlxK7+#DGKWC52Fk9Lozmumma8^Oixh4QdlWS49{J^B_zQ;}(i_M9SQRX3r+g{% z%$1+gA~F1he#Ux811q9TKG$+BdN@A*3P?ef2kkK-J9ZEaNyIQc!AC*`nUUVI5&;ZkQ6X{kjCWR zjk=qZ@w?luK;E}4`)`X)kI%2nBPBE>^^4%83PDbUt@tnzvzcVNboxa^q#l@bF1}~H z^!}4`=cpA~I2w}t#s5z3+4+at@y(>|-Sp#i@il2ZLlbf3Ok^^-`{3ceN#`x8$oM8S zBn^zcC?LPhR~VtSg{g=7+< zB=VqZ#VOY!J(xNrcgn)uM@v#Pi490whA(b;DMFCxZ6iLs*+aAWIFD?05^ukFXMgj_ zahe;XGL(;v7-q1%z#t(N}NRZQ!?#}jPdd*^@p+jw#AdBkB zkM^H&Hp>+<_=R%5hz*zr_;Qt(=M70M3;hZrdopR7WhlPmPw{enbr`rlD+Y^kS3zbY)^H^({_Aa zCX=Q~ULW}ivTl9ihHvGT$nJCqVO9fXgNiTF_~|weZ0POU*|RIYHcip={2w6&(r4a& zu&=32EKklPH3YY0Zz{~p-@CPk<|s?7xOszJk6Hk2wkLnn}3{=akgYp;Q0gzu-F$m#w%gcftynw9qtYfE-itE$ZUbguY0#WLxn|@UiOn z#rT*t7 Date: Thu, 22 Oct 2026 23:34:24 +0200 Subject: [PATCH 067/230] Pins: cultlib-unity-v2.0.1 at b8fc5eb0 (1.0.x saves with ref lists open as compatible drift) packages-lock.json follows from the Unity resolve. caching-unity stays at v2.0.0; cultmath at 7a8933b0. Co-Authored-By: Claude Sonnet 5.5 --- Directory.Build.props | 4 ++-- Packages/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index e17a8e5d5..05d5e4d22 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,8 +2,8 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - - 42dce3611a81adc5c5fac54cadbaa5637d6022d3 + + b8fc5eb07720a187a0421424ac2cb3aa2966ca1d $(CULTMATH_ROOT) $(CultLibRoot) diff --git a/Packages/manifest.json b/Packages/manifest.json index d4dafcbf2..7693bd94f 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -53,7 +53,7 @@ "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0", "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.1", "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0" } } From 7886f0a231890ab3f69932feec1e8d3a97e17926 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:34:26 +0200 Subject: [PATCH 068/230] ShipAuthoringStore.Load names the file when its store format is newer than this runtime reads Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index 2f6db9e5c..3962fa468 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -140,6 +140,10 @@ public static class ShipAuthoringStore { throw new InvalidOperationException($"{path}: a record does not decode as its schema: {error.Message}", error); } + catch (NotSupportedException error) + { + throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error); + } } // Key 1 of ShipAuthoring held the whole hull before S1. The type no longer has that member, so a deserialized record From fba7bd1b4763e08e145a0a21b99eed4720143cb7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:24:10 +0200 Subject: [PATCH 069/230] Station kinds: StationKindData catalog list, HullData.Kind, placed station Kind; GenerateStationLoadout takes a kind (the kindless draw is deleted) Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 3 +- .../Scripts/ServerShared/EntitySerializer.cs | 7 +- Assets/Scripts/ServerShared/ItemData.cs | 6 + .../Scripts/ServerShared/LoadoutGenerator.cs | 10 +- Assets/Scripts/ServerShared/OrbitalEntity.cs | 2 + Assets/Scripts/ServerShared/StationKinds.cs | 128 ++++++ .../Scripts/ServerShared/StationKinds.cs.meta | 11 + Assets/Scripts/ServerShared/ZoneGenerator.cs | 6 +- .../ConsumableSupplyTests.cs | 17 +- .../HardpointOwnershipTests.cs | 2 +- .../PickupGenerationTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 10 +- .../Aetheria.Shared.Tests/StationKindTests.cs | 378 ++++++++++++++++++ .../StationKindsCatalogTests.cs | 53 +++ tools/AetherDb/Program.cs | 5 +- tools/AetherDb/StationKindsCatalog.cs | 82 ++++ 17 files changed, 700 insertions(+), 24 deletions(-) create mode 100644 Assets/Scripts/ServerShared/StationKinds.cs create mode 100644 Assets/Scripts/ServerShared/StationKinds.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/StationKindTests.cs create mode 100644 tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs create mode 100644 tools/AetherDb/StationKindsCatalog.cs diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 525894ac9..47128ef6c 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -7,7 +7,7 @@ using GameCult.Caching.MessagePack; // Aetheria's stores: one home store per type, composed here and nowhere else. public static class AetheriaStores { - public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb) }; + public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb), typeof(StationKindData) }; public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) }; public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; @@ -42,6 +42,7 @@ public static class AetheriaStores foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); VerseGrammarValidation.ValidateAll(cache.GetAll()); ValidateBins(cache); + StationKinds.Validate(cache); return cache; } catch diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 5b4fb7f3f..7f0f04ebd 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -16,7 +16,8 @@ public static class EntitySerializer { Orbit = new CultRecordRef(orbital.OrbitData), SecurityLevel = orbital.SecurityLevel, - SecurityRadius = orbital.SecurityRadius + SecurityRadius = orbital.SecurityRadius, + Kind = orbital.Kind }; else if (entity is Ship ship) pack = new ShipPack @@ -120,6 +121,7 @@ public static class EntitySerializer Restore(itemManager, zone, pack, entity); entity.SecurityLevel = pack.SecurityLevel; entity.SecurityRadius = pack.SecurityRadius; + entity.Kind = pack.Kind; if (pack.Story >= 0) entity.Story = zone.GalaxyZone.Locations[pack.Story]; return entity; } @@ -214,6 +216,7 @@ public class OrbitalEntityPack : EntityPack [Key(18)] public int Story = -1; [Key(19)] public SecurityLevel SecurityLevel; [Key(20)] public float SecurityRadius; + [Key(22)] public CultRecordRef Kind; } [MessagePackObject, @@ -237,7 +240,7 @@ public abstract class EntityPack [Key(14)] public EntitySettings Settings; [Key(15)] public CultRecordRef Faction; [Key(16)] public int[][] WeaponGroups; - // Keys 17-20 belong to the subclasses; the base's next free key is 21. + // Keys 17-20 belong to the subclasses; the base's next free key is 23 (Boss is 21, the orbital pack's Kind is 22). [Key(21)] public bool Boss; private int _price; diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 170c2421d..d9e972a85 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -549,6 +549,12 @@ public class HullData : EquippableItemData [Inspectable, JsonProperty("visual"), Key(32)] public CultRecordRef Visual; + // What a station hull is built to be (StationKinds): set on every Station hull and on no other. The hull names its kind + // and the kind names no hull, so a package can add a faction's model of a kind without editing any kind record. + // Key 33 is the next free key. + [Inspectable, JsonProperty("kind"), Key(33)] + public CultRecordRef Kind; + // The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world // units: a true bound on how far the hull reaches from its position (the half-diagonal of the bounding box is not, // as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5. diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index b75f0d30a..ce8569965 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -74,9 +74,12 @@ public class LoadoutGenerator return EntitySerializer.Pack(entity) as OrbitalEntityPack; } - public OrbitalEntityPack GenerateStationLoadout() + // A station of one kind: the hull is drawn among the station hulls the kind calls for in this faction's hands + // (StationKinds.HullKindFor), so a faction with no model of the kind builds its stand-in's hull and keeps the kind. + public OrbitalEntityPack GenerateStationLoadout(CultRecordRef kind) { - var (hullProduct, hullData) = RandomHull(HullType.Station); + var hullKind = StationKinds.HullKindFor(ItemManager.ItemData, Faction, kind); + var (hullProduct, hullData) = RandomHull(HullType.Station, hull => hull.Kind.Key.Equals(hullKind.Key)); if(hullData==null) { ItemManager.Log("Unable to generate station loadout: no compatible hull found!"); @@ -85,6 +88,9 @@ public class LoadoutGenerator var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem; var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings); entity.Faction = Faction; + entity.Kind = kind; + // A stand-in hull keeps its own model but not its own name: the station is what its kind says it is. + if (!hullData.Kind.Key.Equals(kind.Key) && ItemManager.ItemData.Get(kind) is { } kindData) entity.Name = kindData.Name; // Hardpoint gear goes in first so the docking bay can only take space that gear left free EquipHardpoints(entity); diff --git a/Assets/Scripts/ServerShared/OrbitalEntity.cs b/Assets/Scripts/ServerShared/OrbitalEntity.cs index a2c511ba7..be1bd893c 100644 --- a/Assets/Scripts/ServerShared/OrbitalEntity.cs +++ b/Assets/Scripts/ServerShared/OrbitalEntity.cs @@ -17,6 +17,8 @@ public class OrbitalEntity : Entity public float SecurityRadius; public LocationStory Story; public bool CanTow; + // What this station is for (StationKinds): its own fact, which under a stand-in hull differs from the hull's kind. + public CultRecordRef Kind; public OrbitalEntity(ItemManager itemManager, Zone zone, EquippableItem hull, CultRecordKey orbit, EntitySettings settings) : base(itemManager, zone, hull, settings) { diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs new file mode 100644 index 000000000..18c200092 --- /dev/null +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using MessagePack; +using Newtonsoft.Json; + +// What a station is for: one authored catalog record per kind (mining, trade-hub, shipyard...). The catalog's list of +// these records is the one list of station kinds. A kind names no hull: the hull names its kind (HullData.Kind), and +// which hull a faction builds for a kind is derived from the products that faction makes (StationKinds), so a package can +// add a faction's model of a kind without editing any kind record. Records are keyed station-kind:. +[CultDocument("aetheria.stationkinddata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class StationKindData +{ + [CultName, JsonProperty("name"), Key(0)] + public string Name = ""; + + [JsonProperty("description"), Key(1)] + public string Description = ""; + + // A faction with no model of this kind builds the stand-in's hull and the station stays this kind. Ignored once the + // faction offers a model of this kind. + [JsonProperty("standIns"), Key(2)] + public List StandIns = new List(); + + public static CultRecordKey KeyOf(string id) => new CultRecordKey("station-kind:" + id); +} + +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class StationKindStandIn +{ + [JsonProperty("faction"), Key(0)] + public CultRecordRef Faction; + + // The kind whose model this faction builds in place of the owning kind's. + [JsonProperty("kind"), Key(1)] + public CultRecordRef Kind; +} + +// The one owner of which kinds a faction builds, which kind a station gets, and which hull kind stands in. +public static class StationKinds +{ + // The kinds this faction can place: those of the station hulls its products offer, and those that stand in for it. + // Ordered by key so a choice never depends on store order. Empty for no faction. + public static CultRecordRef[] KindsOf(CultCache catalog, Faction faction) + { + if (faction == null) return Array.Empty>(); + var kinds = new HashSet(OfferedStationHulls(catalog, faction).Select(hull => hull.Kind.Key.Value), StringComparer.Ordinal); + foreach (var kind in catalog.GetAll()) + if (kind.StandIns.Any(standIn => catalog.Get(standIn.Faction) == faction)) + kinds.Add(catalog.RefOf(kind).Key.Value); + return Ordered(kinds); + } + + // A station's kind: a stable pick among the faction's kinds by the station's site key. It draws nothing from any random + // stream, so adding the kind to generation moves no other roll. A faction with no kinds falls back to any kind some + // offered station hull carries, logged, and the hull draw then falls back to any maker. Unset when the catalog has none. + public static CultRecordRef Choose(CultCache catalog, Faction faction, CultRecordKey siteKey, Action log = null) + { + var pool = KindsOf(catalog, faction); + if (pool.Length == 0) + { + pool = Ordered(new HashSet( + catalog.GetAll().Where(product => LoadoutGenerator.IsOffered(catalog, product)) + .Select(product => catalog.Get(product.Design) as HullData) + .Where(hull => hull != null && hull.HullType == HullType.Station && hull.Kind.IsSet()) + .Select(hull => hull.Kind.Key.Value), StringComparer.Ordinal)); + if (pool.Length == 0) return default; + log?.Invoke($"{faction?.Name ?? "This galaxy"} builds no station kind; placing a station of any kind some maker offers."); + } + return pool[(int) (siteKey.Value.StableHash() % (uint) pool.Length)]; + } + + // The kind of hull a faction builds for a station kind: the kind itself when the faction offers a model of it (a model + // wins over a stale stand-in), else the kind its stand-in names, else the kind itself (the hull draw then falls back to + // any maker). + public static CultRecordRef HullKindFor(CultCache catalog, Faction faction, CultRecordRef kind) + { + if (!kind.IsSet() || faction == null) return kind; + if (OfferedStationHulls(catalog, faction).Any(hull => hull.Kind.Key.Equals(kind.Key))) return kind; + var standIn = catalog.Get(kind)?.StandIns.FirstOrDefault(entry => catalog.Get(entry.Faction) == faction); + return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind; + } + + // Open refuses a malformed kind catalog, naming the record and field. Every Station hull names a kind the catalog holds + // and no other hull names one; every stand-in names a faction and a kind the catalog holds, is never its own kind, and + // does not stand on another stand-in for the same faction (one hop). + public static void Validate(CultCache catalog) + { + foreach (var hull in catalog.GetAll()) + { + if (hull.HullType == HullType.Station) + { + if (!hull.Kind.IsSet()) + throw new InvalidOperationException($"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be."); + if (catalog.Get(hull.Kind) == null) + throw new InvalidOperationException($"Station hull '{hull.Name}' Kind names no station kind this catalog holds."); + } + else if (hull.Kind.IsSet()) + throw new InvalidOperationException($"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one."); + } + foreach (var kind in catalog.GetAll()) + { + var self = catalog.RefOf(kind).Key; + for (var i = 0; i < kind.StandIns.Count; i++) + { + var entry = kind.StandIns[i]; + var at = $"Station kind '{kind.Name}' StandIns[{i}]"; + var faction = catalog.Get(entry.Faction); + if (faction == null) throw new InvalidOperationException($"{at}.Faction names no faction this catalog holds."); + var other = catalog.Get(entry.Kind); + if (other == null) throw new InvalidOperationException($"{at}.Kind names no station kind this catalog holds."); + if (entry.Kind.Key.Equals(self)) throw new InvalidOperationException($"{at}.Kind is the kind itself; a kind cannot stand in for itself."); + if (other.StandIns.Any(next => catalog.Get(next.Faction) == faction)) + throw new InvalidOperationException($"{at}.Kind stands in for the same faction itself; a stand-in is one hop."); + } + } + } + + private static IEnumerable OfferedStationHulls(CultCache catalog, Faction faction) => + catalog.GetAll() + .Where(product => LoadoutGenerator.IsOffered(catalog, product) && catalog.Get(product.Manufacturer) == faction) + .Select(product => catalog.Get(product.Design) as HullData) + .Where(hull => hull != null && hull.HullType == HullType.Station && hull.Kind.IsSet()); + + private static CultRecordRef[] Ordered(IEnumerable keys) => + keys.OrderBy(key => key, StringComparer.Ordinal).Select(key => new CultRecordRef(new CultRecordKey(key))).ToArray(); +} diff --git a/Assets/Scripts/ServerShared/StationKinds.cs.meta b/Assets/Scripts/ServerShared/StationKinds.cs.meta new file mode 100644 index 000000000..8e29291ad --- /dev/null +++ b/Assets/Scripts/ServerShared/StationKinds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b3e1a94c2d84f6e9a05d1c8e4f27b63 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index dfe89917c..c5c653bac 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -419,7 +419,8 @@ public static class ZoneGenerator if (i >= selectedStationOrbits.Length) { itemManager.Log($"Story station for {story.Faction?.Name} has no rail clear of the bodies; its story has nowhere to live!"); continue; } var orbit = selectedStationOrbits[i]; var lagrangeOrbit = CreateLagrangeOrbit(orbit); - var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout(); + var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout( + StationKinds.Choose(cache, story.Faction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); if (station == null) { itemManager.Log($"Story station for {story.Faction?.Name} could not be generated; its story has nowhere to live!"); @@ -440,7 +441,8 @@ public static class ZoneGenerator var security = (SecurityLevel) ((int) ((1f - pow(random.NextFloat(), factionPresence / 2f)) * 3f)); var lagrangeOrbit = CreateLagrangeOrbit(orbit); - var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout(); + var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout( + StationKinds.Choose(cache, nearestFaction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); if (station == null) { if (isTutorialEntrance) diff --git a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs index b02dffea7..9878d95c6 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs @@ -60,6 +60,7 @@ public sealed class ConsumableSupplyTests : IDisposable public ItemManager Items; public Galaxy Galaxy; public Faction Maker; + public CultRecordRef Kind; public LoadoutGenerator Generator(ref Random random) => new LoadoutGenerator(ref random, Items, Galaxy, null, Maker, .5f); } @@ -84,8 +85,9 @@ public sealed class ConsumableSupplyTests : IDisposable maker = seed.Upsert(new Faction { Name = "Maker", ShortName = "MKR", Allegiance = { { ally, 1f } } }); // Interior for the crate, the capacitor and the tractor beam every generated ship carries. + var hub = new CultRecordRef(seed.Upsert(new StationKindData { Name = "Hub" }).Key); var skiff = seed.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = Solid(5, 3), Price = 100 }); - var platform = seed.Upsert(new HullData { Name = "Platform", HullType = HullType.Station, Shape = Solid(5, 5), Price = 100 }); + var platform = seed.Upsert(new HullData { Name = "Platform", HullType = HullType.Station, Kind = hub, Shape = Solid(5, 5), Price = 100 }); // A hold with no cell cannot take a consumable. var crate = seed.Upsert(new CargoBayData { @@ -107,7 +109,7 @@ public sealed class ConsumableSupplyTests : IDisposable if (holds != Holds.Pinned) { sold.Add(("Cutter", seed.Upsert(new HullData { Name = "Cutter", HullType = HullType.Ship, Shape = Solid(5, 3), Price = 100 }).Key)); - sold.Add(("Outpost", seed.Upsert(new HullData { Name = "Outpost", HullType = HullType.Station, Shape = Solid(4, 4), Price = 100 }).Key)); + sold.Add(("Outpost", seed.Upsert(new HullData { Name = "Outpost", HullType = HullType.Station, Kind = hub, Shape = Solid(4, 4), Price = 100 }).Key)); sold.Add(("Plate", seed.Upsert(new GearData { Name = "Plate", Hardpoint = HardpointType.Tool, Shape = Solid(2, 2), Price = 1 }).Key)); } @@ -155,7 +157,8 @@ public sealed class ConsumableSupplyTests : IDisposable Cache = cache, Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }), - Maker = cache.GetByName("Maker") + Maker = cache.GetByName("Maker"), + Kind = new CultRecordRef(cache.GetAll().Select(k => cache.RefOf(k).Key).Single()) }; } @@ -193,7 +196,7 @@ public sealed class ConsumableSupplyTests : IDisposable for (var i = 0; i < Seeds; i++) { var random = new Random(SeedOf(i)); - var pack = rig.Generator(ref random).GenerateStationLoadout(); + var pack = rig.Generator(ref random).GenerateStationLoadout(rig.Kind); Assert.NotEmpty(pack.CargoContents.SelectMany(bay => bay)); // the gear stock is read from the same pack var held = Consumables(pack); var hull = rig.Items.GetData(pack.Hull).Name; @@ -255,7 +258,7 @@ public sealed class ConsumableSupplyTests : IDisposable for (var i = 0; i < Seeds; i++) { var random = new Random(SeedOf(i)); - if (Consumables(rig.Generator(ref random).GenerateStationLoadout()).Length > 0) stocked++; + if (Consumables(rig.Generator(ref random).GenerateStationLoadout(rig.Kind)).Length > 0) stocked++; } AssertWithinThreeSigma(stocked, Seeds, StationChance, "stations stocking a consumable past a refusing first bay"); } @@ -289,7 +292,7 @@ public sealed class ConsumableSupplyTests : IDisposable items.Random = new Random(SeedOf(i)); var random = new Random(SeedOf(i)); var generator = new LoadoutGenerator(ref random, items, galaxy, galaxy.Entrance, null, .5f); - if (Consumables(generator.GenerateStationLoadout()).Length > 0) stations++; + if (Consumables(generator.GenerateStationLoadout(TestStationKinds.TradeHub)).Length > 0) stations++; if (Consumables(generator.GenerateShipLoadout()).Length > 0) ships++; } AssertWithinThreeSigma(stations, Seeds, StationChance, "shipped stations stocking a consumable"); @@ -327,7 +330,7 @@ public sealed class ConsumableSupplyTests : IDisposable rig.Items.Random = new Random(99); var random = new Random(5); var generator = rig.Generator(ref random); - generator.GenerateStationLoadout(); + generator.GenerateStationLoadout(rig.Kind); for (var i = 0; i < 3; i++) generator.GenerateShipLoadout(); return Enumerable.Range(0, 4).Select(_ => generator.Random.NextFloat()) .Concat(Enumerable.Range(0, 4).Select(_ => rig.Items.Random.NextFloat())).ToArray(); diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 2650b8009..2b8fa2270 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -264,7 +264,7 @@ public sealed partial class RunStartTests 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())) + .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateStationLoadout(TestStationKinds.TradeHub))) .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateTurretLoadout())) .ToList(); var readers = 0; diff --git a/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs index fefdaeb81..ce4666496 100644 --- a/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs @@ -37,7 +37,7 @@ public sealed partial class RunStartTests // Stations and turrets share the interior outfitting and get none. for (var i = 0; i < 4; i++) { - Assert.Equal(0, PickupGear(generator.GenerateStationLoadout())); + Assert.Equal(0, PickupGear(generator.GenerateStationLoadout(TestStationKinds.TradeHub))); Assert.Equal(0, PickupGear(generator.GenerateTurretLoadout())); } Assert.All(main.arena.Pack.Entities.Where(pack => !(pack is ShipPack)), pack => Assert.Equal(0, PickupGear(pack))); diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 8626148a5..c10feb803 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -318,7 +318,7 @@ public sealed partial class RunStartTests var ship = generator.GenerateShipLoadout(); Assert.NotNull(ship); Assert.NotEqual(hull.Name, HullName(items, ship)); - var station = generator.GenerateStationLoadout(); + var station = generator.GenerateStationLoadout(TestStationKinds.TradeHub); Assert.DoesNotContain(EntitySerializer.Items(station), item => items.GetData(item).Name == hull.Name); Assert.Null(generator.ChooseProduct(hull)); Assert.Null(generator.GenerateShipLoadout(candidate => candidate == hull)); diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index b3c502a6b..69b040d4e 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -107,7 +107,7 @@ public sealed partial class RunStartTests : IDisposable var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); for (var i = 0; i < 8; i++) { - var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.True(station.Equipment.Count(IsHeater) == 1, $"station {i} carries {station.Equipment.Count(IsHeater)} heaters"); Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"station {i} has no reactor"); } @@ -168,7 +168,7 @@ public sealed partial class RunStartTests : IDisposable var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, faction, .5f); for (var i = 0; i < 3; i++) { - var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.True(station.Equipment.Count(IsHeater) == 1, $"{faction.Name} station {i}: no heater"); Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"{faction.Name} station {i}: no reactor"); var unreachable = Made(station).Where(made => made.product == null || !generator.IsAvailable(made.product)).ToList(); @@ -219,7 +219,7 @@ public sealed partial class RunStartTests : IDisposable Entity station = null; for (var i = 0; i < 8; i++) { - station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"station {i}: no reactor"); var foreign = Made(station).Where(made => made.product == null || !generator.IsAvailable(made.product) || made.product.Manufacturer.Key.Equals(_cache.RefOf(zhestokost).Key)).ToList(); @@ -313,7 +313,7 @@ public sealed partial class RunStartTests : IDisposable .OrderBy(design => design.Shape.Coordinates.Length).ThenBy(design => design.Name, StringComparer.Ordinal).First(); var shape = new Shape(bay.Shape.Width + 2, bay.Shape.Height + 2); foreach (var cell in shape.AllCoordinates) shape[cell] = true; - var cramped = new HullData { Name = "Cramped Station", HullType = HullType.Station, Shape = shape, Durability = 100, Mass = 1000, Price = 1 }; + var cramped = new HullData { Name = "Cramped Station", HullType = HullType.Station, Kind = TestStationKinds.TradeHub, Shape = shape, Durability = 100, Mass = 1000, Price = 1 }; _cache.Upsert(cramped); _cache.Upsert(new FactionProductData { @@ -323,7 +323,7 @@ public sealed partial class RunStartTests : IDisposable hull.Price = 0; // generation offers no zero-price design, so the cramped hull is the only station var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); - var failure = Assert.Throws(() => generator.GenerateStationLoadout()); + var failure = Assert.Throws(() => generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.Contains("heater", failure.Message); } diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs new file mode 100644 index 000000000..0bce4474a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; +using Random = CultMath.Random; + +// Test-side handle on the shipped catalog's trade-hub kind, the one Zenith carries (ruling zenith-is-a-trade-hub). +internal static class TestStationKinds +{ + public static readonly CultRecordRef TradeHub = new CultRecordRef(StationKindData.KeyOf("trade-hub")); +} + +// Station kinds (aetheria-living-world, cut station-kind-datum): the one list of kinds in the catalog, a hull's kind, a +// placed station's kind, and the stand-in rule for a faction with no model of a kind. Worlds here are the composed shipped +// catalog copied to a temp directory, with Zenith taken off the market so the only station hulls are the ones a test adds. +public sealed class StationKindTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-stationkinds-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + private int _worlds; + + public StationKindTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var cache in _caches) cache.Dispose(); + Directory.Delete(_root, true); + } + + private static CultRecordRef Kind(string id) => new CultRecordRef(StationKindData.KeyOf(id)); + + private sealed class World + { + public CultCache Cache; + public ItemManager Items; + public List Log = new List(); + public Faction Zh, Au, Lucent; + public HullData Zenith; + private FactionProductData _zenithProduct; + private int _added; + + public static World Make(StationKindTests host, bool zenithOffered) + { + var dir = Path.Combine(host._root, "world" + host._worlds++); + Directory.CreateDirectory(dir); + File.Copy(TestCatalog.Repo, Path.Combine(dir, "Aetheria.cc")); + var cache = new CultCache(TestCatalog.Registry()); + host._caches.Add(cache); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(dir, "Aetheria.cc")), AetheriaStores.CatalogTypes); + var world = new World { Cache = cache }; + world.Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), world.Log.Add); + world.Zh = cache.GetAll().Single(f => f.Name == "Zhestokost"); + world.Au = cache.GetAll().Single(f => f.ShortName == "AU"); + world.Lucent = cache.GetAll().Single(f => f.ShortName == "Lucent"); + world.Zenith = cache.GetAll().Single(h => h.Name == "Zenith"); + world._zenithProduct = cache.GetAll().Single(p => cache.Get(p.Design) == world.Zenith); + if (!zenithOffered) + { + world._zenithProduct.NotOffered = true; + cache.Upsert(world._zenithProduct); + } + return world; + } + + // A station hull of the given kind that this faction sells: a copy of Zenith's body, so it takes the same docking bay + // and heater, with its own name and kind. + public HullData Sell(Faction maker, string kindId, string name = null) + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var hull = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(Zenith, options), options); + hull.Name = name ?? $"{maker.ShortName} {kindId} {_added++}"; + hull.Kind = Kind(kindId); + var hullRef = Cache.Upsert(hull); + var product = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(_zenithProduct, options), options); + product.Name = hull.Name; + product.Design = new CultRecordRef(hullRef.Key); + product.Manufacturer = Cache.RefOf(maker); + product.NotOffered = false; + Cache.Upsert(product); + return hull; + } + + public void StandIn(string kindId, Faction faction, string asKindId) + { + var kind = Cache.Get(Kind(kindId)); + kind.StandIns.Add(new StationKindStandIn { Faction = Cache.RefOf(faction), Kind = Kind(asKindId) }); + Cache.Upsert(kind); + } + + public LoadoutGenerator Generator(Faction faction, uint seed = 1) + { + var random = new Random(seed); + Items.Random = new Random(seed); + return new LoadoutGenerator(ref random, Items, null, null, faction, .5f); + } + + public string HullName(OrbitalEntityPack pack) => Items.GetData(pack.Hull).Name; + public HullData HullOf(OrbitalEntityPack pack) => (HullData) Items.GetData(pack.Hull); + } + + // ---- Open ---- + + private CultCache SeedAndReopen(Action seed, out string path) + { + path = Path.Combine(_root, "open" + _worlds++ + ".cc"); + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + seed(cache); + cache.FlushAsync().Wait(); + } + return null; + } + + private string OpenRefusal(Action seed) + { + SeedAndReopen(seed, out var path); + var failure = Assert.Throws(() => AetheriaStores.Open(path)); + return failure.Message; + } + + private static HullData Hull(HullType type, CultRecordRef kind = default) => + new HullData { Name = "Probe " + type, HullType = type, Shape = new Shape(2, 2), Price = 1, Kind = kind }; + + [Fact] + public void OpenRefusesKindlessStationHull() + { + Assert.Contains("has no Kind", OpenRefusal(cache => cache.Upsert(Hull(HullType.Station)))); + Assert.Contains("names no station kind", OpenRefusal(cache => cache.Upsert(Hull(HullType.Station, Kind("nowhere"))))); + var shipWithKind = OpenRefusal(cache => + { + var kind = new CultRecordRef(cache.Upsert(new StationKindData { Name = "Hub" }).Key); + cache.Upsert(Hull(HullType.Ship, kind)); + }); + Assert.Contains("only a station hull has one", shipWithKind); + var turretWithKind = OpenRefusal(cache => + { + var kind = new CultRecordRef(cache.Upsert(new StationKindData { Name = "Hub" }).Key); + cache.Upsert(Hull(HullType.Turret, kind)); + }); + Assert.Contains("only a station hull has one", turretWithKind); + } + + [Fact] + public void OpenRefusesBadStandIn() + { + CultRecordRef Mint(CultCache cache, string name, Action shape = null) + { + var kind = new StationKindData { Name = name }; + shape?.Invoke(kind); + return new CultRecordRef(cache.Upsert(kind).Key); + } + + Assert.Contains("kind itself", OpenRefusal(cache => + { + CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + var kind = new StationKindData { Name = "Self" }; + var handle = cache.Upsert(kind); + kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = new CultRecordRef(handle.Key) }); + cache.Upsert(kind); + })); + Assert.Contains("StandIns[0].Faction names no faction", OpenRefusal(cache => + { + var other = Mint(cache, "Other"); + Mint(cache, "Lonely", kind => kind.StandIns.Add(new StationKindStandIn { Faction = new CultRecordRef(new CultRecordKey("nobody")), Kind = other })); + })); + Assert.Contains("StandIns[0].Kind names no station kind", OpenRefusal(cache => + { + CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + Mint(cache, "Lonely", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = Kind("nowhere") })); + })); + Assert.Contains("one hop", OpenRefusal(cache => + { + CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + var third = Mint(cache, "Third"); + var second = Mint(cache, "Second", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = third })); + Mint(cache, "First", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = second })); + })); + } + + // ---- generation ---- + + [Fact] + public void StationsArePlacedByKind() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "mining"); + world.Sell(world.Au, "shipyard"); + world.Sell(world.Au, "mining"); + + var kinds = StationKinds.KindsOf(world.Cache, world.Au).Select(kind => kind.Key.Value).ToArray(); + Assert.Equal(new[] { StationKindData.KeyOf("mining").Value, StationKindData.KeyOf("shipyard").Value }, kinds); + + var seen = new HashSet(); + for (var site = 0; site < 40; site++) + { + var kind = StationKinds.Choose(world.Cache, world.Au, new CultRecordKey("site-" + site)); + Assert.Contains(kind.Key.Value, kinds); + seen.Add(kind.Key.Value); + var pack = world.Generator(world.Au, (uint) site + 1).GenerateStationLoadout(kind); + Assert.NotNull(pack); + Assert.Equal(kind.Key, pack.Kind.Key); + Assert.Equal(kind.Key, world.HullOf(pack).Kind.Key); + } + Assert.Equal(kinds.Length, seen.Count); + } + + [Fact] + public void StandInUsesFactionModelAndKeepsName() + { + var world = World.Make(this, zenithOffered: false); + var hub = world.Sell(world.Zh, "trade-hub", "Zh Hub"); + world.StandIn("mining", world.Zh, "trade-hub"); + var mining = world.Cache.Get(Kind("mining")); + + Assert.Contains(Kind("mining").Key.Value, StationKinds.KindsOf(world.Cache, world.Zh).Select(kind => kind.Key.Value)); + Assert.Equal(Kind("trade-hub").Key, StationKinds.HullKindFor(world.Cache, world.Zh, Kind("mining")).Key); + for (uint seed = 1; seed <= 10; seed++) + { + var pack = world.Generator(world.Zh, seed).GenerateStationLoadout(Kind("mining")); + Assert.Equal("Zh Hub", world.HullName(pack)); + Assert.Equal(Kind("mining").Key, pack.Kind.Key); + Assert.Equal(mining.Name, pack.Name); + } + var own = world.Generator(world.Zh).GenerateStationLoadout(Kind("trade-hub")); + Assert.Equal(hub.Name, own.Name); + } + + [Fact] + public void ModelWinsOverStaleStandIn() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Zh, "trade-hub", "Zh Hub"); + world.Sell(world.Zh, "mining", "Zh Mine"); + world.StandIn("mining", world.Zh, "trade-hub"); + + Assert.Equal(Kind("mining").Key, StationKinds.HullKindFor(world.Cache, world.Zh, Kind("mining")).Key); + for (uint seed = 1; seed <= 10; seed++) + { + var pack = world.Generator(world.Zh, seed).GenerateStationLoadout(Kind("mining")); + Assert.Equal("Zh Mine", world.HullName(pack)); + Assert.Equal("Zh Mine", pack.Name); + } + } + + [Fact] + public void FactionWithoutModelsFallsBack() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "shipyard"); + Assert.Empty(StationKinds.KindsOf(world.Cache, world.Lucent)); + + var logged = new List(); + var kind = StationKinds.Choose(world.Cache, world.Lucent, new CultRecordKey("site"), logged.Add); + Assert.Equal(Kind("shipyard").Key, kind.Key); + Assert.Single(logged); + + var pack = world.Generator(world.Lucent).GenerateStationLoadout(kind); + Assert.NotNull(pack); + Assert.Equal(kind.Key, pack.Kind.Key); + Assert.Contains(world.Log, line => line.Contains("any manufacturer")); + } + + [Fact] + public void KindChoiceLeavesRandomStream() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "mining"); + world.Sell(world.Au, "shipyard"); + world.Sell(world.Au, "depot"); + var keys = Enumerable.Range(0, 30).Select(i => new CultRecordKey("orbit-" + i)).ToArray(); + var first = keys.Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).ToArray(); + Assert.Equal(first, keys.Reverse().Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).Reverse().ToArray()); + Assert.True(first.Distinct().Count() > 1, "the stable hash spreads sites over the faction's kinds"); + + // Generating a zone with one station kind in the catalog and with several draws the same orbits and turrets. + (float, float)[] Orbits(Action stock) + { + var w = World.Make(this, zenithOffered: true); + stock?.Invoke(w); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var dir = Path.Combine(_root, "galaxy" + _worlds++); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, 3); + var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); + Assert.NotEmpty(pack.Entities.OfType()); + return pack.Orbits.Select(orbit => w.Cache.Get(orbit)).Select(orbit => (orbit.Distance, orbit.Phase)).ToArray(); + } + var one = Orbits(null); + var several = Orbits(w => { w.Sell(w.Au, "mining"); w.Sell(w.Zh, "shipyard"); w.Sell(w.Lucent, "depot"); }); + Assert.Equal(one, several); + } + + [Fact] + public void GeneratedZonesPlaceStationsByKind() + { + var w = World.Make(this, zenithOffered: true); + w.Sell(w.Au, "mining"); + w.Sell(w.Au, "shipyard"); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(_root, "zones", "Narrative")), _ => { }, null, 5); + var stations = 0; + foreach (var galaxyZone in galaxy.Zones.Take(8)) + { + var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxyZone, galaxyZone == galaxy.Entrance && galaxy.IsPrelude, true); + foreach (var station in pack.Entities.OfType().Where(e => w.HullOf(e).HullType == HullType.Station)) + { + stations++; + Assert.True(station.Kind.IsSet(), "a placed station has a kind"); + Assert.Equal(station.Kind.Key, w.HullOf(station).Kind.Key); + var faction = w.Cache.Get(station.Faction); + var kinds = StationKinds.KindsOf(w.Cache, faction); + if (kinds.Length > 0) Assert.Contains(station.Kind.Key.Value, kinds.Select(kind => kind.Key.Value)); + } + } + Assert.True(stations > 0, "the sample generated stations"); + } + + [Fact] + public void KindSurvivesSave() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "research"); + var pack = world.Generator(world.Au).GenerateStationLoadout(Kind("research")); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var bytes = MessagePackSerializer.Serialize(pack, options); + var restored = Assert.IsType(MessagePackSerializer.Deserialize(bytes, options)); + Assert.Equal(Kind("research").Key, restored.Kind.Key); + + var entity = Assert.IsType(EntitySerializer.Unpack(world.Items, null, restored)); + Assert.Equal(Kind("research").Key, entity.Kind.Key); + Assert.Equal(Kind("research").Key, ((OrbitalEntityPack) EntitySerializer.Pack(entity)).Kind.Key); + } + + // ---- the shipped catalog ---- + + [Fact] + public void ShippedCatalogHoldsOneKindList() + { + using var cache = AetheriaStores.Open(Path.Combine(RunStartTests.FindRepoRoot(), "GameData", "Aetheria.cc")); + var ids = new[] + { + "mining", "volatiles", "agriculture", "processing", "fabrication", "shipyard", "depot", "trade-hub", "residential", + "habitat-support", "urban", "administrative", "power", "research", "salvage", "black-market", "waystation" + }; + Assert.Equal(ids.Select(id => StationKindData.KeyOf(id).Value).OrderBy(key => key, StringComparer.Ordinal), + cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).OrderBy(key => key, StringComparer.Ordinal)); + Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); + } + + [Fact] + public void ShippedStandInsAreLive() + { + using var cache = AetheriaStores.Open(TestCatalog.Repo); + foreach (var kind in cache.GetAll()) + foreach (var standIn in kind.StandIns) + { + var faction = cache.Get(standIn.Faction); + var models = StationKinds.KindsOf(cache, faction); + var own = cache.GetAll().Where(p => cache.Get(p.Manufacturer) == faction && LoadoutGenerator.IsOffered(cache, p)) + .Select(p => cache.Get(p.Design) as HullData).Where(h => h != null && h.HullType == HullType.Station).Select(h => h.Kind.Key).ToArray(); + Assert.DoesNotContain(cache.RefOf(kind).Key, own); + Assert.Contains(standIn.Kind.Key, own); + Assert.NotEmpty(models); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs new file mode 100644 index 000000000..0cd7b4d39 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using Xunit; + +public sealed class StationKindsCatalogTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-stationkindscmd-" + Guid.NewGuid().ToString("N")); + + public StationKindsCatalogTests() => Directory.CreateDirectory(_root); + + public void Dispose() => Directory.Delete(_root, true); + + [Fact] + public void DryRunWritesNothingApplyIsIdempotent() + { + var path = Path.Combine(_root, "Aetheria.cc"); + using (var seed = AetheriaStores.Open(path, catalogWritable: true)) + { + seed.Upsert(new VerseGrammar { Revision = 1 }); + seed.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + seed.FlushAsync().Wait(); + } + var before = File.ReadAllBytes(path); + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + Assert.Equal(0, StationKindsCatalog.Run(cache, false, new StringWriter())); + cache.FlushAsync().Wait(); + Assert.Empty(cache.GetAll()); + } + Assert.Equal(before, File.ReadAllBytes(path)); + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + Assert.Equal(0, StationKindsCatalog.Run(cache, true, new StringWriter())); + cache.FlushAsync().Wait(); + Assert.Equal(17, cache.GetAll().Count()); + } + var applied = File.ReadAllBytes(path); + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + var output = new StringWriter(); + StationKindsCatalog.Run(cache, true, output); + cache.FlushAsync().Wait(); + Assert.Contains("Nothing to author", output.ToString()); + Assert.Equal(17, cache.GetAll().Count()); + } + Assert.Equal(applied, File.ReadAllBytes(path)); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 3e8b37b85..9cfd26f8c 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -61,9 +61,10 @@ public static class Program case "place-names": Console.OutputEncoding = new System.Text.UTF8Encoding(false); return PlaceNamesCommand.Run(args.Skip(1).ToArray()); + case "station-kinds": return StationKindsCatalog.Run(StationKindsCatalog.OpenCatalog(args.Contains("apply")), args.Contains("apply"), Console.Out); case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [apply]"); return 1; } } @@ -824,7 +825,7 @@ public static class Program { HullType.Ship => generator.GenerateShipLoadout(candidate => candidate == hull), HullType.Turret => generator.GenerateTurretLoadout(), - _ => generator.GenerateStationLoadout() + _ => generator.GenerateStationLoadout(hull.Kind) }; outcome = pack == null ? "NO LOADOUT (nothing suitable found)" : "ok"; if (pack == null) failures++; diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs new file mode 100644 index 000000000..1a50d9f10 --- /dev/null +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; + +// Authors the catalog's one list of station kinds (aetheria-living-world, cut station-kind-datum) and gives Zenith, the +// one station hull in the repo, its ruled kind (ruling zenith-is-a-trade-hub: provisional, one edit here to change). +// The 17 kinds are the station-round bench's economic roles; descriptions are its produces/consumes column. A kind that +// already has a record is kept as it is and Zenith keeps a kind it already has, so a rerun that finds nothing to author +// writes nothing. Dry run unless apply. +public static class StationKindsCatalog +{ + public const string ZenithKind = "trade-hub"; + + private static readonly (string id, string name, string description)[] Table = + { + ("mining", "Mining Station", "Extracts ore (Rock, New Metals, Gold, Uranium, Potassium) from bodies and belts; consumes tools and food."), + ("volatiles", "Volatiles Station", "Skims Hydrogen, Methane and Water from gas giants and ice; consumes tools."), + ("agriculture", "Agriculture Station", "Grows Organics (Algae, Fungi, Bacteria, Animal Products) from Water and Potassium; feeds residential."), + ("processing", "Processing Station", "Refines ore and volatiles into Metals, Alloys and Compounds."), + ("fabrication", "Fabrication Station", "Turns Alloys and Compounds into Manufacturing compounds, Assemblies, gear, Ammo and consumer goods."), + ("shipyard", "Shipyard", "Assembles hulls from Assemblies and Alloys."), + ("depot", "Depot", "Stores and dispatches stock; loads haulers and tenders."), + ("trade-hub", "Trade Hub", "Exchanges every bloc's goods between haulers and takes a cut; produces nothing."), + ("residential", "Residential Station", "A bloom: consumes food, water, Wearables, Consumables and Luxuries; supplies crews."), + ("habitat-support", "Habitat Support Station", "Air, water recycling, life support and medicine for nearby habitats; consumes Bacteria, Water, Compounds."), + ("urban", "Urban Station", "Dense city and services: market, media, repair; consumes luxuries and food."), + ("administrative", "Administrative Station", "Issues quotas, contracts and security; consumes luxuries."), + ("power", "Power Station", "Makes reactor fuel, reservoirs and capacitors from Uranium and Hydrogen."), + ("research", "Research Station", "Certification, compute, prototypes; consumes Assemblies."), + ("salvage", "Salvage Station", "Breaks wrecks into Metals and second-hand gear."), + ("black-market", "Black Market", "Buys anything, fences stolen cargo."), + ("waystation", "Waystation", "Fuel, repair and medicine along a corridor."), + }; + + // The catalog opened without AetheriaStores.Open's checks: the catalog this command first runs on holds a station hull with no + // kind, which StationKinds.Validate refuses, and this command is what gives it one. + public static CultCache OpenCatalog(bool apply) + { + var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); + return cache; + } + + public static int Run(CultCache cache, bool apply, TextWriter output) + { + var held = cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).ToHashSet(StringComparer.Ordinal); + var missing = Table.Where(row => !held.Contains(StationKindData.KeyOf(row.id).Value)).ToArray(); + foreach (var row in Table) + output.WriteLine($"{row.id,-16} {(missing.Any(m => m.id == row.id) ? "authors" : "kept")}"); + + var zenith = cache.GetAll().Where(h => h.HullType == HullType.Station && h.Name == "Zenith").SingleOrDefault(); + var setZenith = zenith != null && !zenith.Kind.IsSet(); + output.WriteLine(zenith == null ? "Zenith: no such hull, kind not set" + : setZenith ? $"Zenith: kind {ZenithKind}" : "Zenith: already has a kind, kept"); + + if (!apply) + { + output.WriteLine("\nDry run. Pass \"apply\" to author them."); + return 0; + } + if (missing.Length == 0 && !setZenith) + { + output.WriteLine("Nothing to author."); + return 0; + } + + cache.Commit(batch => + { + foreach (var (id, name, description) in missing) + batch.Upsert(typeof(StationKindData), new StationKindData { Name = name, Description = description }, StationKindData.KeyOf(id)); + if (setZenith) + { + zenith.Kind = new CultRecordRef(StationKindData.KeyOf(ZenithKind)); + batch.Upsert(typeof(HullData), zenith, cache.RefOf(zenith).Key); + } + }); + output.WriteLine($"\nAuthored {missing.Length} station kinds{(setZenith ? " and Zenith's kind" : "")} in Aetheria.cc"); + return 0; + } +} From a7364ed13e9842acc0d5c45e709f39d8ead4f932 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:24:21 +0200 Subject: [PATCH 070/230] GameData: 17 station kinds and Zenith as a trade-hub (station-kinds apply) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index a2ddaad5d..3adacaa37 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c327ef1bbabbd34ddb7b9f643f5db8cc54aa5730f232bd42a1fa5f2b03973e89 -size 189753 +oid sha256:06724d46e8fe2bcf3f03515a4923337cbe30e19dff11b962b81287a56cfe4e17 +size 192474 From 353ca96b5ee66ce03807806dd450bec7c9931f66 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:30:15 +0200 Subject: [PATCH 071/230] StationKindTests: the any-maker log is the hull draw's, not Choose's --- tests/Aetheria.Shared.Tests/StationKindTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 0bce4474a..8ef8513b8 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -264,7 +264,6 @@ public sealed class StationKindTests : IDisposable var pack = world.Generator(world.Lucent).GenerateStationLoadout(kind); Assert.NotNull(pack); Assert.Equal(kind.Key, pack.Kind.Key); - Assert.Contains(world.Log, line => line.Contains("any manufacturer")); } [Fact] From ac4b771f5e18cfcd860a4b3cff3133a700c30ae2 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:37:19 +0200 Subject: [PATCH 072/230] StationKindTests: worlds carry the run store zone generation writes orbits to --- tests/Aetheria.Shared.Tests/StationKindTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 8ef8513b8..7c0b24cac 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -52,6 +52,7 @@ public sealed class StationKindTests : IDisposable var cache = new CultCache(TestCatalog.Registry()); host._caches.Add(cache); cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(dir, "Aetheria.cc")), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(dir, "run.cc")), AetheriaStores.RunTypes); var world = new World { Cache = cache }; world.Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), world.Log.Add); world.Zh = cache.GetAll().Single(f => f.Name == "Zhestokost"); From 0a468f24a86804ee0c1b9869039b3cb93190713a Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:38:26 +0200 Subject: [PATCH 073/230] Station kinds: tests pin the log text, a null faction, and one-hop stand-ins; kind strings default to null --- Assets/Scripts/ServerShared/StationKinds.cs | 6 ++-- .../Aetheria.Shared.Tests/StationKindTests.cs | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs index 18c200092..9e946b295 100644 --- a/Assets/Scripts/ServerShared/StationKinds.cs +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -13,10 +13,10 @@ using Newtonsoft.Json; public class StationKindData { [CultName, JsonProperty("name"), Key(0)] - public string Name = ""; + public string Name; [JsonProperty("description"), Key(1)] - public string Description = ""; + public string Description; // A faction with no model of this kind builds the stand-in's hull and the station stays this kind. Ignored once the // faction offers a model of this kind. @@ -76,7 +76,7 @@ public static class StationKinds // any maker). public static CultRecordRef HullKindFor(CultCache catalog, Faction faction, CultRecordRef kind) { - if (!kind.IsSet() || faction == null) return kind; + if (!kind.IsSet()) return kind; if (OfferedStationHulls(catalog, faction).Any(hull => hull.Kind.Key.Equals(kind.Key))) return kind; var standIn = catalog.Get(kind)?.StandIns.FirstOrDefault(entry => catalog.Get(entry.Faction) == faction); return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind; diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 7c0b24cac..d3752b0cd 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -176,6 +176,29 @@ public sealed class StationKindTests : IDisposable CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); Mint(cache, "Lonely", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = Kind("nowhere") })); })); + // A stand-in whose kind stands in for another faction only is one hop for this faction: Open accepts it. + SeedAndReopen(cache => + { + CultRecordRef mine = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + CultRecordRef other = cache.Upsert(new Faction { Name = "G", ShortName = "G" }); + var third = Mint(cache, "Third"); + var second = Mint(cache, "Second", kind => kind.StandIns.Add(new StationKindStandIn { Faction = other, Kind = third })); + Mint(cache, "First", kind => kind.StandIns.Add(new StationKindStandIn { Faction = mine, Kind = second })); + }, out var accepted); + AetheriaStores.Open(accepted).Dispose(); + // Any one of the stand-in kind's entries for the same faction makes a second hop. + Assert.Contains("one hop", OpenRefusal(cache => + { + CultRecordRef mine = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + CultRecordRef other = cache.Upsert(new Faction { Name = "G", ShortName = "G" }); + var third = Mint(cache, "Third"); + var second = Mint(cache, "Second", kind => + { + kind.StandIns.Add(new StationKindStandIn { Faction = other, Kind = third }); + kind.StandIns.Add(new StationKindStandIn { Faction = mine, Kind = third }); + }); + Mint(cache, "First", kind => kind.StandIns.Add(new StationKindStandIn { Faction = mine, Kind = second })); + })); Assert.Contains("one hop", OpenRefusal(cache => { CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); @@ -261,6 +284,13 @@ public sealed class StationKindTests : IDisposable var kind = StationKinds.Choose(world.Cache, world.Lucent, new CultRecordKey("site"), logged.Add); Assert.Equal(Kind("shipyard").Key, kind.Key); Assert.Single(logged); + Assert.Contains(world.Lucent.Name, logged[0]); + var anonymous = new List(); + StationKinds.Choose(world.Cache, null, new CultRecordKey("site"), anonymous.Add); + Assert.Contains("This galaxy", Assert.Single(anonymous)); + world.StandIn("mining", world.Au, "shipyard"); + Assert.Equal(Kind("mining").Key, StationKinds.HullKindFor(world.Cache, null, Kind("mining")).Key); + Assert.False(StationKinds.HullKindFor(world.Cache, world.Au, default).IsSet()); var pack = world.Generator(world.Lucent).GenerateStationLoadout(kind); Assert.NotNull(pack); From ea3e983aa22a8012c591684d238b570d98f40a7b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:15:38 +0200 Subject: [PATCH 074/230] StationKindsCatalog: OpenCatalog takes a path; tests pin Zenith's kind, the ids and the command's report --- .../StationKindsCatalogTests.cs | 61 ++++++++++++++++++- tools/AetherDb/StationKindsCatalog.cs | 4 +- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs index 0cd7b4d39..1aa661e5e 100644 --- a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs @@ -12,6 +12,51 @@ public sealed class StationKindsCatalogTests : IDisposable public void Dispose() => Directory.Delete(_root, true); + private static readonly string[] Ids = + { + "mining", "volatiles", "agriculture", "processing", "fabrication", "shipyard", "depot", "trade-hub", "residential", + "habitat-support", "urban", "administrative", "power", "research", "salvage", "black-market", "waystation" + }; + + // The catalog this command first runs on holds a station hull with no kind, which Open refuses, so the command opens + // the catalog itself: Zenith gets the ruled kind, a kind it already has is kept. + [Fact] + public void ZenithGetsTheRuledKindAndAKindedOneIsKept() + { + var path = Path.Combine(_root, "Zenith.cc"); + using (var seed = AetheriaStores.Open(path, catalogWritable: true)) + { + seed.Upsert(new VerseGrammar { Revision = 1 }); + seed.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + seed.Upsert(new HullData { Name = "Zenith", HullType = HullType.Station, Shape = new Shape(2, 2), Price = 1 }); + seed.Upsert(new HullData { Name = "Cutter", HullType = HullType.Ship, Shape = new Shape(2, 2), Price = 1 }); + seed.FlushAsync().Wait(); + } + Assert.Throws(() => AetheriaStores.Open(path)); + + var dry = new StringWriter(); + using (var cache = StationKindsCatalog.OpenCatalog(false, path)) StationKindsCatalog.Run(cache, false, dry); + Assert.Contains("Zenith: kind trade-hub", dry.ToString()); + + var applied = new StringWriter(); + using (var cache = StationKindsCatalog.OpenCatalog(true, path)) + { + StationKindsCatalog.Run(cache, true, applied); + cache.FlushAsync().Wait(); + } + Assert.Contains("and Zenith's kind", applied.ToString()); + using (var cache = AetheriaStores.Open(path)) + { + Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); + Assert.False(cache.GetAll().Single(h => h.Name == "Cutter").Kind.IsSet()); + } + + var again = new StringWriter(); + using (var cache = StationKindsCatalog.OpenCatalog(true, path)) StationKindsCatalog.Run(cache, true, again); + Assert.Contains("Zenith: already has a kind, kept", again.ToString()); + Assert.Contains("Nothing to author", again.ToString()); + } + [Fact] public void DryRunWritesNothingApplyIsIdempotent() { @@ -26,17 +71,26 @@ public sealed class StationKindsCatalogTests : IDisposable using (var cache = AetheriaStores.Open(path, catalogWritable: true)) { - Assert.Equal(0, StationKindsCatalog.Run(cache, false, new StringWriter())); + var dry = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.Run(cache, false, dry)); cache.FlushAsync().Wait(); + Assert.Equal(17, dry.ToString().Split((char) 10).Count(line => line.TrimEnd().EndsWith("authors"))); + Assert.Contains("Dry run", dry.ToString()); + Assert.Contains("Zenith: no such hull", dry.ToString()); Assert.Empty(cache.GetAll()); } Assert.Equal(before, File.ReadAllBytes(path)); using (var cache = AetheriaStores.Open(path, catalogWritable: true)) { - Assert.Equal(0, StationKindsCatalog.Run(cache, true, new StringWriter())); + var output = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.Run(cache, true, output)); cache.FlushAsync().Wait(); - Assert.Equal(17, cache.GetAll().Count()); + Assert.Contains("Authored 17 station kinds in", output.ToString()); + var kinds = cache.GetAll().ToArray(); + Assert.Equal(Ids.Select(id => StationKindData.KeyOf(id).Value).OrderBy(key => key, StringComparer.Ordinal), + kinds.Select(kind => cache.RefOf(kind).Key.Value).OrderBy(key => key, StringComparer.Ordinal)); + Assert.All(kinds, kind => { Assert.False(string.IsNullOrWhiteSpace(kind.Name)); Assert.False(string.IsNullOrWhiteSpace(kind.Description)); }); } var applied = File.ReadAllBytes(path); @@ -46,6 +100,7 @@ public sealed class StationKindsCatalogTests : IDisposable StationKindsCatalog.Run(cache, true, output); cache.FlushAsync().Wait(); Assert.Contains("Nothing to author", output.ToString()); + Assert.Equal(17, output.ToString().Split((char) 10).Count(line => line.TrimEnd().EndsWith("kept"))); Assert.Equal(17, cache.GetAll().Count()); } Assert.Equal(applied, File.ReadAllBytes(path)); diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 1a50d9f10..0ee6b1c6c 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -36,10 +36,10 @@ public static class StationKindsCatalog // The catalog opened without AetheriaStores.Open's checks: the catalog this command first runs on holds a station hull with no // kind, which StationKinds.Validate refuses, and this command is what gives it one. - public static CultCache OpenCatalog(bool apply) + public static CultCache OpenCatalog(bool apply, string catalogPath = null) { var cache = new CultCache(); - cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath ?? Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); return cache; } From d55ac309059e047c04be37cf98ef109d8283448a Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:18:20 +0200 Subject: [PATCH 075/230] Station kinds tests: a bad package kind is excluded not fatal, same seed gives the same station and belt kinds, the stand-in check can fail (red) Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 43 ++++++++++++++ .../ShipModCatalogTests.cs | 30 ++++++++++ .../Aetheria.Shared.Tests/StationKindTests.cs | 56 ++++++++++++++++++- 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 3e68b4fb0..6e1da6b0f 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -833,6 +833,49 @@ public sealed partial class MiningCut3Tests : IDisposable Assert.True(belts > 0, "no seed generated a belt; the generation path went unexercised"); } + // The kind of a belt is a function of the zone's seed, not of the key the cache mints for it: the same seed generated + // again, through the real key minting, gives the same belts the same kinds. + [Fact] + public void SameSeedGivesTheSameBeltKinds() + { + string[] Generate(uint seed, int run) + { + var dir = Path.Combine(_root, $"same{seed}-{run}"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + File.Copy(TestCatalog.Repo, catalog); + using var cache = OpenShippedCatalogCopy(catalog, Path.Combine(dir, "run.cc")); + foreach (var existing in cache.GetAll().ToArray()) + { + existing.GenerationWeight = 0f; + cache.Upsert(existing); + } + cache.Upsert(Kind("Gen A", 1f, 1f)); + cache.Upsert(Kind("Gen B", 1f, 2f)); + cache.Upsert(Kind("Gen C", 1f, 4f)); + Galaxy galaxy; + try + { + galaxy = new Galaxy(RestoredHullsTests.TutorialGalaxySettings(), RestoredHullsTests.TutorialBackgroundSettings(), + cache, new PlayerSettings(), new DirectoryInfo(dir), _ => { }, null, seed); + } + catch (Exception) { return null; } + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); + return galaxy.Zones.Take(6).SelectMany(zone => + ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance) + .Planets.Select(body => cache.Get(body)).OfType().Select(belt => belt.Kind.Key.Value)) + .ToArray(); + } + for (uint seed = 1; seed <= 25; seed++) + { + var first = Generate(seed, 0); + if (first == null || first.Length < 4) continue; + for (var run = 1; run < 4; run++) Assert.Equal(first, Generate(seed, run)); + return; + } + Assert.Fail("no seed generated enough belts; the generation path went unexercised"); + } + // ==== Mining Cut 3 fix batch (Soul's findings) ==== // Q12 A ("launchers cannot mine") in the reach rule: reach counts only weapons that can mine (Weapon.CanMine). diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 8d05b9ba8..717108d8d 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -49,6 +49,36 @@ public sealed class ShipModCatalogTests : IDisposable AssertOnlyTheDerivedFile(); } + // A package's station kind is checked per package: a bad one is left out, named with its reason, and the derived catalog + // still opens. The shipped catalog's own check is unchanged. + [Fact] + public void ComposeExcludesPackagesWithBadStationKinds() + { + using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) + { + cache.Upsert(new StationKindData { Name = "Hub" }); + cache.FlushAsync().Wait(); + } + string kindKey; + using (var cache = AetheriaStores.Open(Shipped)) kindKey = cache.RefOf(cache.GetAll().Single()).Key.Value; + var held = new CultRecordRef(new CultRecordKey(kindKey)); + WritePackage("mod.kindless", "Kindless", tweak: p => p.Hull.HullType = HullType.Station); + WritePackage("mod.unknown", "Unknown", tweak: p => { p.Hull.HullType = HullType.Station; p.Hull.Kind = new CultRecordRef(new CultRecordKey("station-kind:nowhere")); }); + WritePackage("mod.shipkind", "ShipKind", tweak: p => p.Hull.Kind = held); + WritePackage("mod.good", "GoodStation", tweak: p => { p.Hull.HullType = HullType.Station; p.Hull.Kind = held; }); + + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Equal(new[] { "mod.good" }, composition.Included); + Assert.Equal(new[] { "mod.kindless", "mod.shipkind", "mod.unknown" }, composition.Excluded.Select(e => e.Package).OrderBy(n => n, StringComparer.Ordinal)); + Assert.Contains("no Kind", composition.Excluded.Single(e => e.Package == "mod.kindless").Reason); + Assert.Contains("names no station kind", composition.Excluded.Single(e => e.Package == "mod.unknown").Reason); + Assert.Contains("only a station hull has one", composition.Excluded.Single(e => e.Package == "mod.shipkind").Reason); + using var derived = AetheriaStores.Open(Derived); + Assert.Contains("GoodStation", derived.GetAll().Select(hull => hull.Name)); + Assert.DoesNotContain("Kindless", derived.GetAll().Select(hull => hull.Name)); + } + // Correction 2 of the S0 map: the derived catalog held each mod hull twice, once as a HullData and once inside the // ship's authoring record. The hull is now one record, and the visual holds no hull. [Fact] diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index d3752b0cd..9947251b3 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -329,6 +329,29 @@ public sealed class StationKindTests : IDisposable Assert.Equal(one, several); } + // A station's kind is a function of the zone's seed and the orbit's place in generation, not of a key the cache mints: + // the same zone generated again, through the real key minting, gives the same stations the same kinds in the same order. + [Fact] + public void SameSeedGivesTheSameStationKinds() + { + string[] Kinds() + { + var w = World.Make(this, zenithOffered: true); + foreach (var faction in new[] { w.Au, w.Zh, w.Lucent }) + foreach (var kind in new[] { "mining", "shipyard", "depot", "power" }) w.Sell(faction, kind); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(_root, "galaxy" + _worlds++, "Narrative")), _ => { }, null, 3); + var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); + return pack.Entities.OfType().Where(e => e.Kind.IsSet()).Select(e => e.Kind.Key.Value).ToArray(); + } + var first = Kinds(); + Assert.True(first.Length >= 3, "the sample placed stations"); + for (var run = 1; run < 6; run++) Assert.Equal(first, Kinds()); + } + [Fact] public void GeneratedZonesPlaceStationsByKind() { @@ -389,10 +412,8 @@ public sealed class StationKindTests : IDisposable Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); } - [Fact] - public void ShippedStandInsAreLive() + private static void AssertStandInsLive(CultCache cache) { - using var cache = AetheriaStores.Open(TestCatalog.Repo); foreach (var kind in cache.GetAll()) foreach (var standIn in kind.StandIns) { @@ -405,4 +426,33 @@ public sealed class StationKindTests : IDisposable Assert.NotEmpty(models); } } + + [Fact] + public void ShippedStandInsAreLive() + { + using var cache = AetheriaStores.Open(TestCatalog.Repo); + AssertStandInsLive(cache); + } + + // The retirement check is not vacuous: given a stand-in it passes when the faction offers the kind it stands in with and + // not the owning kind, and fails when the faction has gained its own model of the owning kind or has no model to use. + [Fact] + public void StandInRetirementCheckCanFail() + { + var live = World.Make(this, zenithOffered: false); + live.Sell(live.Au, "mining"); + live.StandIn("shipyard", live.Au, "mining"); + Assert.Single(live.Cache.GetAll().SelectMany(kind => kind.StandIns)); + AssertStandInsLive(live.Cache); + + var retired = World.Make(this, zenithOffered: false); + retired.Sell(retired.Au, "mining"); + retired.Sell(retired.Au, "shipyard"); + retired.StandIn("shipyard", retired.Au, "mining"); + Assert.ThrowsAny(() => AssertStandInsLive(retired.Cache)); + + var hollow = World.Make(this, zenithOffered: false); + hollow.StandIn("shipyard", hollow.Au, "mining"); + Assert.ThrowsAny(() => AssertStandInsLive(hollow.Cache)); + } } From 17e2da00928bbabc6ceca6b7e4ad26aa2d2c750f Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:18:21 +0200 Subject: [PATCH 076/230] AetherDb tests: a kindless station hull does not lock the tool out; station-kinds sets a hull's kind (red) Co-Authored-By: Claude Sonnet 5.5 --- .../StationKindsCatalogTests.cs | 79 +++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs index 1aa661e5e..517a6ddc6 100644 --- a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; @@ -18,8 +19,8 @@ public sealed class StationKindsCatalogTests : IDisposable "habitat-support", "urban", "administrative", "power", "research", "salvage", "black-market", "waystation" }; - // The catalog this command first runs on holds a station hull with no kind, which Open refuses, so the command opens - // the catalog itself: Zenith gets the ruled kind, a kind it already has is kept. + // The catalog this command first runs on holds a station hull with no kind. The game's Open refuses it; the tool's Open + // names the hull and goes on, so the command can give it one: Zenith gets the ruled kind, a kind it already has is kept. [Fact] public void ZenithGetsTheRuledKindAndAKindedOneIsKept() { @@ -34,12 +35,14 @@ public sealed class StationKindsCatalogTests : IDisposable } Assert.Throws(() => AetheriaStores.Open(path)); + var named = new List(); var dry = new StringWriter(); - using (var cache = StationKindsCatalog.OpenCatalog(false, path)) StationKindsCatalog.Run(cache, false, dry); + using (var cache = AetheriaStores.Open(path, kindlessStation: named.Add)) StationKindsCatalog.Run(cache, false, dry); + Assert.Contains("Zenith", Assert.Single(named)); Assert.Contains("Zenith: kind trade-hub", dry.ToString()); var applied = new StringWriter(); - using (var cache = StationKindsCatalog.OpenCatalog(true, path)) + using (var cache = AetheriaStores.Open(path, catalogWritable: true, kindlessStation: _ => { })) { StationKindsCatalog.Run(cache, true, applied); cache.FlushAsync().Wait(); @@ -52,11 +55,77 @@ public sealed class StationKindsCatalogTests : IDisposable } var again = new StringWriter(); - using (var cache = StationKindsCatalog.OpenCatalog(true, path)) StationKindsCatalog.Run(cache, true, again); + using (var cache = AetheriaStores.Open(path, catalogWritable: true, kindlessStation: _ => { })) StationKindsCatalog.Run(cache, true, again); Assert.Contains("Zenith: already has a kind, kept", again.ToString()); Assert.Contains("Nothing to author", again.ToString()); } + private string SeedRoot(params string[] kindlessStations) + { + var root = Path.Combine(_root, "root" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(root, "GameData")); + using var seed = AetheriaStores.Open(Path.Combine(root, "GameData", "Aetheria.cc"), catalogWritable: true); + seed.Upsert(new VerseGrammar { Revision = 1 }); + seed.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + foreach (var name in kindlessStations) seed.Upsert(new HullData { Name = name, HullType = HullType.Station, Shape = new Shape(2, 2), Price = 1 }); + seed.Upsert(new HullData { Name = "Cutter", HullType = HullType.Ship, Shape = new Shape(2, 2), Price = 1 }); + seed.FlushAsync().Wait(); + return root; + } + + // One kindless station hull does not lock the owner out of the catalog: the tool opens it, names the hull, and the + // command that sets kinds is reachable. The game's own Open still refuses until every station hull has a kind. + [Fact] + public void AKindlessStationHullDoesNotLockTheToolOut() + { + var root = SeedRoot("Zenith", "Second Station"); + var catalog = Path.Combine(root, "GameData", "Aetheria.cc"); + Assert.Throws(() => AetheriaStores.Open(catalog)); + + var warned = new List(); + var applied = new StringWriter(); + using (var db = AetherDb.Open(catalogWritable: true, root: root, warn: warned.Add)) + { + Assert.Contains(warned, line => line.Contains("Zenith")); + Assert.Contains(warned, line => line.Contains("Second Station")); + Assert.Equal(0, StationKindsCatalog.Run(db.Cache, true, applied)); + Assert.Contains("Second Station", applied.ToString()); + db.Cache.FlushAsync().Wait(); + } + Assert.Throws(() => AetheriaStores.Open(catalog)); + + using (var db = AetherDb.Open(catalogWritable: true, root: root, warn: _ => { })) + { + var dry = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.SetHullKind(db.Cache, "Second Station", "mining", false, dry)); + Assert.Contains("Dry run", dry.ToString()); + Assert.False(db.Cache.GetAll().Single(h => h.Name == "Second Station").Kind.IsSet()); + var set = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.SetHullKind(db.Cache, "Second Station", "mining", true, set)); + db.Cache.FlushAsync().Wait(); + } + using var strict = AetheriaStores.Open(catalog); + Assert.Equal(StationKindData.KeyOf("mining").Value, strict.GetAll().Single(h => h.Name == "Second Station").Kind.Key.Value); + } + + [Fact] + public void SetHullKindRefusesWhatIsNotAStationHullOrAHeldKind() + { + var root = SeedRoot("Zenith"); + using var db = AetherDb.Open(catalogWritable: true, root: root, warn: _ => { }); + StationKindsCatalog.Run(db.Cache, true, new StringWriter()); + string Refusal(string hull, string kind) + { + var output = new StringWriter(); + Assert.Equal(1, StationKindsCatalog.SetHullKind(db.Cache, hull, kind, true, output)); + return output.ToString(); + } + Assert.Contains("no station kind", Refusal("Zenith", "nowhere")); + Assert.Contains("no station hull", Refusal("Cutter", "mining")); + Assert.Contains("no station hull", Refusal("Missing", "mining")); + Assert.False(db.Cache.GetAll().Single(h => h.Name == "Cutter").Kind.IsSet()); + } + [Fact] public void DryRunWritesNothingApplyIsIdempotent() { From 37a8110907a6f76c0dfbefed4a6b4774b7523372 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:21:02 +0200 Subject: [PATCH 077/230] Station and belt kinds are seeded: the pick hashes the zone seed and the place in generation order, not a minted key Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/StationKinds.cs | 41 ++++++++++++------- Assets/Scripts/ServerShared/Zone.cs | 2 +- Assets/Scripts/ServerShared/ZoneData.cs | 13 +++--- Assets/Scripts/ServerShared/ZoneGenerator.cs | 12 +++--- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 18 ++++---- .../Aetheria.Shared.Tests/StationKindTests.cs | 8 ++-- 6 files changed, 54 insertions(+), 40 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs index 9e946b295..4d7c54ffe 100644 --- a/Assets/Scripts/ServerShared/StationKinds.cs +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -52,10 +52,11 @@ public static class StationKinds return Ordered(kinds); } - // A station's kind: a stable pick among the faction's kinds by the station's site key. It draws nothing from any random + // A station's kind: a stable pick among the faction's kinds by the station's site, which generation names by the zone's + // seed and the station's place in its orbit order (never by a key the cache mints). It draws nothing from any random // stream, so adding the kind to generation moves no other roll. A faction with no kinds falls back to any kind some // offered station hull carries, logged, and the hull draw then falls back to any maker. Unset when the catalog has none. - public static CultRecordRef Choose(CultCache catalog, Faction faction, CultRecordKey siteKey, Action log = null) + public static CultRecordRef Choose(CultCache catalog, Faction faction, string site, Action log = null) { var pool = KindsOf(catalog, faction); if (pool.Length == 0) @@ -68,7 +69,7 @@ public static class StationKinds if (pool.Length == 0) return default; log?.Invoke($"{faction?.Name ?? "This galaxy"} builds no station kind; placing a station of any kind some maker offers."); } - return pool[(int) (siteKey.Value.StableHash() % (uint) pool.Length)]; + return pool[(int) (site.StableHash() % (uint) pool.Length)]; } // The kind of hull a faction builds for a station kind: the kind itself when the faction offers a model of it (a model @@ -82,22 +83,34 @@ public static class StationKinds return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind; } + // What is wrong with a hull's kind, or null: a Station hull names a kind the catalog holds, and no other hull names one. + // The one rule, applied to the catalog on Open and to each mod package on Compose. + public static string HullKindFault(CultCache catalog, HullData hull) + { + if (hull.HullType == HullType.Station) + { + if (!hull.Kind.IsSet()) + return $"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be."; + if (catalog.Get(hull.Kind) == null) + return $"Station hull '{hull.Name}' Kind names no station kind this catalog holds."; + } + else if (hull.Kind.IsSet()) + return $"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one."; + return null; + } + // Open refuses a malformed kind catalog, naming the record and field. Every Station hull names a kind the catalog holds // and no other hull names one; every stand-in names a faction and a kind the catalog holds, is never its own kind, and - // does not stand on another stand-in for the same faction (one hop). - public static void Validate(CultCache catalog) + // does not stand on another stand-in for the same faction (one hop). A caller that gives kindlessStation is the owner + // of the catalog being repaired: a Station hull with no Kind is reported to it by name instead of refused. + public static void Validate(CultCache catalog, Action kindlessStation = null) { foreach (var hull in catalog.GetAll()) { - if (hull.HullType == HullType.Station) - { - if (!hull.Kind.IsSet()) - throw new InvalidOperationException($"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be."); - if (catalog.Get(hull.Kind) == null) - throw new InvalidOperationException($"Station hull '{hull.Name}' Kind names no station kind this catalog holds."); - } - else if (hull.Kind.IsSet()) - throw new InvalidOperationException($"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one."); + var fault = HullKindFault(catalog, hull); + if (fault == null) continue; + if (kindlessStation != null && hull.HullType == HullType.Station && !hull.Kind.IsSet()) kindlessStation(fault); + else throw new InvalidOperationException(fault); } foreach (var kind in catalog.GetAll()) { diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index d395f722a..ca6764626 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -196,7 +196,7 @@ public class Zone case AsteroidBeltData belt: // Mining Cut 3 (Q16 B): a belt from before field kinds existed gets its kind here, once, by // the rule generation uses, and keeps it. - FieldKinds.Ensure(body.Key, belt, cache); + FieldKinds.Ensure(body.Key.Value, belt, cache); AsteroidBelts[body.Key] = new AsteroidBelt(belt); break; case SunData sun: diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 646347af6..a80278566 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -134,10 +134,11 @@ public class FieldKindData // Mining Cut 3 (Q16 B): the one owner of which kind a belt is. public static class FieldKinds { - // A weighted pick among the catalog's kinds, by a stable hash of the belt's key: the same belt and the same - // catalog always give the same kind. Kinds are ordered by key so the pick does not depend on store order. + // A weighted pick among the catalog's kinds, by a stable hash of the belt's site: the same site and the same + // catalog always give the same kind. Generation names a belt by the zone's seed and the belt's place in generation + // order (never by the key the cache mints, which differs every run); a belt saved without a kind is named by its key. Kinds are ordered by key so the pick does not depend on store order. // Unset when the catalog has no kind with positive weight. - public static CultRecordRef Assign(CultRecordKey field, CultCache catalog) + public static CultRecordRef Assign(string site, CultCache catalog) { var kinds = catalog.GetAll() .Where(kind => kind.GenerationWeight > 0f) @@ -146,7 +147,7 @@ public static class FieldKinds .ToArray(); if (kinds.Length == 0) return default; - var pick = field.Value.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight); + var pick = site.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight); foreach (var kind in kinds) { if (pick < kind.Weight) return new CultRecordRef(kind.Key); @@ -157,10 +158,10 @@ public static class FieldKinds // Gives a belt with no kind its kind and saves it; a belt that has one keeps it, whatever the catalog now says. // Generation (ZoneGenerator) and first load (Zone) both call this, so there is one assignment rule. - public static void Ensure(CultRecordKey field, AsteroidBeltData belt, CultCache cache) + public static void Ensure(string site, AsteroidBeltData belt, CultCache cache) { if (belt.Kind.IsSet()) return; - belt.Kind = Assign(field, cache); + belt.Kind = Assign(site, cache); if (belt.Kind.IsSet()) cache.Upsert(belt); } } diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c5c653bac..426e8b92e 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -119,7 +119,7 @@ public static class ZoneGenerator pack.Orbits = planets.Select(planet => cache.RefOf(orbitMap[planet])).ToList(); var beltOf = new Dictionary(); - pack.Planets = planets.Where(p=>!p.Empty).Select(planet => + pack.Planets = planets.Where(p=>!p.Empty).Select((planet, index) => { BodyType bodyType = planet.Belt ? BodyType.Asteroid : planet.Mass > zoneSettings.SunMass ? BodyType.Sun : @@ -213,7 +213,7 @@ public static class ZoneGenerator var bodyKey = cache.Upsert(planetData).Key; planetData.Name = bodyKey.Value.Substring(0, 8); // Mining Cut 3 (Q16 B): a belt's field kind is chosen here, at generation, and saved with it. - if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure(bodyKey, generatedBelt, cache); + if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure($"{seed}:belt:{index}", generatedBelt, cache); return cache.Upsert(planetData); }).ToList(); @@ -239,7 +239,7 @@ public static class ZoneGenerator BodyData swarmBody = swarm; var swarmKey = cache.Upsert(swarmBody).Key; swarm.Name = swarmKey.Value.Substring(0, 8); - FieldKinds.Ensure(swarmKey, swarm, cache); + FieldKinds.Ensure($"{seed}:swarm:{swarmHosts.Count}", swarm, cache); pack.Planets.Add(cache.Upsert(swarmBody)); } @@ -268,7 +268,7 @@ public static class ZoneGenerator BodyData lanedBody = laned; var lanedKey = cache.Upsert(lanedBody).Key; laned.Name = lanedKey.Value.Substring(0, 8); - FieldKinds.Ensure(lanedKey, laned, cache); + FieldKinds.Ensure($"{seed}:lane:{shaped.Count}", laned, cache); pack.Planets.Add(cache.Upsert(lanedBody)); } @@ -420,7 +420,7 @@ public static class ZoneGenerator var orbit = selectedStationOrbits[i]; var lagrangeOrbit = CreateLagrangeOrbit(orbit); var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout( - StationKinds.Choose(cache, story.Faction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); + StationKinds.Choose(cache, story.Faction, $"{seed}:station:{i}", itemManager.Log)); if (station == null) { itemManager.Log($"Story station for {story.Faction?.Name} could not be generated; its story has nowhere to live!"); @@ -442,7 +442,7 @@ public static class ZoneGenerator var lagrangeOrbit = CreateLagrangeOrbit(orbit); var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout( - StationKinds.Choose(cache, nearestFaction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); + StationKinds.Choose(cache, nearestFaction, $"{seed}:station:{i}", itemManager.Log)); if (station == null) { if (isTutorialEntrance) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 6e1da6b0f..d6de2de59 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -638,7 +638,7 @@ public sealed partial class MiningCut3Tests : IDisposable var counts = new Dictionary { ["Light"] = 0, ["Never"] = 0, ["Middle"] = 0, ["Heavy"] = 0 }; for (var i = 0; i < 2000; i++) { - var key = new CultRecordKey($"belt-{i}"); + var key = $"belt-{i}"; var picked = FieldKinds.Assign(key, s.Cache); Assert.True(picked.Key.Equals(FieldKinds.Assign(key, s.Cache).Key)); counts[s.Cache.Get(picked).Name]++; @@ -649,7 +649,7 @@ public sealed partial class MiningCut3Tests : IDisposable Assert.InRange(counts["Heavy"] / 2000f, 3f / 6 - .04f, 3f / 6 + .04f); var empty = BuildScene(new[] { never }); - Assert.False(FieldKinds.Assign(new CultRecordKey("belt-0"), empty.Cache).IsSet()); + Assert.False(FieldKinds.Assign("belt-0", empty.Cache).IsSet()); } // Q16 B, first load: a belt saved before field kinds existed gets its kind when a zone first loads it, by the @@ -664,7 +664,7 @@ public sealed partial class MiningCut3Tests : IDisposable var beltKey = s.Belts[0]; var belt = (AsteroidBeltData) s.Zone.Planets[beltKey]; Assert.True(belt.Kind.IsSet()); - Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey, s.Cache).Key)); + Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey.Value, s.Cache).Key)); var assigned = belt.Kind.Key; // Saved: a fresh open of the same stores reads the kind back. @@ -678,7 +678,7 @@ public sealed partial class MiningCut3Tests : IDisposable // Kept: a new, overwhelmingly weighted kind now wins the pick for this belt, yet loading it again leaves it. reopened.Upsert(Kind("Overwhelming", 9f, 1e6f)); - Assert.False(FieldKinds.Assign(beltKey, reopened).Key.Equals(assigned)); + Assert.False(FieldKinds.Assign(beltKey.Value, reopened).Key.Equals(assigned)); var items = new ItemManager(1u, reopened, new ProvenanceLedger(), GameSettings(), _ => { }); var reloaded = new Zone(items, PlanetSettings(), s.Pack, new GalaxyZone { Name = "MiningCut3Reload", Owner = null }, null); Assert.True(((AsteroidBeltData) reloaded.Planets[beltKey]).Kind.Key.Equals(assigned)); @@ -786,11 +786,11 @@ public sealed partial class MiningCut3Tests : IDisposable return cache; } - // Q16 B, generation: every belt ZoneGenerator makes carries its kind already, chosen by FieldKinds.Assign on - // the belt's own key. Runs against a scratch copy of the shipped catalog with three kinds of different + // Q16 B, generation: every belt ZoneGenerator makes carries its kind already, chosen by FieldKinds.Assign. Runs against + // a scratch copy of the shipped catalog with three kinds of different // weights, so a pick by any other rule would disagree on some belt. [Fact] - public void GenerationChoosesTheKindByTheSameFunction() + public void GenerationAssignsEveryBeltItsKind() { var shipped = TestCatalog.Repo; var belts = 0; @@ -826,7 +826,7 @@ public sealed partial class MiningCut3Tests : IDisposable if (!(cache.Get(body) is AsteroidBeltData belt)) continue; belts++; Assert.True(belt.Kind.IsSet()); - Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(body.Key, cache).Key)); + Assert.StartsWith("Gen ", cache.Get(belt.Kind).Name); } } } @@ -863,7 +863,7 @@ public sealed partial class MiningCut3Tests : IDisposable var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); return galaxy.Zones.Take(6).SelectMany(zone => ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance) - .Planets.Select(body => cache.Get(body)).OfType().Select(belt => belt.Kind.Key.Value)) + .Planets.Select(body => cache.Get(body)).OfType().Select(belt => cache.Get(belt.Kind).Name)) .ToArray(); } for (uint seed = 1; seed <= 25; seed++) diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 9947251b3..12d1c0e50 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -224,7 +224,7 @@ public sealed class StationKindTests : IDisposable var seen = new HashSet(); for (var site = 0; site < 40; site++) { - var kind = StationKinds.Choose(world.Cache, world.Au, new CultRecordKey("site-" + site)); + var kind = StationKinds.Choose(world.Cache, world.Au, "site-" + site); Assert.Contains(kind.Key.Value, kinds); seen.Add(kind.Key.Value); var pack = world.Generator(world.Au, (uint) site + 1).GenerateStationLoadout(kind); @@ -281,12 +281,12 @@ public sealed class StationKindTests : IDisposable Assert.Empty(StationKinds.KindsOf(world.Cache, world.Lucent)); var logged = new List(); - var kind = StationKinds.Choose(world.Cache, world.Lucent, new CultRecordKey("site"), logged.Add); + var kind = StationKinds.Choose(world.Cache, world.Lucent, "site", logged.Add); Assert.Equal(Kind("shipyard").Key, kind.Key); Assert.Single(logged); Assert.Contains(world.Lucent.Name, logged[0]); var anonymous = new List(); - StationKinds.Choose(world.Cache, null, new CultRecordKey("site"), anonymous.Add); + StationKinds.Choose(world.Cache, null, "site", anonymous.Add); Assert.Contains("This galaxy", Assert.Single(anonymous)); world.StandIn("mining", world.Au, "shipyard"); Assert.Equal(Kind("mining").Key, StationKinds.HullKindFor(world.Cache, null, Kind("mining")).Key); @@ -304,7 +304,7 @@ public sealed class StationKindTests : IDisposable world.Sell(world.Au, "mining"); world.Sell(world.Au, "shipyard"); world.Sell(world.Au, "depot"); - var keys = Enumerable.Range(0, 30).Select(i => new CultRecordKey("orbit-" + i)).ToArray(); + var keys = Enumerable.Range(0, 30).Select(i => "orbit-" + i).ToArray(); var first = keys.Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).ToArray(); Assert.Equal(first, keys.Reverse().Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).Reverse().ToArray()); Assert.True(first.Distinct().Count() > 1, "the stable hash spreads sites over the faction's kinds"); From d2f4b2c3473835a249739db8eade784968e372e1 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:21:04 +0200 Subject: [PATCH 078/230] A bad package station kind excludes the package; AetherDb opens a catalog with a kindless station hull, names it, and station-kinds hull sets it; the raw opener is gone Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 6 ++- Assets/Scripts/ServerShared/ShipModCatalog.cs | 1 + tools/AetherDb/AetherDb.cs | 12 +++--- tools/AetherDb/Program.cs | 17 +++++++- tools/AetherDb/StationKindsCatalog.cs | 42 ++++++++++++++----- 5 files changed, 59 insertions(+), 19 deletions(-) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 47128ef6c..9992c293f 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -14,10 +14,12 @@ public static class AetheriaStores // Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given. // Throws when a [CultGlobal] type routed to the catalog has no record. The one exemption is a writable catalog with // no records at all, a seed store being authored from nothing; a populated catalog is held to it however it opens. + // A catalog's station hull with no Kind is refused, unless the caller gives kindlessStation: the tool that repairs a catalog + // is told the hull's name and goes on. // The registry is the process-wide one unless the caller scopes it (the test host does, so its own document types never // become catalog globals); the global check above is judged against whichever registry opened the cache. public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false, - CultDocumentRegistry registry = null) + CultDocumentRegistry registry = null, Action kindlessStation = null) { // A read-only open of an absent catalog would hydrate an empty game; a shipped player missing its catalog stops here. if (!catalogWritable && !File.Exists(catalogPath)) @@ -42,7 +44,7 @@ public static class AetheriaStores foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); VerseGrammarValidation.ValidateAll(cache.GetAll()); ValidateBins(cache); - StationKinds.Validate(cache); + StationKinds.Validate(cache, kindlessStation); return cache; } catch diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 4f1908466..93b820513 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -108,6 +108,7 @@ public static class ShipModCatalog Exclude(directory, $"hull name '{hull.Name}' collides with an existing catalog item."); if (shipIds.Contains(ship.Id)) Exclude(directory, $"ship ID collides with an existing catalog ship authoring record."); + if (StationKinds.HullKindFault(cache, hull) is { } kindFault) Exclude(directory, kindFault); foreach (var product in package.Products) { if (!factionKeys.Contains(product.Manufacturer.Key)) diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index d5b4ffdb3..027a6453a 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -3,25 +3,27 @@ using System.IO; using GameCult.Caching; // Opens GameData/Aetheria.cc through AetheriaStores, the game's own store composition. The catalog attaches read-only -// unless a command writes it, so a command that only reads cannot write. +// unless a command writes it, so a command that only reads cannot write. A station hull with no kind does not lock the +// catalog: it is named to warn (stderr by default) and the commands that repair it stay reachable. public sealed class AetherDb { public string Root { get; } public CultCache Cache { get; } - private AetherDb(string root, bool catalogWritable, bool withRun) + private AetherDb(string root, bool catalogWritable, bool withRun, Action warn) { Root = root; var gameData = Path.Combine(root, "GameData"); Cache = AetheriaStores.Open( Path.Combine(gameData, "Aetheria.cc"), runPath: withRun ? Path.Combine(gameData, "run.cc") : null, - catalogWritable: catalogWritable); + catalogWritable: catalogWritable, + kindlessStation: warn ?? Console.Error.WriteLine); } // With no root given, walks up from the working directory to the game folder: the first folder whose GameData holds Aetheria.cc - public static AetherDb Open(bool catalogWritable = false, bool withRun = false, string root = null) => - new AetherDb(root ?? FindRoot(), catalogWritable, withRun); + public static AetherDb Open(bool catalogWritable = false, bool withRun = false, string root = null, Action warn = null) => + new AetherDb(root ?? FindRoot(), catalogWritable, withRun, warn); public static string FindRoot() { diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 9cfd26f8c..0ab9787b4 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -61,10 +61,23 @@ public static class Program case "place-names": Console.OutputEncoding = new System.Text.UTF8Encoding(false); return PlaceNamesCommand.Run(args.Skip(1).ToArray()); - case "station-kinds": return StationKindsCatalog.Run(StationKindsCatalog.OpenCatalog(args.Contains("apply")), args.Contains("apply"), Console.Out); + case "station-kinds": + { + var apply = args.Contains("apply"); + using var db = AetherDb.Open(catalogWritable: apply); + int code; + if (args.Length > 1 && args[1] == "hull") + { + if (args.Length != (apply ? 5 : 4)) { Console.WriteLine("station-kinds hull \"\" [apply]"); return 1; } + code = StationKindsCatalog.SetHullKind(db.Cache, args[2], args[3], apply, Console.Out); + } + else code = StationKindsCatalog.Run(db.Cache, apply, Console.Out); + if (apply) db.Cache.FlushAsync().GetAwaiter().GetResult(); + return code; + } case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [hull \"\" ] [apply]"); return 1; } } diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 0ee6b1c6c..6743ccc50 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -2,7 +2,6 @@ using System; using System.IO; using System.Linq; using GameCult.Caching; -using GameCult.Caching.MessagePack; // Authors the catalog's one list of station kinds (aetheria-living-world, cut station-kind-datum) and gives Zenith, the // one station hull in the repo, its ruled kind (ruling zenith-is-a-trade-hub: provisional, one edit here to change). @@ -34,15 +33,6 @@ public static class StationKindsCatalog ("waystation", "Waystation", "Fuel, repair and medicine along a corridor."), }; - // The catalog opened without AetheriaStores.Open's checks: the catalog this command first runs on holds a station hull with no - // kind, which StationKinds.Validate refuses, and this command is what gives it one. - public static CultCache OpenCatalog(bool apply, string catalogPath = null) - { - var cache = new CultCache(); - cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath ?? Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); - return cache; - } - public static int Run(CultCache cache, bool apply, TextWriter output) { var held = cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).ToHashSet(StringComparer.Ordinal); @@ -55,6 +45,9 @@ public static class StationKindsCatalog output.WriteLine(zenith == null ? "Zenith: no such hull, kind not set" : setZenith ? $"Zenith: kind {ZenithKind}" : "Zenith: already has a kind, kept"); + foreach (var hull in cache.GetAll().Where(h => h.HullType == HullType.Station && !h.Kind.IsSet() && h != zenith).OrderBy(h => h.Name, StringComparer.Ordinal)) + output.WriteLine($"Station hull '{hull.Name}' has no kind; set one with: station-kinds hull \"{hull.Name}\" apply"); + if (!apply) { output.WriteLine("\nDry run. Pass \"apply\" to author them."); @@ -79,4 +72,33 @@ public static class StationKindsCatalog output.WriteLine($"\nAuthored {missing.Length} station kinds{(setZenith ? " and Zenith's kind" : "")} in Aetheria.cc"); return 0; } + + // Gives a station hull its kind: the way to repair a hull the catalog holds without one. Dry run unless apply. + public static int SetHullKind(CultCache cache, string hullName, string kindId, bool apply, TextWriter output) + { + var hull = cache.GetAll().FirstOrDefault(h => h.HullType == HullType.Station && h.Name == hullName); + if (hull == null) + { + output.WriteLine($"The catalog holds no station hull named '{hullName}'."); + return 1; + } + var kind = new CultRecordRef(StationKindData.KeyOf(kindId)); + if (cache.Get(kind) == null) + { + output.WriteLine($"The catalog holds no station kind '{kindId}'."); + return 1; + } + output.WriteLine($"{hull.Name}: kind {kindId}"); + if (!apply) + { + output.WriteLine(" +Dry run. Pass \"apply\" to set it."); + return 0; + } + hull.Kind = kind; + cache.Upsert(hull); + output.WriteLine($" +Set {hull.Name}'s kind in Aetheria.cc"); + return 0; + } } From b87873415836e069801241a269901332ce4dec28 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:23:59 +0200 Subject: [PATCH 079/230] station-kinds hull: the dry-run and applied lines are one line each Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/StationKindsCatalog.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 6743ccc50..a450d2ff7 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -91,14 +91,12 @@ public static class StationKindsCatalog output.WriteLine($"{hull.Name}: kind {kindId}"); if (!apply) { - output.WriteLine(" -Dry run. Pass \"apply\" to set it."); + output.WriteLine("\nDry run. Pass \"apply\" to set it."); return 0; } hull.Kind = kind; cache.Upsert(hull); - output.WriteLine($" -Set {hull.Name}'s kind in Aetheria.cc"); + output.WriteLine($"\nSet {hull.Name}'s kind in Aetheria.cc"); return 0; } } From 4918fcd993e9f1bd15a13b4a5ab4b529e3e2d304 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:26:00 +0200 Subject: [PATCH 080/230] AetherDb is disposable: it owns the cache it opened Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/AetherDb.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index 027a6453a..c65002d11 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -5,11 +5,13 @@ using GameCult.Caching; // Opens GameData/Aetheria.cc through AetheriaStores, the game's own store composition. The catalog attaches read-only // unless a command writes it, so a command that only reads cannot write. A station hull with no kind does not lock the // catalog: it is named to warn (stderr by default) and the commands that repair it stay reachable. -public sealed class AetherDb +public sealed class AetherDb : IDisposable { public string Root { get; } public CultCache Cache { get; } + public void Dispose() => Cache.Dispose(); + private AetherDb(string root, bool catalogWritable, bool withRun, Action warn) { Root = root; From d9bc46535566fa3b702ae8ca7d21287d7a4209ec Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:26:14 +0200 Subject: [PATCH 081/230] Packages: lock regenerated by Unity at cultlib-unity-v2.0.1 (b8fc5eb0) Co-Authored-By: Claude Opus 5.5 --- Packages/packages-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 7345ecc47..919cf94d1 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -265,13 +265,13 @@ "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.1", "depth": 0, "source": "git", "dependencies": { "org.gamecult.cultmath": "0.5.0" }, - "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" + "hash": "b8fc5eb07720a187a0421424ac2cb3aa2966ca1d" }, "org.gamecult.cultmath": { "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0", From b1ca2ead9027cd00557170da4fecea2eb5571b83 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:32:34 +0200 Subject: [PATCH 082/230] Same-seed tests: authored kind keys are fixed; the station sample spans eight zones Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/MiningCut3Tests.cs | 7 ++++--- tests/Aetheria.Shared.Tests/StationKindTests.cs | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index d6de2de59..a92f69a0c 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -850,9 +850,10 @@ public sealed partial class MiningCut3Tests : IDisposable existing.GenerationWeight = 0f; cache.Upsert(existing); } - cache.Upsert(Kind("Gen A", 1f, 1f)); - cache.Upsert(Kind("Gen B", 1f, 2f)); - cache.Upsert(Kind("Gen C", 1f, 4f)); + // Authored kinds have fixed keys; the pick orders kinds by key, so the fixture's keys are fixed too. + cache.Upsert(typeof(FieldKindData), Kind("Gen A", 1f, 1f), new CultRecordKey("field-kind:gen-a")); + cache.Upsert(typeof(FieldKindData), Kind("Gen B", 1f, 2f), new CultRecordKey("field-kind:gen-b")); + cache.Upsert(typeof(FieldKindData), Kind("Gen C", 1f, 4f), new CultRecordKey("field-kind:gen-c")); Galaxy galaxy; try { diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 12d1c0e50..47154c1c7 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -343,9 +343,9 @@ public sealed class StationKindTests : IDisposable var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "galaxy" + _worlds++, "Narrative")), _ => { }, null, 3); - var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), - authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); - return pack.Entities.OfType().Where(e => e.Kind.IsSet()).Select(e => e.Kind.Key.Value).ToArray(); + return galaxy.Zones.Take(8).SelectMany(zone => ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, zone, zone == galaxy.Entrance && galaxy.IsPrelude, true) + .Entities.OfType().Where(e => e.Kind.IsSet()).Select(e => e.Kind.Key.Value)).ToArray(); } var first = Kinds(); Assert.True(first.Length >= 3, "the sample placed stations"); From 02d60691dcd604584ffa1b79367dc7c8a9041078 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:37:40 +0200 Subject: [PATCH 083/230] Same-seed belt test: fixed kind keys through a batch Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/MiningCut3Tests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index a92f69a0c..484116eb3 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -851,9 +851,9 @@ public sealed partial class MiningCut3Tests : IDisposable cache.Upsert(existing); } // Authored kinds have fixed keys; the pick orders kinds by key, so the fixture's keys are fixed too. - cache.Upsert(typeof(FieldKindData), Kind("Gen A", 1f, 1f), new CultRecordKey("field-kind:gen-a")); - cache.Upsert(typeof(FieldKindData), Kind("Gen B", 1f, 2f), new CultRecordKey("field-kind:gen-b")); - cache.Upsert(typeof(FieldKindData), Kind("Gen C", 1f, 4f), new CultRecordKey("field-kind:gen-c")); + cache.Commit(batch => batch.Upsert(typeof(FieldKindData), Kind("Gen A", 1f, 1f), new CultRecordKey("field-kind:gen-a"))); + cache.Commit(batch => batch.Upsert(typeof(FieldKindData), Kind("Gen B", 1f, 2f), new CultRecordKey("field-kind:gen-b"))); + cache.Commit(batch => batch.Upsert(typeof(FieldKindData), Kind("Gen C", 1f, 4f), new CultRecordKey("field-kind:gen-c"))); Galaxy galaxy; try { From 7f1337167dd505bd103565d4f541fc5c6807ed12 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:32:11 +0200 Subject: [PATCH 084/230] Loadouts.Commit stops refusing package designs (ruling catalog-is-a-package-stack) The catalog is a stack of packages, the shipped content included, so a Loadout may name any design the stack holds. The mod-design refusal (from 54917bb3, backed by no ruling) is deleted, and its test now pins that a package design commits under its key. The IsModKey comment no longer claims the shipped catalog never references a mod. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 7 +++---- Assets/Scripts/ServerShared/ShipModCatalog.cs | 3 ++- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 20 +++++++++---------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index d7fec7f03..15e832a58 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -99,14 +99,13 @@ public static class Loadouts // The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it // after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the // file; a cache that already has the catalog open sees the loadout only once it reloads. - // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a loadout of this name + // The catalog is a stack of packages, the shipped content included (ruling catalog-is-a-package-stack), so a loadout may + // name any design the stack holds; one whose package is later removed fails closed where it is built, with its reason logged. + // Throws and writes nothing when the file is missing (capture never creates a catalog), when a loadout of this name // exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional // on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then. public static bool Commit(string catalogPath, Loadout loadout, bool replace) { - var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray(); - if (modDesigns.Length > 0) - throw new InvalidOperationException($"Loadout '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a loadout."); if (!File.Exists(catalogPath)) throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one."); using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 4f1908466..9e89411b8 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -25,7 +25,8 @@ public static class ShipModCatalog public static CultRecordKey ProductKey(string id, CultRecordKey maker) => new CultRecordKey("mod-product:" + id + ":" + maker.Value); - // A key the mods own. The shipped catalog and its authored records never reference one: a mod may be uninstalled. + // A key a package owns. Any record may reference one (ruling catalog-is-a-package-stack); one whose package is removed + // fails closed where it is read. public static bool IsModKey(CultRecordKey key) => key.IsSet() && (key.Value.StartsWith("mod-hull:", StringComparison.Ordinal) || key.Value.StartsWith("mod-ship:", StringComparison.Ordinal) || key.Value.StartsWith("mod-product:", StringComparison.Ordinal)); diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 7d3316302..c073bf37e 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -122,27 +122,25 @@ public sealed class LoadoutTests : IDisposable Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout")); } - // The shipped catalog never references a mod: a mod may be uninstalled, and a preset naming one would dangle. The - // refusal is at the only writer, so no caller (the editor command included) can get one into the file. + // The catalog is a stack of packages (ruling catalog-is-a-package-stack): a preset may name a package's design, hull or + // slot, and the only writer commits it under the package's key. Whether the design resolves is decided where the preset is + // built, not here. [Theory] [InlineData("hull", "mod-hull:mod.skiff")] [InlineData("slot", "mod-hull:mod.skiff")] [InlineData("slot", "mod-ship:mod.skiff")] - public void APresetNamingAModDesignIsRefusedAndNothingIsWritten(string where, string key) + public void APresetNamingAPackageDesignIsCommitted(string where, string key) { - var mod = new CultRecordRef(new CultRecordKey(key)); using var cache = Open(); var loadout = HandBuilt(cache); if (where == "hull") loadout.Hull = new CultRecordRef(new CultRecordKey(key)); - else loadout.Slots[1].Design = mod; - var before = Hash(Catalog); + else loadout.Slots[1].Design = new CultRecordRef(new CultRecordKey(key)); - var error = Assert.Throws(() => Loadouts.Commit(Catalog, loadout, replace: false)); + Assert.True(Loadouts.Commit(Catalog, loadout, replace: false)); - Assert.Contains("names mod designs", error.Message); - Assert.Contains(key, error.Message); - Assert.Equal(before, Hash(Catalog)); - Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false)); + using var reopened = Open(); + var written = Assert.Single(reopened.GetAll()); + Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value); } // Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates. From 80283d18a9dd1cacd70eaf70f78b42105f6efc46 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:36:14 +0200 Subject: [PATCH 085/230] Loadouts.Commit takes bossOf: the Loadout and Faction.BossLoadout land in one batch With bossOf set, the same conditional batch Expects the faction unchanged and Upserts it with BossLoadout = KeyOf(Name), so both land or neither does. The batch lives in an internal overload over a caller's cache; tests pin that a faction changed on disk after the read refuses both, and that a missing boss faction writes nothing. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 34 ++++++++--- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 67 +++++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 15e832a58..b8b73a0ab 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -96,19 +96,28 @@ public static class Loadouts _ => "" }; - // The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it - // after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the - // file; a cache that already has the catalog open sees the loadout only once it reloads. - // The catalog is a stack of packages, the shipped content included (ruling catalog-is-a-package-stack), so a loadout may - // name any design the stack holds; one whose package is later removed fails closed where it is built, with its reason logged. - // Throws and writes nothing when the file is missing (capture never creates a catalog), when a loadout of this name - // exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional - // on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then. - public static bool Commit(string catalogPath, Loadout loadout, bool replace) + // The only writer of Loadout records, and of the Faction.BossLoadout that names one. It opens its own short-lived cache + // over the catalog file, writable, and disposes it after the one commit. No other cache is touched, so no catalog + // instance play holds (or has mutated) can reach the file; a cache that already has the catalog open sees the loadout + // only once it reloads. The catalog is a stack of packages, the shipped content included (ruling + // catalog-is-a-package-stack), so a loadout may name any design the stack holds; one whose package is later removed fails + // closed where it is built, with its reason logged. With bossOf set, the same batch makes the loadout that faction's boss + // (Faction.BossLoadout), so the loadout and the reference land together or not at all. + // Throws and writes nothing when the file is missing (capture never creates a catalog), when bossOf names no faction in + // it, when a loadout of this name exists under another key, or when one exists under KeyOf(Name) and replace is not set. + // The commit is conditional on the record at KeyOf(Name) and on the faction, so a record changed on disk since this open + // is not clobbered: returns false then, and neither is written. + public static bool Commit(string catalogPath, Loadout loadout, bool replace, CultRecordRef bossOf = default) { if (!File.Exists(catalogPath)) throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one."); using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); + return Commit(cache, loadout, replace, bossOf); + } + + // The one batch Commit(path, ...) makes, over a writable catalog cache the caller opened. + internal static bool Commit(CultCache cache, Loadout loadout, bool replace, CultRecordRef bossOf) + { var key = KeyOf(loadout.Name); var namesake = cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key)); if (namesake != null) @@ -116,10 +125,17 @@ public static class Loadouts var existing = cache.Get(key); if (existing != null && !replace) throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly."); + var boss = bossOf.IsSet() + ? cache.Get(bossOf) ?? throw new InvalidOperationException($"Loadout '{loadout.Name}': the boss faction is not in the catalog.") + : null; return cache.Commit(batch => { batch.Expect(key, existing); batch.Upsert(typeof(Loadout), loadout, key); + if (boss == null) return; + batch.Expect(bossOf.Key, boss); + boss.BossLoadout = new CultRecordRef(key); + batch.Upsert(boss.GetType(), boss, bossOf.Key); }); } diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index c073bf37e..6f55baa76 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -143,6 +143,73 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value); } + // With bossOf, one commit writes the loadout and makes it that faction's boss: Faction.BossLoadout names its key. + [Fact] + public void ABossCommitWritesTheLoadoutAndTheFactionsBossReference() + { + using (var cache = Open()) + Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, cache.RefOf(cache.GetByName("Maker")))); + + using (var cache = Open()) + { + var loadout = Assert.Single(cache.GetAll()); + Assert.Equal(Loadouts.KeyOf("Skiff build"), cache.RefOf(loadout).Key); + Assert.Same(loadout, Loadouts.BossOf(cache, cache.GetByName("Maker"), out var refusal)); + Assert.Equal(BossRefusal.None, refusal); + } + } + + // The loadout and the boss reference are one batch: when the faction changed on disk after the commit's cache read it, + // the commit returns false and neither lands, though the loadout alone would have; the other writer's change survives. + [Fact] + public void ABossCommitOverAFactionChangedOnDiskWritesNeitherTheLoadoutNorTheReference() + { + Loadout loadout; + CultRecordRef maker; + using (var read = Open()) + { + loadout = HandBuilt(read); + maker = read.RefOf(read.GetByName("Maker")); + } + + using (var stale = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + Assert.NotNull(stale.Get(maker)); + using (var other = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + var fresh = other.Get(maker); + Assert.True(other.Commit(batch => + { + batch.Expect(maker.Key, fresh); + fresh.ShortName = "NEW"; + batch.Upsert(typeof(Faction), fresh, maker.Key); + })); + } + + Assert.False(Loadouts.Commit(stale, loadout, replace: false, maker)); + } + + using var after = Open(); + Assert.Empty(after.GetAll()); + var faction = after.Get(maker); + Assert.Equal("NEW", faction.ShortName); + Assert.False(faction.BossLoadout.IsSet()); + } + + // A boss faction the catalog does not hold is refused before anything is written. + [Fact] + public void ABossCommitNamingNoFactionIsRefusedAndNothingIsWritten() + { + var before = Hash(Catalog); + using (var cache = Open()) + { + var error = Assert.Throws(() => + Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, new CultRecordRef(new CultRecordKey("faction:absent")))); + Assert.Contains("boss faction is not in the catalog", error.Message); + } + Assert.Equal(before, Hash(Catalog)); + } + // Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates. [Fact] public void SameNameCaptureReplacesOnlyWhenAsked() From 3bfac250279141aef87cf56129f84c9d368d3bbb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:02:16 +0200 Subject: [PATCH 086/230] New Game is a two-pane scenario picker: names by group, selected brief/seed/ambient, one Launch, arrows and Enter MainMenu.AddScenarios and the per-scenario launching buttons are deleted; the seven Clear+Title pairs collapse into Next. The main menu EventSystem no longer deselects on background click so the arrows keep working. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scenes/Main Menu.unity | 2 +- Assets/Scripts/UI/MainMenu.cs | 68 +++++++-------- Assets/Scripts/UI/ScenarioPicker.cs | 130 ++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 38 deletions(-) create mode 100644 Assets/Scripts/UI/ScenarioPicker.cs diff --git a/Assets/Scenes/Main Menu.unity b/Assets/Scenes/Main Menu.unity index 4fafe8336..c1f111f09 100644 --- a/Assets/Scenes/Main Menu.unity +++ b/Assets/Scenes/Main Menu.unity @@ -525,7 +525,7 @@ MonoBehaviour: type: 3} m_TrackedDeviceOrientationAction: {fileID: 1025543830046995696, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} - m_DeselectOnBackgroundClick: 1 + m_DeselectOnBackgroundClick: 0 m_PointerBehavior: 0 --- !u!114 &553528108 MonoBehaviour: diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index e72ce5263..401f4b2af 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -23,7 +23,7 @@ public class MainMenu : MonoBehaviour public float FadeAlphaExponent = 2; public float FadePositionExponent = 2; - private (PropertiesPanel panel, CanvasGroup group) _currentMenu, _nextMenu; + private (PropertiesPanel panel, CanvasGroup group, PropertiesPanel detail) _currentMenu, _nextMenu; private bool _fadeFromRight; private float _fadeLerp; private bool _fading; @@ -38,10 +38,14 @@ public class MainMenu : MonoBehaviour _panelPosition = PanelPrototype.transform.position; var panel1 = PanelPrototype.Instantiate(); - _currentMenu = (panel1, panel1.GetComponent()); + var detail1 = PanelPrototype.Instantiate(); + ScenarioPicker.Dock(detail1, panel1); + _currentMenu = (panel1, panel1.GetComponent(), detail1); var panel2 = PanelPrototype.Instantiate(); - _nextMenu = (panel2, panel2.GetComponent()); + var detail2 = PanelPrototype.Instantiate(); + ScenarioPicker.Dock(detail2, panel2); + _nextMenu = (panel2, panel2.GetComponent(), detail2); _currentMenu.panel.gameObject.SetActive(false); //_saveDirectory = ActionGameManager.GameDataDirectory.CreateSubdirectory("Saves"); @@ -74,7 +78,7 @@ public class MainMenu : MonoBehaviour } } - private string TitleSubtitle(string title, string subtitle) => $"{title}\n{subtitle}"; + public static string TitleSubtitle(string title, string subtitle) => $"{title}\n{subtitle}"; private void Fade(bool fromRight) { @@ -87,10 +91,18 @@ public class MainMenu : MonoBehaviour private static bool _exclusionsShown; - private void ShowMain() + // Starts the next screen on an empty panel with a title and its scenario detail pane hidden and empty. + private void Next(string title) { _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("aetheria", "terminus"); + _nextMenu.panel.Title.text = title; + _nextMenu.detail.Clear(); + _nextMenu.detail.gameObject.SetActive(false); + } + + private void ShowMain() + { + Next(TitleSubtitle("aetheria", "terminus")); if (ActionGameManager.CurrentGalaxy == null) { // No run is live: at the title, after death, or after a new run failed to start. A saved run exists only as @@ -131,31 +143,18 @@ public class MainMenu : MonoBehaviour // to set up a run and the test arenas are listed only in editor and development builds. private void ShowScenarios() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("new game", "scenarios"); - AddScenarios(Scenarios.Modes); + Next(TitleSubtitle("new game", "scenarios")); + var groups = new List<(string label, Scenario[] scenarios)> { ("modes", Scenarios.Modes) }; if (Debug.isDebugBuild) { - _nextMenu.panel.AddSection("development"); - AddScenarios(Scenarios.Development); - _nextMenu.panel.AddSection("test arenas"); - AddScenarios(Scenarios.Tests); + groups.Add(("development", Scenarios.Development)); + groups.Add(("test arenas", Scenarios.Tests)); } - _nextMenu.panel.AddButton("Back", - () => - { - ShowMain(); - Fade(false); - }); - } - - private void AddScenarios(IEnumerable scenarios) - { - foreach (var scenario in scenarios) + ScenarioPicker.Show(_nextMenu.panel, _nextMenu.detail, groups, Launch, () => { - _nextMenu.panel.AddButton(scenario.Name, () => Launch(scenario)); - _nextMenu.panel.AddProperty(() => scenario.Brief); - } + ShowMain(); + Fade(false); + }); } // The only way into a new run: generates the scenario's galaxy off the main thread and enters the game, where @@ -263,8 +262,7 @@ public class MainMenu : MonoBehaviour private void ShowSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = "settings"; + Next("settings"); _nextMenu.panel.AddButton("Gameplay", () => { @@ -299,8 +297,7 @@ public class MainMenu : MonoBehaviour private void ShowGameplaySettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("gameplay", "settings"); + Next(TitleSubtitle("gameplay", "settings")); _nextMenu.panel.AddField("Name", () => ActionGameManager.PlayerSettings.Name, s => ActionGameManager.PlayerSettings.Name = s); @@ -322,8 +319,7 @@ public class MainMenu : MonoBehaviour private void ShowGraphicsSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("graphics", "settings"); + Next(TitleSubtitle("graphics", "settings")); _nextMenu.panel.AddField("Nebula Quality", () => (int)ActionGameManager.PlayerSettings.GraphicsSettings.NebulaQuality, i => @@ -347,8 +343,7 @@ public class MainMenu : MonoBehaviour private void ShowInputSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("input", "settings"); + Next(TitleSubtitle("input", "settings")); _nextMenu.panel.AddButton("Back", () => { @@ -359,8 +354,7 @@ public class MainMenu : MonoBehaviour private void ShowAudioSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("audio", "settings"); + Next(TitleSubtitle("audio", "settings")); _nextMenu.panel.AddButton("Back", () => { diff --git a/Assets/Scripts/UI/ScenarioPicker.cs b/Assets/Scripts/UI/ScenarioPicker.cs new file mode 100644 index 000000000..4d6ab393e --- /dev/null +++ b/Assets/Scripts/UI/ScenarioPicker.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using UniRx; +using UniRx.Triggers; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +// The New Game screen: scenario names on the left pane by group, the selected scenario's brief, seed and ambient on a +// docked right pane, one Launch. This class owns which scenario is selected (one index into the rows it built); +// everything the screen shows about a scenario, and what Launch and Enter launch, reads that index. Keys arrive only as +// Input System UI events on the focused Launch button, so Enter and a mouse click are the same Button.onClick. +public sealed class ScenarioPicker +{ + private readonly List _scenarios = new List(); + private readonly List _groupLabels = new List(); + private readonly List