diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index fb7ce3c7c..3c8121b72 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -304,11 +304,11 @@ public static class EconomyChecks } var final = Build(pick, 0); - var (rho, component) = Radius(final, n); var faults = new List(); - if (rho >= 1 - Margin) + string Name(int i) => i == labour ? "labour" : names[i]; + foreach (var (rho, component) in Radii(final, n)) { - string Name(int i) => i == labour ? "labour" : names[i]; + if (rho < 1 - Margin) continue; var order = CycleOrder(final, n, component); faults.Add(new EconomyFault(LProductive, Name(order[0]), $"The goods cannot sustain themselves: spectral radius {rho:0.######} is not below 1, carried by {string.Join(" -> ", order.Select(Name))}.", @@ -336,9 +336,9 @@ public static class EconomyChecks return u; } - // The spectral radius of a nonnegative matrix: the largest over its strongly connected components that hold a cycle, each by - // power iteration on A + I (primitive when irreducible). An edge j -> i is a nonzero A[i, j]. Also the component carrying it. - private static (double Rho, List Component) Radius(double[,] a, int n) + // The spectral radius of every strongly connected component of a nonnegative matrix, each by power iteration on A + I + // (primitive when irreducible). An edge j -> i is a nonzero A[i, j]. A component with no cycle has radius 0. + private static List<(double Rho, List Component)> Radii(double[,] a, int n) { var reach = new bool[n, n]; for (var i = 0; i < n; i++) @@ -350,8 +350,7 @@ public static class EconomyChecks for (var j = 0; j < n; j++) if (reach[k, j]) reach[i, j] = true; var done = new bool[n]; - var best = 0.0; - var bestComponent = new List(); + var radii = new List<(double, List)>(); for (var start = 0; start < n; start++) { if (done[start]) continue; @@ -359,9 +358,9 @@ public static class EconomyChecks foreach (var i in component) done[i] = true; var rho = Irreducible(a, component); if (double.IsNaN(rho)) rho = double.PositiveInfinity; - if (rho > best) { best = rho; bestComponent = component; } + radii.Add((rho, component)); } - return (best, bestComponent); + return radii; } private static double Irreducible(double[,] a, List nodes) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 4d4efbe98..7fc19f641 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -416,6 +416,28 @@ public sealed class EconomyCommandTests Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ANewUnproductiveCycleBelowTheHeldRadiusIsRefusedAloneOrBesideALoweringOfTheHeldCycle(bool alsoLowerHeld) + { + using var f = EconomyFixture.Healthy(); + f.Good("loopa", inputs: new[] { ("loopb", 2) }); + f.Good("loopb", inputs: new[] { ("loopa", 4) }); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + var code = Author(f, true, out var output, a => + { + if (alsoLowerHeld) Good(f, a, "loopb", inputs: new[] { ("loopa", 2) }); + Good(f, a, "loopc", inputs: new[] { ("loopd", 1) }); + Good(f, a, "loopd", inputs: new[] { ("loope", 1) }); + Good(f, a, "loope", inputs: new[] { ("loopc", 2) }); + }); + Assert.Equal(1, code); + Assert.Contains("L-productive", output); + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + [Fact] public void AHeldCycleWhoseRadiusFallsStillApplies() { @@ -520,6 +542,23 @@ public sealed class EconomyCommandTests Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(f.CatalogPath), "*.economy-*")); } + [Fact] + public void ACatalogHeldOpenForWritingIsANamedRefusalAndStaysIntact() + { + if (!OperatingSystem.IsWindows()) return; + using var f = EconomyFixture.Healthy(); + Flush(f); + var bytes = File.ReadAllBytes(f.CatalogPath); + using (new FileStream(f.CatalogPath, FileMode.Open, FileAccess.ReadWrite, FileShare.Read)) + { + var code = Author(f, true, out var output, a => Good(f, a, "coal")); + Assert.Equal(1, code); + Assert.Contains("close Unity", output); + Assert.DoesNotContain(" at ", output); + } + Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); + } + [Fact] public void TheSwapKeepsTheCatalogsFileMode() { diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index 6c6cc15c2..6981cc2e3 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -121,19 +121,26 @@ public static class EconomyCommand try { var trial = Path.Combine(scratch, "Aetheria.cc"); - var fingerprint = Fingerprint(catalogPath); - File.Copy(catalogPath, trial); - new FileInfo(trial).IsReadOnly = false; + string fingerprint; Dictionary held; HashSet recipesHeld; Dictionary original; - using (var cache = AetheriaStores.Open(catalogPath, registry: registry)) + try { + fingerprint = Fingerprint(catalogPath); + File.Copy(catalogPath, trial); + new FileInfo(trial).IsReadOnly = false; + using var cache = AetheriaStores.Open(catalogPath, registry: registry); held = new Dictionary(StringComparer.Ordinal); foreach (var fault in EconomyChecks.Check(cache).Faults) held[Identity(fault)] = fault; recipesHeld = new HashSet(cache.GetAll().Select(r => cache.RefOf(r).Key.Value), StringComparer.Ordinal); original = Records(cache); } + catch (Exception e) when (e is UnauthorizedAccessException || e is IOException) + { + output.WriteLine(Held("read", e)); + return 1; + } var touched = new List(); int written; using (var cache = AetheriaStores.Open(trial, catalogWritable: true, registry: registry, kindlessStation: warning => output.WriteLine("warning: " + warning))) @@ -214,6 +221,10 @@ public static class EconomyCommand } } + // Something holds Aetheria.cc open: a refusal that names the holder to close, never an exception. + private static string Held(string verb, Exception e) => + $"The batches are refused: Aetheria.cc could not be {verb} ({e.Message}). Something holds the file: close Unity and any other editor or tool that has it open, then run again. The catalog is unchanged."; + // A fault is the same fault by rule, record and message, so fixing one fault on a record cannot hide a new one there. L-productive // is one fault per cycle: its identity is the cycle's member set, and the same cycle with a higher radius is a worse fault. private static string Identity(EconomyFault fault) =>