2026-10-23 05:26:47 +02:00
using System ;
using System.Collections.Generic ;
using System.IO ;
2026-10-23 05:28:42 +02:00
using System.Linq ;
2026-10-23 05:26:47 +02:00
using GameCult.Caching ;
2026-10-23 05:28:42 +02:00
using GameCult.Caching.MessagePack ;
using MessagePack ;
2026-10-23 05:26:47 +02:00
2026-10-23 05:28:42 +02:00
// One batch of economy content: commodities, recipes, station kinds with stand-ins, outside-market rows and edits to existing
// items, written through EconomyAuthor. A batch is a class under tools/AetherDb/Economy/, named on the command line by its class
// name. It writes what it names and nothing else, so a revised batch converges and a rerun writes nothing.
2026-10-23 05:26:47 +02:00
public abstract class EconomyBatch
{
public abstract void Author ( EconomyAuthor author ) ;
}
2026-10-23 06:03:29 +02:00
// What a batch writes through. The command compares the trial catalog with the original on disk, so a write here is only recorded
// by key, in the order the batch made it.
2026-10-23 05:26:47 +02:00
public sealed class EconomyAuthor
{
2026-10-23 05:28:42 +02:00
private readonly CultCache _catalog ;
2026-10-23 06:03:29 +02:00
private readonly List < string > _touched = new List < string > ( ) ;
2026-10-23 05:28:42 +02:00
public EconomyAuthor ( CultCache catalog ) = > _catalog = catalog ;
2026-10-23 06:03:29 +02:00
// The keys this author wrote, in order, once each.
public IReadOnlyList < string > Touched = > _touched ;
2026-10-23 05:28:42 +02:00
// The catalog as the batch has left it so far, for a batch that builds on its own earlier writes.
public CultCache Catalog = > _catalog ;
// Writes a record under a key: a new commodity, a recipe, a station kind, an outside market.
2026-10-23 06:03:29 +02:00
public void Put < T > ( T document , CultRecordKey key ) where T : class = > Write ( typeof ( T ) , document , key ) ;
2026-10-23 05:28:42 +02:00
// Edits the one item of that name and type. A name that matches no item or several is an error the command reports.
public void Edit < T > ( string name , Action < T > edit ) where T : ItemData
{
var named = _catalog . GetAll < T > ( ) . Where ( item = > item . Name = = name ) . ToArray ( ) ;
if ( named . Length ! = 1 )
throw new InvalidOperationException ( named . Length = = 0 ? $"The catalog holds no {typeof(T).Name} named '{name}'." : $"{named.Length} {typeof(T).Name} records are named '{name}'." ) ;
edit ( named [ 0 ] ) ;
2026-10-23 06:03:29 +02:00
Write ( named [ 0 ] . GetType ( ) , named [ 0 ] , _catalog . RefOf ( named [ 0 ] ) . Key ) ;
2026-10-23 05:28:42 +02:00
}
2026-10-23 06:03:29 +02:00
private void Write ( Type type , object document , CultRecordKey key )
2026-10-23 05:28:42 +02:00
{
2026-10-23 06:03:29 +02:00
if ( ! _touched . Contains ( key . Value ) ) _touched . Add ( key . Value ) ;
_catalog . UpsertAsync ( type , document , key ) . GetAwaiter ( ) . GetResult ( ) ;
2026-10-23 05:28:42 +02:00
}
2026-10-23 05:26:47 +02:00
}
2026-10-23 05:28:42 +02:00
// economy report | author <batch>... [apply]: the one writer of economy content and a reader of the catalog's economy report.
// EconomyChecks owns every rule; this command only calls it.
2026-10-23 05:26:47 +02:00
public static class EconomyCommand
{
2026-10-23 06:03:44 +02:00
// The invariants whose faults the batches introduce refuse them. The rest (a missing sink, no consumer demand, a commodity
2026-10-23 05:28:42 +02:00
// with no recipe yet) are completeness: reported, and settled by the batch that closes the chain.
private static readonly string [ ] Shape =
{ EconomyChecks . LRecipe , EconomyChecks . LLore , EconomyChecks . LProductive , EconomyChecks . LOneKindList } ;
2026-10-23 06:03:29 +02:00
// A batch is compiled code under tools/AetherDb/Economy/ and runs with the operator's authority: reviewed code, never an input.
2026-10-23 06:03:44 +02:00
private const string Usage = "economy report | author <batch>... [apply]\nA batch is reviewed code: it runs on a trial copy and must write only through EconomyAuthor." ;
2026-10-23 05:28:42 +02:00
2026-10-27 04:38:08 +01:00
public static int Run ( string [ ] args , TextWriter output )
2026-10-23 05:28:42 +02:00
{
if ( args . Length > 0 & & args [ 0 ] = = "report" )
{
using var db = AetherDb . Open ( ) ;
2026-10-27 04:38:08 +01:00
return Report ( db . Cache , output ) ;
2026-10-23 05:28:42 +02:00
}
if ( args . Length > 1 & & args [ 0 ] = = "author" )
{
var apply = args . Contains ( "apply" ) ;
var names = args . Skip ( 1 ) . Where ( a = > a ! = "apply" ) . ToArray ( ) ;
var known = typeof ( EconomyBatch ) . Assembly . GetTypes ( ) . Where ( t = > ! t . IsAbstract & & t . IsSubclassOf ( typeof ( EconomyBatch ) ) )
. OrderBy ( t = > t . Name , StringComparer . Ordinal ) . ToArray ( ) ;
var unknown = names . Where ( n = > known . All ( t = > ! string . Equals ( t . Name , n , StringComparison . OrdinalIgnoreCase ) ) ) . ToArray ( ) ;
if ( unknown . Length > 0 | | names . Length = = 0 )
{
2026-10-27 04:38:08 +01:00
output . WriteLine ( $"{unknown.Length} of the names given match no batch. Batches: {string.Join(" , ", known.Select(t => t.Name))}" ) ;
2026-10-23 05:28:42 +02:00
return 1 ;
}
var batches = names . Select ( n = > ( EconomyBatch ) Activator . CreateInstance ( known . Single ( t = > string . Equals ( t . Name , n , StringComparison . OrdinalIgnoreCase ) ) ) ) . ToList ( ) ;
2026-10-27 04:38:08 +01:00
return Author ( Path . Combine ( AetherDb . FindRoot ( ) , "GameData" , "Aetheria.cc" ) , batches , apply , output ) ;
2026-10-23 05:28:42 +02:00
}
2026-10-27 04:38:08 +01:00
output . WriteLine ( Usage ) ;
2026-10-23 05:28:42 +02:00
return 1 ;
}
// Faults grouped by invariant, then one line per good with its depth, then one line per recipe. Fails on any fault.
public static int Report ( CultCache catalog , TextWriter output )
{
var report = EconomyChecks . Check ( catalog ) ;
PrintFaults ( report , output ) ;
PrintDepth ( report , output ) ;
output . WriteLine ( ) ;
foreach ( var recipe in catalog . GetAll < RecipeData > ( ) . OrderBy ( r = > catalog . RefOf ( r ) . Key . Value , StringComparer . Ordinal ) )
{
2026-10-27 10:56:01 +01:00
var takes = string . Join ( ", " , ( recipe . Inputs ? ? new List < CommodityCount > ( ) ) . Where ( c = > c ! = null ) . Select ( c = > $"{c.Count} x {c.Commodity.Key.Value}" )
. Concat ( Recipes . RoleInputsOf ( catalog , recipe ) . Where ( r = > r ! = null ) . Select ( r = > $"{r.Count} x {r.Name}" ) ) ) ;
2026-10-23 05:28:42 +02:00
output . WriteLine ( $"{recipe.Output.Key.Value} <- {(takes.Length == 0 ? " nothing " : takes)} | {catalog.Get(recipe.StationKind)?.Name ?? " no kind "}, cycle {recipe.Cycle}, workforce {recipe.Workforce} | {recipe.LoreHook}" ) ;
}
return report . Faults . Count = = 0 ? 0 : 1 ;
}
2026-10-23 06:03:29 +02:00
// Runs the batches once, on a copy of the catalog, and checks that copy. What apply writes is that checked copy, swapped in
// atomically, so a refusal leaves Aetheria.cc byte-identical. A batch is compiled code and is reviewed as code: it is trusted not
// to touch the real file, and the command refuses by name if the file changed under it. Added, changed and unchanged are read by
// comparing the original and the copy on disk. A shape fault the batches introduce (present after, absent before, by rule and
// record) refuses; faults the catalog already held print and do not.
2026-10-23 05:28:42 +02:00
public static int Author ( string catalogPath , IReadOnlyList < EconomyBatch > batches , bool apply , TextWriter output , CultDocumentRegistry registry = null )
{
2026-10-23 06:38:50 +02:00
if ( apply & & 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 ;
}
2026-10-23 05:28:42 +02:00
var scratch = Directory . CreateDirectory ( Path . Combine ( Path . GetTempPath ( ) , "aetherdb-economy-" + Guid . NewGuid ( ) . ToString ( "N" ) ) ) . FullName ;
try
{
var trial = Path . Combine ( scratch , "Aetheria.cc" ) ;
2026-10-23 07:15:59 +02:00
string fingerprint ;
2026-10-23 06:28:20 +02:00
Dictionary < string , EconomyFault > held ;
HashSet < string > recipesHeld ;
2026-10-23 06:03:29 +02:00
Dictionary < string , byte [ ] > original ;
2026-10-23 07:15:59 +02:00
try
2026-10-23 06:03:29 +02:00
{
2026-10-23 07:15:59 +02:00
fingerprint = Fingerprint ( catalogPath ) ;
File . Copy ( catalogPath , trial ) ;
new FileInfo ( trial ) . IsReadOnly = false ;
using var cache = AetheriaStores . Open ( catalogPath , registry : registry ) ;
2026-10-23 06:28:20 +02:00
held = new Dictionary < string , EconomyFault > ( StringComparer . Ordinal ) ;
foreach ( var fault in EconomyChecks . Check ( cache ) . Faults ) held [ Identity ( fault ) ] = fault ;
recipesHeld = new HashSet < string > ( cache . GetAll < RecipeData > ( ) . Select ( r = > cache . RefOf ( r ) . Key . Value ) , StringComparer . Ordinal ) ;
2026-10-23 06:03:29 +02:00
original = Records ( cache ) ;
}
2026-10-23 07:15:59 +02:00
catch ( Exception e ) when ( e is UnauthorizedAccessException | | e is IOException )
{
output . WriteLine ( Held ( "read" , e ) ) ;
return 1 ;
}
2026-10-23 06:03:29 +02:00
var touched = new List < string > ( ) ;
int written ;
2026-10-23 05:28:42 +02:00
using ( var cache = AetheriaStores . Open ( trial , catalogWritable : true , registry : registry , kindlessStation : warning = > output . WriteLine ( "warning: " + warning ) ) )
{
var author = new EconomyAuthor ( cache ) ;
2026-10-23 06:03:29 +02:00
var running = "" ;
2026-10-23 05:28:42 +02:00
try
{
2026-10-23 06:03:29 +02:00
foreach ( var batch in batches )
{
running = batch . GetType ( ) . Name ;
batch . Author ( author ) ;
}
running = "" ;
2026-10-23 05:28:42 +02:00
StationKinds . Validate ( cache ) ;
}
catch ( Exception e )
{
2026-10-23 06:03:29 +02:00
output . WriteLine ( running . Length = = 0 ? $"The batches are refused: {e.Message}" : $"The batch {running} is refused: {e.Message}" ) ;
2026-10-23 05:28:42 +02:00
return 1 ;
}
2026-10-23 06:03:29 +02:00
touched . AddRange ( author . Touched ) ;
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
}
if ( Fingerprint ( catalogPath ) ! = fingerprint )
{
output . WriteLine ( "The batches are refused: Aetheria.cc changed during the trial. Batch code must write only through EconomyAuthor." ) ;
return 1 ;
}
using ( var cache = AetheriaStores . Open ( trial , registry : registry ) )
{
var trialRecords = Records ( cache ) ;
var status = new Dictionary < string , string > ( StringComparer . Ordinal ) ;
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" ;
2026-10-23 06:28:20 +02:00
foreach ( var id in original . Keys )
if ( ! trialRecords . ContainsKey ( id ) ) status [ KeyOf ( id ) ] = "removed" ;
2026-10-23 06:03:29 +02:00
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}" ) ;
2026-10-23 06:28:20 +02:00
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" ) ;
2026-10-23 06:03:29 +02:00
var report = EconomyChecks . Check ( cache ) ;
2026-10-23 05:28:42 +02:00
PrintFaults ( report , output ) ;
PrintDepth ( report , output ) ;
2026-10-23 06:28:20 +02:00
var introduced = report . Faults . Where ( fault = > Shape . Contains ( fault . Invariant ) & & Introduced ( fault , held , recipesHeld ) ) . ToArray ( ) ;
2026-10-23 06:03:29 +02:00
if ( introduced . Length > 0 )
2026-10-23 05:28:42 +02:00
{
2026-10-23 06:03:29 +02:00
output . WriteLine ( $"\nThe batch is refused: {introduced.Length} shape faults the batches introduce." ) ;
foreach ( var fault in introduced ) output . WriteLine ( $" {fault}" ) ;
2026-10-23 05:28:42 +02:00
return 1 ;
}
2026-10-23 06:03:29 +02:00
written = status . Count ;
if ( status . Count = = 0 | | ! apply )
{
output . WriteLine ( status . Count = = 0 ? "\nNothing to write." : "\nDry run. Pass \"apply\" to write Aetheria.cc." ) ;
return 0 ;
}
2026-10-23 05:28:42 +02:00
}
2026-10-23 06:03:29 +02:00
var staged = catalogPath + ".economy-" + Guid . NewGuid ( ) . ToString ( "N" ) ;
2026-10-23 06:28:20 +02:00
try
{
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 )
{
2026-10-23 06:32:52 +02:00
output . WriteLine ( $"\nThe 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." ) ;
2026-10-23 06:28:20 +02:00
return 1 ;
}
2026-10-23 06:09:35 +02:00
finally { if ( File . Exists ( staged ) ) File . Delete ( staged ) ; }
2026-10-23 06:03:29 +02:00
output . WriteLine ( $"\nWrote {written} records to Aetheria.cc" ) ;
2026-10-23 05:28:42 +02:00
return 0 ;
}
finally
{
try { Directory . Delete ( scratch , true ) ; } catch ( IOException ) { }
}
}
2026-10-23 07:15:59 +02:00
// 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." ;
2026-10-27 04:38:08 +01:00
// A held cycle's radius may rise by this fraction without being a worse fault: it is float noise from rebuilding the same graph.
private const double RiseNoise = 1e-12 ;
2026-10-23 06:28:20 +02:00
// 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 < string , EconomyFault > held , HashSet < string > 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 ;
2026-10-27 04:38:08 +01:00
return fault . Members ! = null & & fault . Radius > was . Radius * ( 1 + RiseNoise ) ;
2026-10-23 06:28:20 +02:00
}
2026-10-23 06:03:29 +02:00
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 ) ) ) ;
// Every record in the catalog as its wire bytes, under "schema|key".
private static Dictionary < string , byte [ ] > Records ( CultCache catalog ) = >
catalog . AllStoredDocuments . ToDictionary ( d = > d . Descriptor . SchemaName + "|" + d . Key . Value , d = > MessagePackSerializer . Serialize ( d . Document . GetType ( ) , d . Document , Wire ) , StringComparer . Ordinal ) ;
private static string KeyOf ( string id ) = > id . Substring ( id . IndexOf ( '|' ) + 1 ) ;
2026-10-23 05:28:42 +02:00
private static void PrintFaults ( EconomyReport report , TextWriter output )
{
output . WriteLine ( $"{report.Faults.Count} faults" ) ;
foreach ( var group in report . Faults . GroupBy ( f = > f . Invariant ) . OrderBy ( g = > g . Key , StringComparer . Ordinal ) )
{
output . WriteLine ( $"{group.Key} ({group.Count()})" ) ;
foreach ( var fault in group ) output . WriteLine ( $" {fault.Record}: {fault.Message}" ) ;
}
}
private static void PrintDepth ( EconomyReport report , TextWriter output )
{
output . WriteLine ( "\nDepth" ) ;
foreach ( var pair in report . Depth . OrderBy ( p = > p . Key , StringComparer . Ordinal ) )
output . WriteLine ( $" {pair.Key} depth {(pair.Value.HasValue ? pair.Value.Value.ToString() : " none ")}" ) ;
}
2026-10-23 05:26:47 +02:00
}