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:
2026-07-02 13:08:25 +03:00
commit eae7c066d1
30 changed files with 1954 additions and 0 deletions
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\FactorioCalc.Data\FactorioCalc.Data.csproj" />
<ProjectReference Include="..\FactorioCalc.Reporting\FactorioCalc.Reporting.csproj" />
<ProjectReference Include="..\FactorioCalc.Solver\FactorioCalc.Solver.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.48.0" />
</ItemGroup>
</Project>
+331
View File
@@ -0,0 +1,331 @@
using FactorioCalc.Data;
using FactorioCalc.Domain;
using FactorioCalc.Reporting;
using FactorioCalc.Solver;
using Spectre.Console;
namespace FactorioCalc.CLI;
/// <summary>
/// Factorio Space Age Production Calculator
/// Model: GPT-4.1 (OpenAI) — executed by pi coding agent
/// </summary>
public static class Program
{
public static int Main(string[] args)
{
if (args.Length == 0)
{
ShowHelp();
return 0;
}
var command = args[0].ToLowerInvariant();
if (command == "solve")
return RunSolve(args[1..]);
if (command == "list")
return RunList(args[1..]);
if (command == "help" || command == "--help" || command == "-h")
{
ShowHelp();
return 0;
}
AnsiConsole.MarkupLine($"[red]Unknown command: {args[0]}[/]");
ShowHelp();
return 1;
}
private static void ShowHelp()
{
Console.WriteLine();
AnsiConsole.MarkupLine(" [bold]Factorio Space Age Production Calculator[/]");
AnsiConsole.MarkupLine(" [dim]=========================================[/]");
Console.WriteLine();
AnsiConsole.MarkupLine(" [bold]Usage:[/]");
Console.WriteLine(" factorio-calc solve \"ResourceName:amount\" [targets...] [options]");
Console.WriteLine(" factorio-calc list [--resources|--recipes|--machines|--modules]");
Console.WriteLine();
AnsiConsole.MarkupLine(" [bold]Options:[/]");
Console.WriteLine(" -r, --recipe-file <path> Recipe database JSON file");
Console.WriteLine(" -m, --modules <name> Module to apply (repeatable)");
Console.WriteLine(" -j, --json <path> Export to JSON file");
Console.WriteLine(" -e, --excel <path> Export to Excel file");
Console.WriteLine();
AnsiConsole.MarkupLine(" [bold]Examples:[/]");
Console.WriteLine(" factorio-calc solve \"Science Pack 1:10\"");
Console.WriteLine(" factorio-calc solve \"Blue Science Pack:60\" -m \"Productivity Module 3\"");
Console.WriteLine(" factorio-calc solve \"Yellow Science Pack:15\" -j result.json -e result.xlsx");
Console.WriteLine(" factorio-calc list --resources");
Console.WriteLine();
}
private static int RunSolve(string[] args)
{
try
{
var options = ParseOptions(args);
if (options.Targets.Count == 0)
{
AnsiConsole.MarkupLine("[red]Error: No targets specified.[/]\n");
AnsiConsole.MarkupLine("[dim]Usage: solve \"ResourceName:amount\" [targets...][/]\n");
return 1;
}
var repoPath = options.RecipeFile ?? FindRecipeFile();
AnsiConsole.MarkupLine($"[dim]Loading recipes from: {repoPath}[/]");
var repository = new JsonRecipeRepository(repoPath);
var solver = new ProductionSolver(repository);
// Parse targets
var targets = new List<ProductionTarget>();
foreach (var targetStr in options.Targets)
{
var parts = targetStr.Split(':');
if (parts.Length != 2)
{
AnsiConsole.MarkupLine($"[yellow]Warning: Invalid target format: {targetStr}[/]");
continue;
}
var name = parts[0].Trim();
var amountStr = parts[1].Trim().Replace("/sec", "").Trim();
if (!double.TryParse(amountStr, out var amount))
{
AnsiConsole.MarkupLine($"[yellow]Warning: Cannot parse amount: {amountStr}[/]");
continue;
}
var resource = repository.Resources.Values.FirstOrDefault(r =>
r.Name.Equals(name, StringComparison.OrdinalIgnoreCase) ||
r.Name.Contains(name, StringComparison.OrdinalIgnoreCase));
if (resource == null)
{
AnsiConsole.MarkupLine($"[yellow]Warning: Resource not found: {name}[/]");
continue;
}
targets.Add(new ProductionTarget(resource.Id, amount));
}
if (targets.Count == 0)
{
AnsiConsole.MarkupLine("[red]Error: No valid targets.[/]");
return 1;
}
AnsiConsole.MarkupLine("\n[green]Targets:[/]");
foreach (var t in targets)
{
var res = repository.Resources[t.ResourceId];
AnsiConsole.MarkupLine($" [cyan]{res.Name}[/] @ [yellow]{t.AmountPerSecond:F1}[/] items/sec");
}
// Parse modules
var modules = new List<Module>();
foreach (var moduleName in options.Modules)
{
var module = repository.Modules.Values.FirstOrDefault(m =>
m.Name.Equals(moduleName, StringComparison.OrdinalIgnoreCase) ||
m.Name.Contains(moduleName, StringComparison.OrdinalIgnoreCase));
if (module == null)
{
AnsiConsole.MarkupLine($"[yellow]Warning: Module not found: {moduleName}[/]");
continue;
}
modules.Add(module);
AnsiConsole.MarkupLine($" [blue]Module: {module.Name}[/]");
}
// Solve
AnsiConsole.MarkupLine("\n[dim]Solving...[/]");
var result = modules.Count > 0
? solver.SolveWithModules(targets, modules)
: solver.Solve(targets);
// Report
var consoleReporter = new ConsoleReporter();
consoleReporter.Report(result, repository.Resources, repository.Recipes, repository.Machines);
// Export
if (!string.IsNullOrWhiteSpace(options.JsonOutput))
{
var jsonReporter = new JsonReporter(options.JsonOutput);
jsonReporter.Report(result, repository.Resources, repository.Recipes, repository.Machines);
}
if (!string.IsNullOrWhiteSpace(options.ExcelOutput))
{
var excelReporter = new ExcelReporter(options.ExcelOutput);
excelReporter.Report(result, repository.Resources, repository.Recipes, repository.Machines);
}
return 0;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]");
return 1;
}
}
private static int RunList(string[] args)
{
try
{
var options = ParseOptions(args);
var repoPath = options.RecipeFile ?? FindRecipeFile();
var repository = new JsonRecipeRepository(repoPath);
var showAll = !options.ShowResources && !options.ShowRecipes && !options.ShowMachines && !options.ShowModules;
if (options.ShowResources || showAll)
{
AnsiConsole.MarkupLine("\n[bold cyan]RESOURCES:[/]");
var table = new Table();
table.AddColumn("ID");
table.AddColumn("Name");
foreach (var kv in repository.Resources.OrderBy(r => r.Key))
table.AddRow(kv.Key.ToString(), kv.Value.Name);
AnsiConsole.Write(table);
}
if (options.ShowRecipes || showAll)
{
AnsiConsole.MarkupLine("\n[bold cyan]RECIPES:[/]");
var table = new Table();
table.AddColumn("ID");
table.AddColumn("Name");
table.AddColumn("Category");
table.AddColumn("Time");
foreach (var kv in repository.Recipes.OrderBy(r => r.Key))
table.AddRow(kv.Key.ToString(), kv.Value.Name, kv.Value.Category, kv.Value.CraftTime.ToString("F1"));
AnsiConsole.Write(table);
}
if (options.ShowMachines || showAll)
{
AnsiConsole.MarkupLine("\n[bold cyan]MACHINES:[/]");
var table = new Table();
table.AddColumn("ID");
table.AddColumn("Name");
table.AddColumn("Speed");
table.AddColumn("Slots");
foreach (var kv in repository.Machines.OrderBy(r => r.Key))
table.AddRow(kv.Key.ToString(), kv.Value.Name, kv.Value.CraftingSpeed.ToString("F1"), kv.Value.ModuleSlots.ToString());
AnsiConsole.Write(table);
}
if (options.ShowModules || showAll)
{
AnsiConsole.MarkupLine("\n[bold cyan]MODULES:[/]");
var table = new Table();
table.AddColumn("ID");
table.AddColumn("Name");
table.AddColumn("Type");
table.AddColumn("Speed");
table.AddColumn("Productivity");
foreach (var kv in repository.Modules.OrderBy(r => r.Key))
table.AddRow(kv.Key.ToString(), kv.Value.Name, kv.Value.Type.ToString(),
(kv.Value.SpeedBonus * 100).ToString("F0") + "%",
(kv.Value.ProductivityBonus * 100).ToString("F0") + "%");
AnsiConsole.Write(table);
}
return 0;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]");
return 1;
}
}
private static SolveOptions ParseOptions(string[] args)
{
var options = new SolveOptions();
var i = 0;
while (i < args.Length)
{
var arg = args[i];
if (arg == "-r" || arg == "--recipe-file")
{
if (i + 1 < args.Length) { options.RecipeFile = args[++i]; }
}
else if (arg == "-m" || arg == "--modules")
{
if (i + 1 < args.Length) { options.Modules.Add(args[++i]); }
}
else if (arg == "-j" || arg == "--json")
{
if (i + 1 < args.Length) { options.JsonOutput = args[++i]; }
}
else if (arg == "-e" || arg == "--excel")
{
if (i + 1 < args.Length) { options.ExcelOutput = args[++i]; }
}
else if (arg == "--resources")
{
options.ShowResources = true;
}
else if (arg == "--recipes")
{
options.ShowRecipes = true;
}
else if (arg == "--machines")
{
options.ShowMachines = true;
}
else if (arg == "--modules")
{
options.ShowModules = true;
}
else if (!arg.StartsWith("-"))
{
options.Targets.Add(arg);
}
i++;
}
return options;
}
private static string FindRecipeFile()
{
var searchDirs = new[]
{
"./data/recipes.json",
"../data/recipes.json",
"../../data/recipes.json",
"../../../data/recipes.json"
};
foreach (var dir in searchDirs)
if (File.Exists(dir))
return dir;
throw new FileNotFoundException("recipes.json not found. Place it in data/ or specify --recipe-file.");
}
private sealed class SolveOptions
{
public List<string> Targets { get; } = new();
public string? RecipeFile { get; set; }
public List<string> Modules { get; } = new();
public string? JsonOutput { get; set; }
public string? ExcelOutput { get; set; }
public bool ShowResources { get; set; }
public bool ShowRecipes { get; set; }
public bool ShowMachines { get; set; }
public bool ShowModules { get; set; }
}
}
@@ -0,0 +1,18 @@
<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" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="..\..\data\recipes.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+111
View File
@@ -0,0 +1,111 @@
using System.Text.Json.Serialization;
namespace FactorioCalc.Data;
internal sealed class RecipeDatabase
{
[JsonPropertyName("resources")]
public List<ResourceJson> Resources { get; set; } = new();
[JsonPropertyName("machines")]
public List<MachineJson> Machines { get; set; } = new();
[JsonPropertyName("modules")]
public List<ModuleJson> Modules { get; set; } = new();
[JsonPropertyName("recipes")]
public List<RecipeJson> Recipes { get; set; } = new();
}
internal sealed class ResourceJson
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
}
internal sealed class MachineJson
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("craftingSpeed")]
public double CraftingSpeed { get; set; }
[JsonPropertyName("energy")]
public double Energy { get; set; }
[JsonPropertyName("moduleSlots")]
public int ModuleSlots { get; set; }
[JsonPropertyName("allowedCategories")]
public List<string> AllowedCategories { get; set; } = new();
}
internal sealed class ModuleJson
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("speedBonus")]
public double SpeedBonus { get; set; }
[JsonPropertyName("productivityBonus")]
public double ProductivityBonus { get; set; }
[JsonPropertyName("efficiencyBonus")]
public double EfficiencyBonus { get; set; }
}
internal sealed class RecipeJson
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("category")]
public string Category { get; set; } = string.Empty;
[JsonPropertyName("craftTime")]
public double CraftTime { get; set; }
[JsonPropertyName("machineCategory")]
public string MachineCategory { get; set; } = string.Empty;
[JsonPropertyName("ingredients")]
public List<IngredientJson> Ingredients { get; set; } = new();
[JsonPropertyName("products")]
public List<ProductJson> Products { get; set; } = new();
}
internal sealed class IngredientJson
{
[JsonPropertyName("resourceId")]
public int ResourceId { get; set; }
[JsonPropertyName("amount")]
public double Amount { get; set; }
}
internal sealed class ProductJson
{
[JsonPropertyName("resourceId")]
public int ResourceId { get; set; }
[JsonPropertyName("amount")]
public double Amount { get; set; }
}
@@ -0,0 +1,48 @@
using FactorioCalc.Domain;
using System.Text.Json;
namespace FactorioCalc.Data;
/// <summary>
/// Loads recipes, resources, machines, and modules from a JSON file.
/// </summary>
public sealed class JsonRecipeRepository : IRecipeRepository
{
public IReadOnlyDictionary<int, Recipe> Recipes { get; }
public IReadOnlyDictionary<int, Resource> Resources { get; }
public IReadOnlyDictionary<int, Machine> Machines { get; }
public IReadOnlyDictionary<int, Module> Modules { get; }
public JsonRecipeRepository(string jsonFilePath)
{
if (!File.Exists(jsonFilePath))
throw new FileNotFoundException($"Recipe file not found: {jsonFilePath}", jsonFilePath);
var json = File.ReadAllText(jsonFilePath);
var db = JsonSerializer.Deserialize<RecipeDatabase>(json)
?? throw new InvalidOperationException("Failed to deserialize recipe database.");
Resources = db.Resources.ToDictionary(r => r.Id, r => new Resource(r.Id, r.Name));
Machines = db.Machines.ToDictionary(m => m.Id, m => new Machine(
m.Id, m.Name, m.CraftingSpeed, m.Energy, m.ModuleSlots,
(IReadOnlyCollection<string>)m.AllowedCategories.AsReadOnly()));
Modules = db.Modules.ToDictionary(m => m.Id, m => new Module(
m.Id, m.Name, ParseModuleType(m.Type),
m.SpeedBonus, m.ProductivityBonus, m.EfficiencyBonus));
Recipes = db.Recipes.ToDictionary(r => r.Id, r => new Recipe(
r.Id, r.Name, r.Category, r.CraftTime, r.MachineCategory,
r.Ingredients.Select(i => new Ingredient(i.ResourceId, i.Amount)).ToList(),
r.Products.Select(p => new Product(p.ResourceId, p.Amount)).ToList()));
}
private static ModuleType ParseModuleType(string type) => type switch
{
"Speed" => ModuleType.Speed,
"Productivity" => ModuleType.Productivity,
"Efficiency" => ModuleType.Efficiency,
_ => ModuleType.None
};
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,12 @@
namespace FactorioCalc.Domain;
/// <summary>
/// Repository for accessing recipes and resources.
/// </summary>
public interface IRecipeRepository
{
IReadOnlyDictionary<int, Recipe> Recipes { get; }
IReadOnlyDictionary<int, Resource> Resources { get; }
IReadOnlyDictionary<int, Machine> Machines { get; }
IReadOnlyDictionary<int, Module> Modules { get; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace FactorioCalc.Domain;
/// <summary>
/// Reporter — formats and outputs production results.
/// </summary>
public interface IReporter
{
/// <summary>
/// Print the production result to the configured output.
/// </summary>
void Report(ProductionResult result, IReadOnlyDictionary<int, Resource> resources, IReadOnlyDictionary<int, Recipe> recipes, IReadOnlyDictionary<int, Machine> machines);
}
+17
View File
@@ -0,0 +1,17 @@
namespace FactorioCalc.Domain;
/// <summary>
/// Production solver — calculates machine counts and resource flows from targets.
/// </summary>
public interface ISolver
{
/// <summary>
/// Solve production plan for the given targets.
/// </summary>
ProductionResult Solve(IReadOnlyCollection<ProductionTarget> targets);
/// <summary>
/// Solve with module configuration (applied to all matching recipes).
/// </summary>
ProductionResult SolveWithModules(IReadOnlyCollection<ProductionTarget> targets, IReadOnlyCollection<Module> modules);
}
+16
View File
@@ -0,0 +1,16 @@
namespace FactorioCalc.Domain;
/// <summary>
/// Immutable ingredient — what goes INTO a recipe.
/// </summary>
public sealed class Ingredient
{
public int ResourceId { get; }
public double Amount { get; }
public Ingredient(int resourceId, double amount)
{
ResourceId = resourceId;
Amount = amount < 0 ? throw new ArgumentException("Amount cannot be negative.", nameof(amount)) : amount;
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace FactorioCalc.Domain;
/// <summary>
/// Immutable machine entity (e.g. Assembler 1, Smelter, Chemical Plant).
/// </summary>
public sealed class Machine
{
public int Id { get; }
public string Name { get; }
public double CraftingSpeed { get; }
public double Energy { get; }
public int ModuleSlots { get; }
public IReadOnlyCollection<string> AllowedCategories { get; }
public Machine(int id, string name, double craftingSpeed, double energy, int moduleSlots, IReadOnlyCollection<string> allowedCategories)
{
Id = id;
Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Name cannot be empty.", nameof(name)) : name;
CraftingSpeed = craftingSpeed > 0 ? craftingSpeed : throw new ArgumentException("CraftingSpeed must be positive.", nameof(craftingSpeed));
Energy = energy;
ModuleSlots = moduleSlots >= 0 ? moduleSlots : throw new ArgumentException("ModuleSlots cannot be negative.", nameof(moduleSlots));
AllowedCategories = allowedCategories ?? throw new ArgumentNullException(nameof(allowedCategories));
}
}
+35
View File
@@ -0,0 +1,35 @@
namespace FactorioCalc.Domain;
/// <summary>
/// Module types that can be inserted into machines.
/// </summary>
public enum ModuleType
{
None = 0,
Speed,
Productivity,
Efficiency
}
/// <summary>
/// Immutable module entity (e.g. Speed Module 3, Productivity Module 2).
/// </summary>
public sealed class Module
{
public int Id { get; }
public string Name { get; }
public ModuleType Type { get; }
public double SpeedBonus { get; } // e.g. +0.10 = +10% speed
public double ProductivityBonus { get; } // e.g. +0.25 = +25% productivity
public double EfficiencyBonus { get; } // e.g. -0.05 = -5% pollution efficiency
public Module(int id, string name, ModuleType type, double speedBonus, double productivityBonus, double efficiencyBonus)
{
Id = id;
Name = name ?? throw new ArgumentNullException(nameof(name));
Type = type;
SpeedBonus = speedBonus;
ProductivityBonus = productivityBonus;
EfficiencyBonus = efficiencyBonus;
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace FactorioCalc.Domain;
/// <summary>
/// Immutable product — what comes OUT of a recipe.
/// </summary>
public sealed class Product
{
public int ResourceId { get; }
public double Amount { get; }
public Product(int resourceId, double amount)
{
ResourceId = resourceId;
Amount = amount > 0 ? amount : throw new ArgumentException("Amount must be positive.", nameof(amount));
}
}
@@ -0,0 +1,21 @@
namespace FactorioCalc.Domain;
/// <summary>
/// Final production plan — the result of the solver.
/// </summary>
public sealed class ProductionResult
{
public IReadOnlyCollection<RecipeExecution> Executions { get; }
public IReadOnlyDictionary<int, double> ResourceFlows { get; } // ResourceId -> net flow (positive=produced, negative=consumed)
public IReadOnlyDictionary<int, double> RequiredInputs { get; } // ResourceId -> raw input needed
public ProductionResult(
IReadOnlyCollection<RecipeExecution> executions,
IReadOnlyDictionary<int, double> resourceFlows,
IReadOnlyDictionary<int, double> requiredInputs)
{
Executions = executions ?? throw new ArgumentNullException(nameof(executions));
ResourceFlows = resourceFlows ?? throw new ArgumentNullException(nameof(resourceFlows));
RequiredInputs = requiredInputs ?? throw new ArgumentNullException(nameof(requiredInputs));
}
}
@@ -0,0 +1,16 @@
namespace FactorioCalc.Domain;
/// <summary>
/// User-defined production target (e.g. "Science Pack 1 at 10/sec").
/// </summary>
public sealed class ProductionTarget
{
public int ResourceId { get; }
public double AmountPerSecond { get; }
public ProductionTarget(int resourceId, double amountPerSecond)
{
ResourceId = resourceId;
AmountPerSecond = amountPerSecond >= 0 ? amountPerSecond : throw new ArgumentException("AmountPerSecond cannot be negative.", nameof(amountPerSecond));
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace FactorioCalc.Domain;
/// <summary>
/// Immutable recipe entity with ingredients and products.
/// </summary>
public sealed class Recipe
{
public int Id { get; }
public string Name { get; }
public string Category { get; }
public double CraftTime { get; }
public string MachineCategory { get; }
public IReadOnlyCollection<Ingredient> Ingredients { get; }
public IReadOnlyCollection<Product> Products { get; }
public Recipe(int id, string name, string category, double craftTime, string machineCategory,
IReadOnlyCollection<Ingredient> ingredients, IReadOnlyCollection<Product> products)
{
Id = id;
Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Name cannot be empty.", nameof(name)) : name;
Category = category ?? throw new ArgumentNullException(nameof(category));
CraftTime = craftTime > 0 ? craftTime : throw new ArgumentException("CraftTime must be positive.", nameof(craftTime));
MachineCategory = machineCategory ?? throw new ArgumentNullException(nameof(machineCategory));
Ingredients = ingredients ?? throw new ArgumentNullException(nameof(ingredients));
Products = products ?? throw new ArgumentNullException(nameof(products));
}
}
@@ -0,0 +1,27 @@
namespace FactorioCalc.Domain;
/// <summary>
/// Represents a single machine executing a recipe (with optional modules).
/// </summary>
public sealed class RecipeExecution
{
public int RecipeId { get; }
public int MachineId { get; }
public int MachineCount { get; }
public double RecipeRate { get; } // cycles per second
public IReadOnlyCollection<Module> Modules { get; }
public double EffectiveSpeed { get; } // base speed + module bonuses
public double EffectiveProductivity { get; }
public RecipeExecution(int recipeId, int machineId, int machineCount, double recipeRate,
IReadOnlyCollection<Module> modules, double effectiveSpeed, double effectiveProductivity)
{
RecipeId = recipeId;
MachineId = machineId;
MachineCount = machineCount > 0 ? machineCount : throw new ArgumentException("MachineCount must be positive.", nameof(machineCount));
RecipeRate = recipeRate;
Modules = modules ?? Array.Empty<Module>();
EffectiveSpeed = effectiveSpeed;
EffectiveProductivity = effectiveProductivity;
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace FactorioCalc.Domain;
/// <summary>
/// Immutable resource entity (e.g. Iron Ore, Steel Plate, Science Pack 1).
/// </summary>
public sealed class Resource
{
public int Id { get; }
public string Name { get; }
public Resource(int id, string name)
{
Id = id;
Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Name cannot be empty.", nameof(name)) : name;
}
public override string ToString() => Name;
}
@@ -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);
}
}
+103
View File
@@ -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}");
}
}
@@ -0,0 +1,13 @@
<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" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MathNet.Numerics" Version="5.0.0" />
</ItemGroup>
</Project>
+221
View File
@@ -0,0 +1,221 @@
using FactorioCalc.Domain;
namespace FactorioCalc.Solver;
/// <summary>
/// DFS-based production solver.
/// Traverses the recipe dependency tree from targets to raw resources,
/// calculating machine counts and resource flows.
/// </summary>
public sealed class ProductionSolver : ISolver
{
private readonly IRecipeRepository _repository;
private const double DefaultMachineSpeed = 0.5; // base crafting speed for all machines
public ProductionSolver(IRecipeRepository repository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
public ProductionResult Solve(IReadOnlyCollection<ProductionTarget> targets)
{
return SolveWithModules(targets, Array.Empty<Module>());
}
public ProductionResult SolveWithModules(IReadOnlyCollection<ProductionTarget> targets, IReadOnlyCollection<Module> modules)
{
if (targets == null) throw new ArgumentNullException(nameof(targets));
var demand = new Dictionary<int, double>(); // resourceId -> needed amount/sec
var executions = new List<RecipeExecution>();
var visited = new HashSet<int>(); // prevent cycles
// Seed with user targets
foreach (var target in targets)
{
demand[target.ResourceId] = target.AmountPerSecond;
}
// DFS: resolve each demand
var stack = new Stack<int>(targets.Select(t => t.ResourceId));
while (stack.Count > 0)
{
var resourceId = stack.Pop();
if (!demand.TryGetValue(resourceId, out var needed) || needed <= 0)
continue;
// Find the best recipe that produces this resource
var recipe = FindBestRecipe(resourceId, modules);
if (recipe == null)
{
// This is a raw resource — nothing to craft
continue;
}
// Avoid re-processing the same recipe for the same resource
var recipeKey = recipe.Id;
if (visited.Contains(recipeKey))
continue;
visited.Add(recipeKey);
// Calculate recipe rate needed
var (recipeRate, effectiveSpeed, effectiveProductivity, machineCount, machineId) =
CalculateExecution(recipe, needed, modules);
// Record execution
executions.Add(new RecipeExecution(
recipe.Id, machineId, (int)Math.Ceiling(machineCount),
recipeRate, modules, effectiveSpeed, effectiveProductivity));
// Add ingredient demands (DFS)
foreach (var ingredient in recipe.Ingredients)
{
// How much of this ingredient does one cycle consume?
// With productivity, input is NOT reduced — only output is increased
var ingredientNeeded = ingredient.Amount * recipeRate;
demand[ingredient.ResourceId] = demand.GetValueOrDefault(ingredient.ResourceId) + ingredientNeeded;
stack.Push(ingredient.ResourceId);
}
}
// Build resource flows
var resourceFlows = BuildResourceFlows(executions, demand);
var requiredInputs = BuildRequiredInputs(executions, demand, _repository.Recipes);
return new ProductionResult(
executions.AsReadOnly(),
resourceFlows,
requiredInputs);
}
private Recipe? FindBestRecipe(int resourceId, IReadOnlyCollection<Module> modules)
{
// Prefer productivity recipes when productivity modules are present
var hasProductivityModules = modules.Any(m => m.Type == ModuleType.Productivity);
var candidates = _repository.Recipes
.Where(r => r.Value.Products.Any(p => p.ResourceId == resourceId))
.Select(r => r.Value)
.ToList();
if (candidates.Count == 0)
return null;
if (hasProductivityModules)
{
// Prefer the productivity variant
var productivityRecipe = candidates.FirstOrDefault(r => r.Name.Contains("Productivity"));
if (productivityRecipe != null)
return productivityRecipe;
}
// Return the recipe with the best efficiency (highest output per cycle)
return candidates.OrderByDescending(r =>
{
var product = r.Products.First(p => p.ResourceId == resourceId);
return product.Amount / r.CraftTime;
}).First();
}
private (double recipeRate, double effectiveSpeed, double effectiveProductivity, double machineCount, int machineId)
CalculateExecution(Recipe recipe, double neededPerSec, IReadOnlyCollection<Module> modules)
{
// Find the main product for our target resource
var mainProduct = recipe.Products.First();
// Calculate module bonuses
var totalSpeedBonus = modules.Sum(m => m.SpeedBonus);
var totalProductivityBonus = modules.Sum(m => m.ProductivityBonus);
// Effective speed = base speed * (1 + speed bonus)
var effectiveSpeed = DefaultMachineSpeed * (1 + totalSpeedBonus);
// Effective productivity
var effectiveProductivity = totalProductivityBonus;
// Output per cycle with productivity
var outputPerCycle = mainProduct.Amount * (1 + effectiveProductivity);
// Recipe rate (cycles/sec) needed
var recipeRate = neededPerSec / outputPerCycle;
// Machines needed = recipe_rate / (effective_speed / craft_time)
var cyclesPerMachinePerSec = effectiveSpeed / recipe.CraftTime;
var machineCount = cyclesPerMachinePerSec > 0 ? recipeRate / cyclesPerMachinePerSec : double.PositiveInfinity;
// Find best machine for this recipe category
var machineId = FindBestMachine(recipe.MachineCategory, modules);
return (recipeRate, effectiveSpeed, effectiveProductivity, machineCount, machineId);
}
private int FindBestMachine(string category, IReadOnlyCollection<Module> modules)
{
// Prefer machines with more module slots when modules are used
var hasModules = modules.Count > 0;
var candidates = _repository.Machines
.Where(m => m.Value.AllowedCategories.Contains(category))
.ToList();
if (candidates.Count == 0)
return 1; // fallback
if (hasModules)
{
// Prefer machines with more module slots
return candidates.OrderByDescending(m => m.Value.ModuleSlots)
.ThenByDescending(m => m.Value.CraftingSpeed)
.First().Value.Id;
}
// Prefer faster machines
return candidates.OrderByDescending(m => m.Value.CraftingSpeed).First().Value.Id;
}
private Dictionary<int, double> BuildResourceFlows(List<RecipeExecution> executions, Dictionary<int, double> demand)
{
var flows = new Dictionary<int, double>();
foreach (var exec in executions)
{
var recipe = _repository.Recipes[exec.RecipeId];
// Products
foreach (var product in recipe.Products)
{
var outputPerCycle = product.Amount * (1 + exec.EffectiveProductivity);
var totalOutput = outputPerCycle * exec.RecipeRate * exec.MachineCount;
flows[product.ResourceId] = flows.GetValueOrDefault(product.ResourceId) + totalOutput;
}
// Ingredients
foreach (var ingredient in recipe.Ingredients)
{
var totalInput = ingredient.Amount * exec.RecipeRate * exec.MachineCount;
flows[ingredient.ResourceId] = flows.GetValueOrDefault(ingredient.ResourceId) - totalInput;
}
}
return flows;
}
private Dictionary<int, double> BuildRequiredInputs(List<RecipeExecution> executions, Dictionary<int, double> demand, IReadOnlyDictionary<int, Recipe> recipes)
{
var inputs = new Dictionary<int, double>();
foreach (var (resourceId, amount) in demand)
{
// If no recipe produces this, it's a raw input
var hasProducer = recipes.Any(r => r.Value.Products.Any(p => p.ResourceId == resourceId));
if (!hasProducer && amount > 0)
{
inputs[resourceId] = amount;
}
}
return inputs;
}
}