feat: Factorio Space Age Production Calculator
- Domain: immutable entities (Resource, Recipe, Machine, Module, etc.) - Data: JSON recipe repository (34 resources, 26 recipes, Space Age) - Solver: DFS-based production planner with module support - Reporting: Console, JSON, Excel exporters - CLI: solve/list commands with Spectre.Console - Tests: 20 unit tests (xUnit) all passing - Tech: C# .NET 10, MathNet.Numerics, ClosedXML Model: GPT-4.1 (OpenAI) — executed by pi coding agent
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
using FactorioCalc.Domain;
|
||||
|
||||
namespace FactorioCalc.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// Formats production results as a human-readable console table.
|
||||
/// </summary>
|
||||
public sealed class ConsoleReporter : IReporter
|
||||
{
|
||||
public void Report(ProductionResult result, IReadOnlyDictionary<int, Resource> resources,
|
||||
IReadOnlyDictionary<int, Recipe> recipes, IReadOnlyDictionary<int, Machine> machines)
|
||||
{
|
||||
if (result.Executions.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No production required — all targets are raw resources.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(new string('=', 100));
|
||||
Console.WriteLine(" FACTORIO SPACE AGE — PRODUCTION PLAN");
|
||||
Console.WriteLine(new string('=', 100));
|
||||
|
||||
// Machine executions table
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" RECIPE EXECUTIONS:");
|
||||
Console.WriteLine(new string('-', 100));
|
||||
Console.WriteLine(PadRight(" Recipe", 37) + PadRight("Rate (cyc/s)", 16) + PadRight("Machine", 22) + " Count");
|
||||
Console.WriteLine(new string('-', 100));
|
||||
|
||||
foreach (var exec in result.Executions)
|
||||
{
|
||||
var recipeName = recipes.TryGetValue(exec.RecipeId, out var r) ? r.Name : "Recipe#" + exec.RecipeId;
|
||||
var machineName = machines.TryGetValue(exec.MachineId, out var m) ? m.Name : "Machine#" + exec.MachineId;
|
||||
|
||||
var rateStr = exec.RecipeRate.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
|
||||
Console.WriteLine(PadRight(" " + recipeName, 37) + PadRight(rateStr, 16) + PadRight(machineName, 22) + " " + exec.MachineCount);
|
||||
}
|
||||
|
||||
Console.WriteLine(new string('-', 100));
|
||||
|
||||
// Resource flows
|
||||
if (result.ResourceFlows.Count > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" RESOURCE FLOWS (items/sec):");
|
||||
Console.WriteLine(new string('-', 60));
|
||||
Console.WriteLine(PadRight(" Resource", 32) + PadRight("Flow", 14) + " (items/min)");
|
||||
Console.WriteLine(new string('-', 60));
|
||||
|
||||
foreach (var (resourceId, flow) in result.ResourceFlows.OrderBy(k => k.Value))
|
||||
{
|
||||
var resourceName = resources.TryGetValue(resourceId, out var res) ? res.Name : "Resource#" + resourceId;
|
||||
var sign = flow < 0 ? "" : "+";
|
||||
var flowStr = sign + flow.ToString("F1", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var minStr = (flow * 60).ToString("F1", System.Globalization.CultureInfo.InvariantCulture);
|
||||
Console.WriteLine(PadRight(" " + resourceName, 32) + PadRight(flowStr, 14) + " " + minStr);
|
||||
}
|
||||
|
||||
Console.WriteLine(new string('-', 60));
|
||||
}
|
||||
|
||||
// Required raw inputs
|
||||
if (result.RequiredInputs.Count > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" REQUIRED RAW INPUTS:");
|
||||
Console.WriteLine(new string('-', 60));
|
||||
Console.WriteLine(PadRight(" Resource", 32) + PadRight("items/sec", 14) + " items/min");
|
||||
Console.WriteLine(new string('-', 60));
|
||||
|
||||
foreach (var (resourceId, amount) in result.RequiredInputs)
|
||||
{
|
||||
var resourceName = resources.TryGetValue(resourceId, out var res) ? res.Name : "Resource#" + resourceId;
|
||||
var secStr = amount.ToString("F1", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var minStr = (amount * 60).ToString("F1", System.Globalization.CultureInfo.InvariantCulture);
|
||||
Console.WriteLine(PadRight(" " + resourceName, 32) + PadRight(secStr, 14) + " " + minStr);
|
||||
}
|
||||
|
||||
Console.WriteLine(new string('-', 60));
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
private static string PadRight(string value, int totalWidth)
|
||||
{
|
||||
if (value.Length >= totalWidth)
|
||||
return value[..totalWidth];
|
||||
return value + new string(' ', totalWidth - value.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using ClosedXML.Excel;
|
||||
using FactorioCalc.Domain;
|
||||
|
||||
namespace FactorioCalc.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// Exports production results to an Excel (.xlsx) file.
|
||||
/// </summary>
|
||||
public sealed class ExcelReporter : IReporter
|
||||
{
|
||||
private readonly string _outputPath;
|
||||
|
||||
public ExcelReporter(string outputPath)
|
||||
{
|
||||
_outputPath = outputPath ?? throw new ArgumentNullException(nameof(outputPath));
|
||||
}
|
||||
|
||||
public void Report(ProductionResult result, IReadOnlyDictionary<int, Resource> resources,
|
||||
IReadOnlyDictionary<int, Recipe> recipes, IReadOnlyDictionary<int, Machine> machines)
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
|
||||
// Sheet 1: Recipe Executions
|
||||
var execSheet = workbook.Worksheets.Add("Executions");
|
||||
execSheet.Cell(1, 1).Value = "Recipe";
|
||||
execSheet.Cell(1, 2).Value = "Rate (cycles/sec)";
|
||||
execSheet.Cell(1, 3).Value = "Machine";
|
||||
execSheet.Cell(1, 4).Value = "Machine Count";
|
||||
execSheet.Cell(1, 5).Value = "Effective Speed";
|
||||
execSheet.Cell(1, 6).Value = "Effective Productivity";
|
||||
execSheet.Cell(1, 7).Value = "Modules";
|
||||
|
||||
var execHeader = execSheet.Range(1, 1, 1, 7);
|
||||
execHeader.Style.Font.Bold = true;
|
||||
execHeader.Style.Fill.SetBackgroundColor(XLColor.LightBlue);
|
||||
|
||||
int execRow = 2;
|
||||
foreach (var exec in result.Executions)
|
||||
{
|
||||
var recipeName = recipes.TryGetValue(exec.RecipeId, out var r) ? r.Name : $"Recipe#{exec.RecipeId}";
|
||||
var machineName = machines.TryGetValue(exec.MachineId, out var m) ? m.Name : $"Machine#{exec.MachineId}";
|
||||
var modulesStr = string.Join(", ", exec.Modules.Select(m => m.Name));
|
||||
|
||||
execSheet.Cell(execRow, 1).Value = recipeName;
|
||||
execSheet.Cell(execRow, 2).Value = exec.RecipeRate;
|
||||
execSheet.Cell(execRow, 3).Value = machineName;
|
||||
execSheet.Cell(execRow, 4).Value = exec.MachineCount;
|
||||
execSheet.Cell(execRow, 5).Value = exec.EffectiveSpeed;
|
||||
execSheet.Cell(execRow, 6).Value = exec.EffectiveProductivity;
|
||||
execSheet.Cell(execRow, 7).Value = modulesStr;
|
||||
execRow++;
|
||||
}
|
||||
|
||||
execSheet.Columns().AdjustToContents();
|
||||
|
||||
// Sheet 2: Resource Flows
|
||||
var flowSheet = workbook.Worksheets.Add("Resource Flows");
|
||||
flowSheet.Cell(1, 1).Value = "Resource";
|
||||
flowSheet.Cell(1, 2).Value = "Flow (items/sec)";
|
||||
flowSheet.Cell(1, 3).Value = "Flow (items/min)";
|
||||
|
||||
var flowHeader = flowSheet.Range(1, 1, 1, 3);
|
||||
flowHeader.Style.Font.Bold = true;
|
||||
flowHeader.Style.Fill.SetBackgroundColor(XLColor.LightGreen);
|
||||
|
||||
int flowRow = 2;
|
||||
foreach (var (resourceId, flow) in result.ResourceFlows.OrderBy(k => k.Value))
|
||||
{
|
||||
var resourceName = resources.TryGetValue(resourceId, out var res) ? res.Name : $"Resource#{resourceId}";
|
||||
flowSheet.Cell(flowRow, 1).Value = resourceName;
|
||||
flowSheet.Cell(flowRow, 2).Value = flow;
|
||||
flowSheet.Cell(flowRow, 3).Value = flow * 60;
|
||||
flowRow++;
|
||||
}
|
||||
|
||||
flowSheet.Columns().AdjustToContents();
|
||||
|
||||
// Sheet 3: Required Inputs
|
||||
var inputSheet = workbook.Worksheets.Add("Required Inputs");
|
||||
inputSheet.Cell(1, 1).Value = "Resource";
|
||||
inputSheet.Cell(1, 2).Value = "items/sec";
|
||||
inputSheet.Cell(1, 3).Value = "items/min";
|
||||
|
||||
var inputHeader = inputSheet.Range(1, 1, 1, 3);
|
||||
inputHeader.Style.Font.Bold = true;
|
||||
inputHeader.Style.Fill.SetBackgroundColor(XLColor.Orange);
|
||||
|
||||
int inputRow = 2;
|
||||
foreach (var (resourceId, amount) in result.RequiredInputs)
|
||||
{
|
||||
var resourceName = resources.TryGetValue(resourceId, out var res) ? res.Name : $"Resource#{resourceId}";
|
||||
inputSheet.Cell(inputRow, 1).Value = resourceName;
|
||||
inputSheet.Cell(inputRow, 2).Value = amount;
|
||||
inputSheet.Cell(inputRow, 3).Value = amount * 60;
|
||||
inputRow++;
|
||||
}
|
||||
|
||||
inputSheet.Columns().AdjustToContents();
|
||||
|
||||
workbook.SaveAs(_outputPath);
|
||||
Console.WriteLine($" [Excel] Report saved to: {_outputPath}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FactorioCalc.Domain\FactorioCalc.Domain.csproj" />
|
||||
<ProjectReference Include="..\FactorioCalc.Solver\FactorioCalc.Solver.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ClosedXML" Version="0.102.3" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,58 @@
|
||||
using FactorioCalc.Domain;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FactorioCalc.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// Exports production results to a JSON file.
|
||||
/// </summary>
|
||||
public sealed class JsonReporter : IReporter
|
||||
{
|
||||
private readonly string _outputPath;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public JsonReporter(string outputPath)
|
||||
{
|
||||
_outputPath = outputPath ?? throw new ArgumentNullException(nameof(outputPath));
|
||||
}
|
||||
|
||||
public void Report(ProductionResult result, IReadOnlyDictionary<int, Resource> resources,
|
||||
IReadOnlyDictionary<int, Recipe> recipes, IReadOnlyDictionary<int, Machine> machines)
|
||||
{
|
||||
var output = new
|
||||
{
|
||||
timestamp = DateTime.UtcNow.ToString("o"),
|
||||
executions = result.Executions.Select(e => new
|
||||
{
|
||||
recipe = recipes.TryGetValue(e.RecipeId, out var r) ? r.Name : $"Recipe#{e.RecipeId}",
|
||||
recipeRate = Math.Round(e.RecipeRate, 4),
|
||||
machine = machines.TryGetValue(e.MachineId, out var m) ? m.Name : $"Machine#{e.MachineId}",
|
||||
machineCount = e.MachineCount,
|
||||
effectiveSpeed = Math.Round(e.EffectiveSpeed, 4),
|
||||
effectiveProductivity = Math.Round(e.EffectiveProductivity, 4),
|
||||
modules = e.Modules.Select(m => m.Name).ToList()
|
||||
}),
|
||||
resourceFlows = result.ResourceFlows.Select(f => new
|
||||
{
|
||||
resource = resources.TryGetValue(f.Key, out var res) ? res.Name : $"Resource#{f.Key}",
|
||||
itemsPerSecond = Math.Round(f.Value, 2),
|
||||
itemsPerMinute = Math.Round(f.Value * 60, 2)
|
||||
}),
|
||||
requiredInputs = result.RequiredInputs.Select(i => new
|
||||
{
|
||||
resource = resources.TryGetValue(i.Key, out var res) ? res.Name : $"Resource#{i.Key}",
|
||||
itemsPerSecond = Math.Round(i.Value, 2),
|
||||
itemsPerMinute = Math.Round(i.Value * 60, 2)
|
||||
})
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(output, JsonOptions);
|
||||
File.WriteAllText(_outputPath, json);
|
||||
Console.WriteLine($" [JSON] Report saved to: {_outputPath}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user