diff --git a/Assets/Scripts/ServerShared/EconomyChecks.cs b/Assets/Scripts/ServerShared/EconomyChecks.cs index ce2b49317..fb7ce3c7c 100644 --- a/Assets/Scripts/ServerShared/EconomyChecks.cs +++ b/Assets/Scripts/ServerShared/EconomyChecks.cs @@ -22,8 +22,15 @@ public sealed class EconomyFault // A fault only because the chain has not written the record yet (a commodity with no recipe). Reported, never a refusal. public readonly bool Pending; - public EconomyFault(string invariant, string record, string message, bool pending = false) + // L-productive only: every good the carrying cycle holds (sorted) and the spectral radius. The fault's identity is the member + // set; a radius that rises is a worse fault of the same identity. + public readonly IReadOnlyList Members; + public readonly double Radius; + + public EconomyFault(string invariant, string record, string message, bool pending = false, IReadOnlyList members = null, double radius = 0) { + Members = members; + Radius = radius; Pending = pending; Invariant = invariant; Record = record; @@ -304,7 +311,8 @@ public static class EconomyChecks string Name(int i) => i == labour ? "labour" : names[i]; 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))}.")); + $"The goods cannot sustain themselves: spectral radius {rho:0.######} is not below 1, carried by {string.Join(" -> ", order.Select(Name))}.", + members: order.Select(Name).OrderBy(name => name, StringComparer.Ordinal).ToArray(), radius: rho)); } return faults; } diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index 240f6a118..9385e181a 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -118,11 +118,14 @@ public static class EconomyCommand var trial = Path.Combine(scratch, "Aetheria.cc"); var fingerprint = Fingerprint(catalogPath); File.Copy(catalogPath, trial); - HashSet<(string, string)> held; + Dictionary held; + HashSet recipesHeld; Dictionary original; using (var cache = AetheriaStores.Open(catalogPath, registry: registry)) { - held = new HashSet<(string, string)>(EconomyChecks.Check(cache).Faults.Select(f => (f.Invariant, f.Record))); + 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); } var touched = new List(); @@ -161,13 +164,15 @@ public static class EconomyCommand foreach (var (id, bytes) in trialRecords) if (!original.TryGetValue(id, out var was)) status[KeyOf(id)] = "added"; else if (!was.AsSpan().SequenceEqual(bytes)) status[KeyOf(id)] = "changed"; + foreach (var id in original.Keys) + if (!trialRecords.ContainsKey(id)) status[KeyOf(id)] = "removed"; var listed = touched.Concat(status.Keys.OrderBy(k => k, StringComparer.Ordinal).Where(k => !touched.Contains(k))).ToList(); foreach (var key in listed) output.WriteLine($"{(status.TryGetValue(key, out var s) ? s : "unchanged")} {key}"); - output.WriteLine($"{status.Values.Count(v => v == "added")} added, {status.Values.Count(v => v == "changed")} changed, {listed.Count - status.Count} unchanged"); + output.WriteLine($"{status.Values.Count(v => v == "added")} added, {status.Values.Count(v => v == "changed")} changed, {status.Values.Count(v => v == "removed")} removed, {listed.Count - status.Count} unchanged"); var report = EconomyChecks.Check(cache); PrintFaults(report, output); PrintDepth(report, output); - var introduced = report.Faults.Where(fault => Shape.Contains(fault.Invariant) && !fault.Pending && !held.Contains((fault.Invariant, fault.Record))).ToArray(); + var introduced = report.Faults.Where(fault => Shape.Contains(fault.Invariant) && Introduced(fault, held, recipesHeld)).ToArray(); if (introduced.Length > 0) { output.WriteLine($"\nThe batch is refused: {introduced.Length} shape faults the batches introduce."); @@ -182,8 +187,24 @@ public static class EconomyCommand } } var staged = catalogPath + ".economy-" + Guid.NewGuid().ToString("N"); - File.Copy(trial, staged); - try { File.Replace(staged, catalogPath, null); } + try + { + if (new FileInfo(catalogPath).IsReadOnly) + { + output.WriteLine(" +The batches are refused: Aetheria.cc is read-only. Clear the read-only flag, or check it out, and run again. The catalog is unchanged."); + return 1; + } + File.Copy(trial, staged); + if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(staged, File.GetUnixFileMode(catalogPath)); + File.Replace(staged, catalogPath, null); + } + catch (Exception e) when (e is UnauthorizedAccessException || e is IOException) + { + output.WriteLine($" +The batches are refused: Aetheria.cc could not be replaced ({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."); + return 1; + } finally { if (File.Exists(staged)) File.Delete(staged); } output.WriteLine($"\nWrote {written} records to Aetheria.cc"); return 0; @@ -194,6 +215,19 @@ public static class EconomyCommand } } + // 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) => + fault.Members != null ? fault.Invariant + "|" + string.Join(",", fault.Members) : fault.Invariant + "|" + fault.Record + "|" + fault.Message; + + private static bool Introduced(EconomyFault fault, Dictionary held, HashSet recipesHeld) + { + // "No recipe" is pending for a commodity that had none; it refuses where the batch took away a recipe the catalog held. + if (fault.Pending) return recipesHeld.Contains(RecipeData.KeyOf(new CultRecordKey(fault.Record)).Value); + if (!held.TryGetValue(Identity(fault), out var was)) return true; + return fault.Members != null && fault.Radius > was.Radius + 1e-9; + } + private static readonly MessagePackSerializerOptions Wire = CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly); private static string Fingerprint(string path) => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(path)));