commit eae7c066d19641a4dd6d08769279b82c811f869f Author: Shahovalov MIkhail Date: Thu Jul 2 13:08:25 2026 +0300 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c3b12c --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +## Build outputs +bin/ +obj/ + +## IDE +.vs/ +.vscode/ +*.suo +*.user + +## OS +.DS_Store +Thumbs.db +desktop.ini + +## Run outputs +output.json +output.xlsx + +## Old solution format +*.slnx + +## Prompt folder (source docs) +Promt/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b35b99 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# Factorio Space Age Production Calculator + +> **Model:** GPT-4.1 (OpenAI) — executed by pi coding agent +> **Stack:** C# .NET 10, MathNet.Numerics, Spectre.Console, ClosedXML, xUnit + +Калькулятор производственных цепочек для **Factorio: Space Age**. Рассчитывает количество машин, потоки ресурсов и сырьевые входы для заданных целей производства. + +## Архитектура + +``` +FactorioCalc/ +├── src/ +│ ├── FactorioCalc.Domain/ # Сущности, интерфейсы (DDD) +│ ├── FactorioCalc.Data/ # JSON-хранилище рецептов +│ ├── FactorioCalc.Solver/ # DFS-решатель (MathNet.Numerics) +│ ├── FactorioCalc.Reporting/ # Console/JSON/Excel экспорт +│ └── FactorioCalc.CLI/ # Точка входа, парсинг аргументов +├── tests/ +│ └── FactorioCalc.Tests/ # xUnit unit-тесты (20 тестов) +├── data/ +│ └── recipes.json # База рецептов Space Age +└── README.md +``` + +### Слои + +| Слой | Описание | +|------|----------| +| **Domain** | Immutable-сущности: `Resource`, `Recipe`, `Machine`, `Module`, `RecipeExecution`, `ProductionTarget`, `ProductionResult` | +| **Data** | `JsonRecipeRepository` — загрузка рецептов из JSON | +| **Solver** | `ProductionSolver` — DFS-обход дерева зависимостей, расчёт модулей (speed/productivity) | +| **Reporting** | `ConsoleReporter`, `JsonReporter`, `ExcelReporter` | +| **CLI** | Парсинг аргументов, DI, Spectre.Console | + +## Быстрый старт + +```bash +# Сборка +dotnet build src/FactorioCalc.CLI/FactorioCalc.CLI.csproj + +# Тесты +dotnet test tests/FactorioCalc.Tests/FactorioCalc.Tests.csproj + +# Запуск +dotnet run --project src/FactorioCalc.CLI/FactorioCalc.CLI.csproj +``` + +## Использование + +### Расчёт производства + +```bash +# Простой расчёт +dotnet run --project src/FactorioCalc.CLI/FactorioCalc.CLI.csproj -- solve "Steel Plate:5" + +# Множественные цели +dotnet run --project src/FactorioCalc.CLI/FactorioCalc.CLI.csproj -- solve "Red Science Pack:10" "Processing Unit:30" + +# С модулями +dotnet run --project src/FactorioCalc.CLI/FactorioCalc.CLI.csproj -- solve "Blue Science Pack:60" -m "Productivity Module 3" + +# С экспортом +dotnet run --project src/FactorioCalc.CLI/FactorioCalc.CLI.csproj -- solve "Yellow Science Pack:15" -j result.json -e result.xlsx +``` + +### Формат целей + +``` +"ResourceName:amount" +``` + +Например: `"Steel Plate:5"` → 5 стальных пластин в секунду. + +### Опции + +| Опция | Описание | +|-------|----------| +| `-r, --recipe-file ` | Путь к JSON с рецептами | +| `-m, --modules ` | Модуль для применения (можно повторять) | +| `-j, --json ` | Экспорт в JSON | +| `-e, --excel ` | Экспорт в Excel (.xlsx) | + +### Список ресурсов + +```bash +dotnet run --project src/FactorioCalc.CLI/FactorioCalc.CLI.csproj -- list --resources +dotnet run --project src/FactorioCalc.CLI/FactorioCalc.CLI.csproj -- list --recipes +dotnet run --project src/FactorioCalc.CLI/FactorioCalc.CLI.csproj -- list --machines +dotnet run --project src/FactorioCalc.CLI/FactorioCalc.CLI.csproj -- list --modules +``` + +## Формула расчёта + +``` +Recipe Rate (cycles/sec) = needed_per_sec / (product_amount × (1 + productivity_bonus)) +Machine Count = recipe_rate / (effective_speed / craft_time) +Effective Speed = base_speed × (1 + speed_bonus) +``` + +## Принципы + +- **SOLID** — разделение ответственности по слоям +- **Immutable entities** — все сущности Domain-слоя неизменяемы +- **IReadOnlyCollection / IReadOnlyDictionary** — публичные API +- **DFS** — обход дерева зависимостей рецептов +- **MathNet.Numerics** — для матричных вычислений (резерв) + +## Тесты + +```bash +dotnet test tests/FactorioCalc.Tests/FactorioCalc.Tests.csproj +``` + +20 тестов: валидация сущностей Domain-слоя + интеграционные тесты Solver-а. diff --git a/data/recipes.json b/data/recipes.json new file mode 100644 index 0000000..808e525 --- /dev/null +++ b/data/recipes.json @@ -0,0 +1,192 @@ +{ + "resources": [ + { "id": 1, "name": "Iron Ore" }, + { "id": 2, "name": "Copper Ore" }, + { "id": 3, "name": "Stone" }, + { "id": 4, "name": "Water" }, + { "id": 5, "name": "Coal" }, + { "id": 6, "name": "Wood" }, + { "id": 7, "name": "Iron Plate" }, + { "id": 8, "name": "Copper Plate" }, + { "id": 9, "name": "Steel Plate" }, + { "id": 10, "name": "Copper Cable" }, + { "id": 11, "name": "Electronic Circuit" }, + { "id": 12, "name": "Electronic Circuit (Productivity)" }, + { "id": 13, "name": "Advanced Circuit" }, + { "id": 14, "name": "Processing Unit" }, + { "id": 15, "name": "Low Grade Alloy" }, + { "id": 16, "name": "Gear Wheel" }, + { "id": 17, "name": "Belt" }, + { "id": 18, "name": "Express Belt" }, + { "id": 19, "name": "Turbo Belt" }, + { "id": 20, "name": "Flared Copper Pipe" }, + { "id": 21, "name": "Sulfuric Acid" }, + { "id": 22, "name": "Sulfur" }, + { "id": 23, "name": "Crude Oil" }, + { "id": 24, "name": "Heavy Oil Residue" }, + { "id": 25, "name": "Light Oil" }, + { "id": 26, "name": "Petroleum Gas" }, + { "id": 27, "name": "Solid Fuel" }, + { "id": 28, "name": "Lubricant" }, + { "id": 29, "name": "Plastic Bar" }, + { "id": 30, "name": "Red Science Pack" }, + { "id": 31, "name": "Green Science Pack" }, + { "id": 32, "name": "Blue Science Pack" }, + { "id": 33, "name": "Yellow Science Pack" }, + { "id": 34, "name": "Space Science Pack" } + ], + "machines": [ + { "id": 1, "name": "Mining Drill", "craftingSpeed": 0.5, "energy": 4.0, "moduleSlots": 0, "allowedCategories": ["extracting"] }, + { "id": 2, "name": "Pumpjack", "craftingSpeed": 0.5, "energy": 2.0, "moduleSlots": 0, "allowedCategories": ["extracting"] }, + { "id": 3, "name": "Smelter", "craftingSpeed": 0.5, "energy": 3.0, "moduleSlots": 2, "allowedCategories": ["smelting"] }, + { "id": 4, "name": "Steel Furnace", "craftingSpeed": 0.5, "energy": 6.0, "moduleSlots": 2, "allowedCategories": ["smelting"] }, + { "id": 5, "name": "Assembler 1", "craftingSpeed": 0.5, "energy": 3.0, "moduleSlots": 2, "allowedCategories": ["basic-crafting"] }, + { "id": 6, "name": "Assembler 2", "craftingSpeed": 0.5, "energy": 4.0, "moduleSlots": 2, "allowedCategories": ["basic-crafting"] }, + { "id": 7, "name": "Assembler 3", "craftingSpeed": 0.5, "energy": 6.0, "moduleSlots": 4, "allowedCategories": ["basic-crafting"] }, + { "id": 8, "name": "Chemical Plant", "craftingSpeed": 0.5, "energy": 6.0, "moduleSlots": 4, "allowedCategories": ["advanced-crafting", "basic-crafting"] }, + { "id": 9, "name": "Oil Extractor", "craftingSpeed": 0.5, "energy": 4.0, "moduleSlots": 0, "allowedCategories": ["extracting"] } + ], + "modules": [ + { "id": 1, "name": "Speed Module 1", "type": "Speed", "speedBonus": 0.10, "productivityBonus": 0, "efficiencyBonus": -0.05 }, + { "id": 2, "name": "Speed Module 2", "type": "Speed", "speedBonus": 0.20, "productivityBonus": 0, "efficiencyBonus": -0.10 }, + { "id": 3, "name": "Speed Module 3", "type": "Speed", "speedBonus": 0.30, "productivityBonus": 0, "efficiencyBonus": -0.15 }, + { "id": 4, "name": "Productivity Module 1", "type": "Productivity", "speedBonus": -0.10, "productivityBonus": 0.10, "efficiencyBonus": -0.05 }, + { "id": 5, "name": "Productivity Module 2", "type": "Productivity", "speedBonus": -0.15, "productivityBonus": 0.20, "efficiencyBonus": -0.10 }, + { "id": 6, "name": "Productivity Module 3", "type": "Productivity", "speedBonus": -0.20, "productivityBonus": 0.30, "efficiencyBonus": -0.15 }, + { "id": 7, "name": "Efficiency Module 1", "type": "Efficiency", "speedBonus": -0.05, "productivityBonus": 0, "efficiencyBonus": 0.10 }, + { "id": 8, "name": "Efficiency Module 2", "type": "Efficiency", "speedBonus": -0.10, "productivityBonus": 0, "efficiencyBonus": 0.20 }, + { "id": 9, "name": "Efficiency Module 3", "type": "Efficiency", "speedBonus": -0.15, "productivityBonus": 0, "efficiencyBonus": 0.30 } + ], + "recipes": [ + { + "id": 1, "name": "Iron Plate", "category": "smelting", "craftTime": 3.0, "machineCategory": "smelting", + "ingredients": [ { "resourceId": 1, "amount": 1 } ], + "products": [ { "resourceId": 7, "amount": 1 } ] + }, + { + "id": 2, "name": "Copper Plate", "category": "smelting", "craftTime": 3.0, "machineCategory": "smelting", + "ingredients": [ { "resourceId": 2, "amount": 1 } ], + "products": [ { "resourceId": 8, "amount": 1 } ] + }, + { + "id": 3, "name": "Steel Plate", "category": "smelting", "craftTime": 5.0, "machineCategory": "smelting", + "ingredients": [ { "resourceId": 7, "amount": 2 }, { "resourceId": 5, "amount": 1 } ], + "products": [ { "resourceId": 9, "amount": 1 } ] + }, + { + "id": 4, "name": "Copper Cable", "category": "basic-crafting", "craftTime": 0.5, "machineCategory": "basic-crafting", + "ingredients": [ { "resourceId": 8, "amount": 1 } ], + "products": [ { "resourceId": 10, "amount": 5 } ] + }, + { + "id": 5, "name": "Electronic Circuit", "category": "basic-crafting", "craftTime": 3.0, "machineCategory": "basic-crafting", + "ingredients": [ { "resourceId": 10, "amount": 2 }, { "resourceId": 7, "amount": 1 } ], + "products": [ { "resourceId": 11, "amount": 1 } ] + }, + { + "id": 6, "name": "Electronic Circuit (Productivity)", "category": "basic-crafting", "craftTime": 4.0, "machineCategory": "basic-crafting", + "ingredients": [ { "resourceId": 10, "amount": 5 }, { "resourceId": 7, "amount": 1 } ], + "products": [ { "resourceId": 11, "amount": 3 } ] + }, + { + "id": 7, "name": "Advanced Circuit", "category": "advanced-crafting", "craftTime": 5.0, "machineCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 11, "amount": 3 }, { "resourceId": 9, "amount": 1 } ], + "products": [ { "resourceId": 13, "amount": 1 } ] + }, + { + "id": 8, "name": "Processing Unit", "category": "advanced-crafting", "craftTime": 8.0, "machineCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 13, "amount": 1 }, { "resourceId": 21, "amount": 1 } ], + "products": [ { "resourceId": 14, "amount": 1 } ] + }, + { + "id": 9, "name": "Low Grade Alloy", "category": "smelting", "craftTime": 3.0, "machineCategory": "smelting", + "ingredients": [ { "resourceId": 7, "amount": 15 }, { "resourceId": 8, "amount": 5 } ], + "products": [ { "resourceId": 15, "amount": 1 } ] + }, + { + "id": 10, "name": "Gear Wheel", "category": "basic-crafting", "craftTime": 1.0, "machineCategory": "basic-crafting", + "ingredients": [ { "resourceId": 7, "amount": 2 } ], + "products": [ { "resourceId": 16, "amount": 1 } ] + }, + { + "id": 11, "name": "Gear Wheel (Productivity)", "category": "basic-crafting", "craftTime": 2.0, "machineCategory": "basic-crafting", + "ingredients": [ { "resourceId": 7, "amount": 5 } ], + "products": [ { "resourceId": 16, "amount": 3 } ] + }, + { + "id": 12, "name": "Belt", "category": "basic-crafting", "craftTime": 1.0, "machineCategory": "basic-crafting", + "ingredients": [ { "resourceId": 7, "amount": 2 }, { "resourceId": 10, "amount": 1 } ], + "products": [ { "resourceId": 17, "amount": 1 } ] + }, + { + "id": 13, "name": "Express Belt", "category": "basic-crafting", "craftTime": 4.0, "machineCategory": "basic-crafting", + "ingredients": [ { "resourceId": 9, "amount": 2 }, { "resourceId": 11, "amount": 1 }, { "resourceId": 16, "amount": 1 } ], + "products": [ { "resourceId": 18, "amount": 1 } ] + }, + { + "id": 14, "name": "Turbo Belt", "category": "basic-crafting", "craftTime": 10.0, "machineCategory": "basic-crafting", + "ingredients": [ { "resourceId": 18, "amount": 2 }, { "resourceId": 13, "amount": 1 }, { "resourceId": 29, "amount": 1 } ], + "products": [ { "resourceId": 19, "amount": 1 } ] + }, + { + "id": 15, "name": "Flared Copper Pipe", "category": "basic-crafting", "craftTime": 2.0, "machineCategory": "basic-crafting", + "ingredients": [ { "resourceId": 8, "amount": 5 }, { "resourceId": 9, "amount": 1 } ], + "products": [ { "resourceId": 20, "amount": 1 } ] + }, + { + "id": 16, "name": "Sulfuric Acid", "category": "advanced-crafting", "craftTime": 4.0, "machineCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 22, "amount": 1 }, { "resourceId": 4, "amount": 1 } ], + "products": [ { "resourceId": 21, "amount": 2 } ] + }, + { + "id": 17, "name": "Solid Fuel", "category": "advanced-crafting", "craftTime": 4.0, "machineCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 25, "amount": 1 } ], + "products": [ { "resourceId": 27, "amount": 1 } ] + }, + { + "id": 18, "name": "Lubricant", "category": "advanced-crafting", "craftTime": 2.0, "machineCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 25, "amount": 1 }, { "resourceId": 4, "amount": 1 } ], + "products": [ { "resourceId": 28, "amount": 1 } ] + }, + { + "id": 19, "name": "Lubricant (Productivity)", "category": "advanced-crafting", "craftTime": 4.0, "moduleCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 25, "amount": 2 }, { "resourceId": 4, "amount": 2 } ], + "products": [ { "resourceId": 28, "amount": 3 } ] + }, + { + "id": 20, "name": "Plastic Bar", "category": "advanced-crafting", "craftTime": 4.0, "machineCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 26, "amount": 1 }, { "resourceId": 21, "amount": 1 } ], + "products": [ { "resourceId": 29, "amount": 1 } ] + }, + { + "id": 21, "name": "Plastic Bar (Productivity)", "category": "advanced-crafting", "craftTime": 8.0, "machineCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 26, "amount": 3 }, { "resourceId": 21, "amount": 3 } ], + "products": [ { "resourceId": 29, "amount": 5 } ] + }, + { + "id": 22, "name": "Red Science Pack", "category": "advanced-crafting", "craftTime": 6.0, "machineCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 22, "amount": 1 }, { "resourceId": 4, "amount": 1 } ], + "products": [ { "resourceId": 30, "amount": 1 } ] + }, + { + "id": 23, "name": "Green Science Pack", "category": "advanced-crafting", "craftTime": 6.0, "machineCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 11, "amount": 1 }, { "resourceId": 10, "amount": 1 } ], + "products": [ { "resourceId": 31, "amount": 1 } ] + }, + { + "id": 24, "name": "Blue Science Pack", "category": "advanced-crafting", "craftTime": 6.0, "machineCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 11, "amount": 2 }, { "resourceId": 13, "amount": 1 } ], + "products": [ { "resourceId": 32, "amount": 1 } ] + }, + { + "id": 25, "name": "Yellow Science Pack", "category": "advanced-crafting", "craftTime": 6.0, "machineCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 14, "amount": 1 }, { "resourceId": 21, "amount": 1 } ], + "products": [ { "resourceId": 33, "amount": 1 } ] + }, + { + "id": 26, "name": "Space Science Pack", "category": "advanced-crafting", "craftTime": 10.0, "machineCategory": "advanced-crafting", + "ingredients": [ { "resourceId": 30, "amount": 1 }, { "resourceId": 31, "amount": 1 }, { "resourceId": 32, "amount": 1 }, { "resourceId": 33, "amount": 1 } ], + "products": [ { "resourceId": 34, "amount": 1 } ] + } + ] +} diff --git a/src/FactorioCalc.CLI/FactorioCalc.CLI.csproj b/src/FactorioCalc.CLI/FactorioCalc.CLI.csproj new file mode 100644 index 0000000..d66cc06 --- /dev/null +++ b/src/FactorioCalc.CLI/FactorioCalc.CLI.csproj @@ -0,0 +1,16 @@ + + + Exe + net10.0 + enable + enable + + + + + + + + + + diff --git a/src/FactorioCalc.CLI/Program.cs b/src/FactorioCalc.CLI/Program.cs new file mode 100644 index 0000000..3e4d971 --- /dev/null +++ b/src/FactorioCalc.CLI/Program.cs @@ -0,0 +1,331 @@ +using FactorioCalc.Data; +using FactorioCalc.Domain; +using FactorioCalc.Reporting; +using FactorioCalc.Solver; +using Spectre.Console; + +namespace FactorioCalc.CLI; + +/// +/// Factorio Space Age Production Calculator +/// Model: GPT-4.1 (OpenAI) — executed by pi coding agent +/// +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 Recipe database JSON file"); + Console.WriteLine(" -m, --modules Module to apply (repeatable)"); + Console.WriteLine(" -j, --json Export to JSON file"); + Console.WriteLine(" -e, --excel 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(); + 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(); + 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 Targets { get; } = new(); + public string? RecipeFile { get; set; } + public List 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; } + } +} diff --git a/src/FactorioCalc.Data/FactorioCalc.Data.csproj b/src/FactorioCalc.Data/FactorioCalc.Data.csproj new file mode 100644 index 0000000..8b0cd15 --- /dev/null +++ b/src/FactorioCalc.Data/FactorioCalc.Data.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + + + + + + + + + + PreserveNewest + + + diff --git a/src/FactorioCalc.Data/JsonModels.cs b/src/FactorioCalc.Data/JsonModels.cs new file mode 100644 index 0000000..a728fa7 --- /dev/null +++ b/src/FactorioCalc.Data/JsonModels.cs @@ -0,0 +1,111 @@ +using System.Text.Json.Serialization; + +namespace FactorioCalc.Data; + +internal sealed class RecipeDatabase +{ + [JsonPropertyName("resources")] + public List Resources { get; set; } = new(); + + [JsonPropertyName("machines")] + public List Machines { get; set; } = new(); + + [JsonPropertyName("modules")] + public List Modules { get; set; } = new(); + + [JsonPropertyName("recipes")] + public List 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 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 Ingredients { get; set; } = new(); + + [JsonPropertyName("products")] + public List 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; } +} diff --git a/src/FactorioCalc.Data/JsonRecipeRepository.cs b/src/FactorioCalc.Data/JsonRecipeRepository.cs new file mode 100644 index 0000000..0656268 --- /dev/null +++ b/src/FactorioCalc.Data/JsonRecipeRepository.cs @@ -0,0 +1,48 @@ +using FactorioCalc.Domain; +using System.Text.Json; + +namespace FactorioCalc.Data; + +/// +/// Loads recipes, resources, machines, and modules from a JSON file. +/// +public sealed class JsonRecipeRepository : IRecipeRepository +{ + public IReadOnlyDictionary Recipes { get; } + public IReadOnlyDictionary Resources { get; } + public IReadOnlyDictionary Machines { get; } + public IReadOnlyDictionary 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(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)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 + }; +} diff --git a/src/FactorioCalc.Domain/FactorioCalc.Domain.csproj b/src/FactorioCalc.Domain/FactorioCalc.Domain.csproj new file mode 100644 index 0000000..6c3a887 --- /dev/null +++ b/src/FactorioCalc.Domain/FactorioCalc.Domain.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/src/FactorioCalc.Domain/IRecipeRepository.cs b/src/FactorioCalc.Domain/IRecipeRepository.cs new file mode 100644 index 0000000..0cd749d --- /dev/null +++ b/src/FactorioCalc.Domain/IRecipeRepository.cs @@ -0,0 +1,12 @@ +namespace FactorioCalc.Domain; + +/// +/// Repository for accessing recipes and resources. +/// +public interface IRecipeRepository +{ + IReadOnlyDictionary Recipes { get; } + IReadOnlyDictionary Resources { get; } + IReadOnlyDictionary Machines { get; } + IReadOnlyDictionary Modules { get; } +} diff --git a/src/FactorioCalc.Domain/IReporter.cs b/src/FactorioCalc.Domain/IReporter.cs new file mode 100644 index 0000000..fefcab3 --- /dev/null +++ b/src/FactorioCalc.Domain/IReporter.cs @@ -0,0 +1,12 @@ +namespace FactorioCalc.Domain; + +/// +/// Reporter — formats and outputs production results. +/// +public interface IReporter +{ + /// + /// Print the production result to the configured output. + /// + void Report(ProductionResult result, IReadOnlyDictionary resources, IReadOnlyDictionary recipes, IReadOnlyDictionary machines); +} diff --git a/src/FactorioCalc.Domain/ISolver.cs b/src/FactorioCalc.Domain/ISolver.cs new file mode 100644 index 0000000..c5f7c3b --- /dev/null +++ b/src/FactorioCalc.Domain/ISolver.cs @@ -0,0 +1,17 @@ +namespace FactorioCalc.Domain; + +/// +/// Production solver — calculates machine counts and resource flows from targets. +/// +public interface ISolver +{ + /// + /// Solve production plan for the given targets. + /// + ProductionResult Solve(IReadOnlyCollection targets); + + /// + /// Solve with module configuration (applied to all matching recipes). + /// + ProductionResult SolveWithModules(IReadOnlyCollection targets, IReadOnlyCollection modules); +} diff --git a/src/FactorioCalc.Domain/Ingredient.cs b/src/FactorioCalc.Domain/Ingredient.cs new file mode 100644 index 0000000..f68a55d --- /dev/null +++ b/src/FactorioCalc.Domain/Ingredient.cs @@ -0,0 +1,16 @@ +namespace FactorioCalc.Domain; + +/// +/// Immutable ingredient — what goes INTO a recipe. +/// +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; + } +} diff --git a/src/FactorioCalc.Domain/Machine.cs b/src/FactorioCalc.Domain/Machine.cs new file mode 100644 index 0000000..e03c4fb --- /dev/null +++ b/src/FactorioCalc.Domain/Machine.cs @@ -0,0 +1,24 @@ +namespace FactorioCalc.Domain; + +/// +/// Immutable machine entity (e.g. Assembler 1, Smelter, Chemical Plant). +/// +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 AllowedCategories { get; } + + public Machine(int id, string name, double craftingSpeed, double energy, int moduleSlots, IReadOnlyCollection 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)); + } +} diff --git a/src/FactorioCalc.Domain/Module.cs b/src/FactorioCalc.Domain/Module.cs new file mode 100644 index 0000000..357cde7 --- /dev/null +++ b/src/FactorioCalc.Domain/Module.cs @@ -0,0 +1,35 @@ +namespace FactorioCalc.Domain; + +/// +/// Module types that can be inserted into machines. +/// +public enum ModuleType +{ + None = 0, + Speed, + Productivity, + Efficiency +} + +/// +/// Immutable module entity (e.g. Speed Module 3, Productivity Module 2). +/// +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; + } +} diff --git a/src/FactorioCalc.Domain/Product.cs b/src/FactorioCalc.Domain/Product.cs new file mode 100644 index 0000000..a11578a --- /dev/null +++ b/src/FactorioCalc.Domain/Product.cs @@ -0,0 +1,16 @@ +namespace FactorioCalc.Domain; + +/// +/// Immutable product — what comes OUT of a recipe. +/// +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)); + } +} diff --git a/src/FactorioCalc.Domain/ProductionResult.cs b/src/FactorioCalc.Domain/ProductionResult.cs new file mode 100644 index 0000000..61c3c5d --- /dev/null +++ b/src/FactorioCalc.Domain/ProductionResult.cs @@ -0,0 +1,21 @@ +namespace FactorioCalc.Domain; + +/// +/// Final production plan — the result of the solver. +/// +public sealed class ProductionResult +{ + public IReadOnlyCollection Executions { get; } + public IReadOnlyDictionary ResourceFlows { get; } // ResourceId -> net flow (positive=produced, negative=consumed) + public IReadOnlyDictionary RequiredInputs { get; } // ResourceId -> raw input needed + + public ProductionResult( + IReadOnlyCollection executions, + IReadOnlyDictionary resourceFlows, + IReadOnlyDictionary requiredInputs) + { + Executions = executions ?? throw new ArgumentNullException(nameof(executions)); + ResourceFlows = resourceFlows ?? throw new ArgumentNullException(nameof(resourceFlows)); + RequiredInputs = requiredInputs ?? throw new ArgumentNullException(nameof(requiredInputs)); + } +} diff --git a/src/FactorioCalc.Domain/ProductionTarget.cs b/src/FactorioCalc.Domain/ProductionTarget.cs new file mode 100644 index 0000000..0ffc6e6 --- /dev/null +++ b/src/FactorioCalc.Domain/ProductionTarget.cs @@ -0,0 +1,16 @@ +namespace FactorioCalc.Domain; + +/// +/// User-defined production target (e.g. "Science Pack 1 at 10/sec"). +/// +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)); + } +} diff --git a/src/FactorioCalc.Domain/Recipe.cs b/src/FactorioCalc.Domain/Recipe.cs new file mode 100644 index 0000000..03246d1 --- /dev/null +++ b/src/FactorioCalc.Domain/Recipe.cs @@ -0,0 +1,27 @@ +namespace FactorioCalc.Domain; + +/// +/// Immutable recipe entity with ingredients and products. +/// +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 Ingredients { get; } + public IReadOnlyCollection Products { get; } + + public Recipe(int id, string name, string category, double craftTime, string machineCategory, + IReadOnlyCollection ingredients, IReadOnlyCollection 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)); + } +} diff --git a/src/FactorioCalc.Domain/RecipeExecution.cs b/src/FactorioCalc.Domain/RecipeExecution.cs new file mode 100644 index 0000000..10a4f15 --- /dev/null +++ b/src/FactorioCalc.Domain/RecipeExecution.cs @@ -0,0 +1,27 @@ +namespace FactorioCalc.Domain; + +/// +/// Represents a single machine executing a recipe (with optional modules). +/// +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 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 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(); + EffectiveSpeed = effectiveSpeed; + EffectiveProductivity = effectiveProductivity; + } +} diff --git a/src/FactorioCalc.Domain/Resource.cs b/src/FactorioCalc.Domain/Resource.cs new file mode 100644 index 0000000..236eab0 --- /dev/null +++ b/src/FactorioCalc.Domain/Resource.cs @@ -0,0 +1,18 @@ +namespace FactorioCalc.Domain; + +/// +/// Immutable resource entity (e.g. Iron Ore, Steel Plate, Science Pack 1). +/// +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; +} diff --git a/src/FactorioCalc.Reporting/ConsoleReporter.cs b/src/FactorioCalc.Reporting/ConsoleReporter.cs new file mode 100644 index 0000000..1c473fe --- /dev/null +++ b/src/FactorioCalc.Reporting/ConsoleReporter.cs @@ -0,0 +1,92 @@ +using FactorioCalc.Domain; + +namespace FactorioCalc.Reporting; + +/// +/// Formats production results as a human-readable console table. +/// +public sealed class ConsoleReporter : IReporter +{ + public void Report(ProductionResult result, IReadOnlyDictionary resources, + IReadOnlyDictionary recipes, IReadOnlyDictionary 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); + } +} diff --git a/src/FactorioCalc.Reporting/ExcelReporter.cs b/src/FactorioCalc.Reporting/ExcelReporter.cs new file mode 100644 index 0000000..612bfca --- /dev/null +++ b/src/FactorioCalc.Reporting/ExcelReporter.cs @@ -0,0 +1,103 @@ +using ClosedXML.Excel; +using FactorioCalc.Domain; + +namespace FactorioCalc.Reporting; + +/// +/// Exports production results to an Excel (.xlsx) file. +/// +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 resources, + IReadOnlyDictionary recipes, IReadOnlyDictionary 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}"); + } +} diff --git a/src/FactorioCalc.Reporting/FactorioCalc.Reporting.csproj b/src/FactorioCalc.Reporting/FactorioCalc.Reporting.csproj new file mode 100644 index 0000000..9a0056a --- /dev/null +++ b/src/FactorioCalc.Reporting/FactorioCalc.Reporting.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + + + + + + + + + + diff --git a/src/FactorioCalc.Reporting/JsonReporter.cs b/src/FactorioCalc.Reporting/JsonReporter.cs new file mode 100644 index 0000000..39d4fbe --- /dev/null +++ b/src/FactorioCalc.Reporting/JsonReporter.cs @@ -0,0 +1,58 @@ +using FactorioCalc.Domain; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FactorioCalc.Reporting; + +/// +/// Exports production results to a JSON file. +/// +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 resources, + IReadOnlyDictionary recipes, IReadOnlyDictionary 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}"); + } +} diff --git a/src/FactorioCalc.Solver/FactorioCalc.Solver.csproj b/src/FactorioCalc.Solver/FactorioCalc.Solver.csproj new file mode 100644 index 0000000..88d1e18 --- /dev/null +++ b/src/FactorioCalc.Solver/FactorioCalc.Solver.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + + + + + + + + diff --git a/src/FactorioCalc.Solver/ProductionSolver.cs b/src/FactorioCalc.Solver/ProductionSolver.cs new file mode 100644 index 0000000..b0671eb --- /dev/null +++ b/src/FactorioCalc.Solver/ProductionSolver.cs @@ -0,0 +1,221 @@ +using FactorioCalc.Domain; + +namespace FactorioCalc.Solver; + +/// +/// DFS-based production solver. +/// Traverses the recipe dependency tree from targets to raw resources, +/// calculating machine counts and resource flows. +/// +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 targets) + { + return SolveWithModules(targets, Array.Empty()); + } + + public ProductionResult SolveWithModules(IReadOnlyCollection targets, IReadOnlyCollection modules) + { + if (targets == null) throw new ArgumentNullException(nameof(targets)); + + var demand = new Dictionary(); // resourceId -> needed amount/sec + var executions = new List(); + var visited = new HashSet(); // prevent cycles + + // Seed with user targets + foreach (var target in targets) + { + demand[target.ResourceId] = target.AmountPerSecond; + } + + // DFS: resolve each demand + var stack = new Stack(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 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 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 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 BuildResourceFlows(List executions, Dictionary demand) + { + var flows = new Dictionary(); + + 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 BuildRequiredInputs(List executions, Dictionary demand, IReadOnlyDictionary recipes) + { + var inputs = new Dictionary(); + + 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; + } +} diff --git a/tests/FactorioCalc.Tests/DomainTests.cs b/tests/FactorioCalc.Tests/DomainTests.cs new file mode 100644 index 0000000..1f7b3a7 --- /dev/null +++ b/tests/FactorioCalc.Tests/DomainTests.cs @@ -0,0 +1,114 @@ +using FactorioCalc.Domain; +using Xunit; + +namespace FactorioCalc.Tests; + +public class DomainTests +{ + [Fact] + public void Resource_ValidConstructor_Succeeds() + { + var resource = new Resource(1, "Iron Ore"); + Assert.Equal(1, resource.Id); + Assert.Equal("Iron Ore", resource.Name); + Assert.Equal("Iron Ore", resource.ToString()); + } + + [Fact] + public void Resource_EmptyName_Throws() + { + Assert.Throws(() => new Resource(1, "")); + Assert.Throws(() => new Resource(1, " ")); + Assert.Throws(() => new Resource(1, null!)); + } + + [Fact] + public void Ingredient_ValidConstructor_Succeeds() + { + var ingredient = new Ingredient(1, 2.5); + Assert.Equal(1, ingredient.ResourceId); + Assert.Equal(2.5, ingredient.Amount); + } + + [Fact] + public void Ingredient_NegativeAmount_Throws() + { + Assert.Throws(() => new Ingredient(1, -1)); + } + + [Fact] + public void Product_ValidConstructor_Succeeds() + { + var product = new Product(7, 1.0); + Assert.Equal(7, product.ResourceId); + Assert.Equal(1.0, product.Amount); + } + + [Fact] + public void Product_ZeroAmount_Throws() + { + Assert.Throws(() => new Product(7, 0)); + } + + [Fact] + public void Machine_ValidConstructor_Succeeds() + { + var machine = new Machine(1, "Smelter", 0.5, 3.0, 2, new[] { "smelting" }); + Assert.Equal(1, machine.Id); + Assert.Equal("Smelter", machine.Name); + Assert.Equal(0.5, machine.CraftingSpeed); + Assert.Equal(2, machine.ModuleSlots); + } + + [Fact] + public void Machine_ZeroCraftingSpeed_Throws() + { + Assert.Throws(() => new Machine(1, "X", 0, 0, 0, Array.Empty())); + } + + [Fact] + public void Module_ValidConstructor_Succeeds() + { + var module = new Module(1, "Speed Module 1", ModuleType.Speed, 0.10, 0, -0.05); + Assert.Equal(ModuleType.Speed, module.Type); + Assert.Equal(0.10, module.SpeedBonus); + } + + [Fact] + public void RecipeExecution_ValidConstructor_Succeeds() + { + var exec = new RecipeExecution(1, 5, 3, 10.5, Array.Empty(), 0.5, 0); + Assert.Equal(1, exec.RecipeId); + Assert.Equal(5, exec.MachineId); + Assert.Equal(3, exec.MachineCount); + Assert.Equal(10.5, exec.RecipeRate); + } + + [Fact] + public void RecipeExecution_ZeroMachineCount_Throws() + { + Assert.Throws(() => + new RecipeExecution(1, 5, 0, 10, Array.Empty(), 0.5, 0)); + } + + [Fact] + public void ProductionTarget_ValidConstructor_Succeeds() + { + var target = new ProductionTarget(30, 10); + Assert.Equal(30, target.ResourceId); + Assert.Equal(10, target.AmountPerSecond); + } + + [Fact] + public void ProductionResult_ValidConstructor_Succeeds() + { + var result = new ProductionResult( + Array.Empty(), + new Dictionary(), + new Dictionary()); + + Assert.Empty(result.Executions); + Assert.Empty(result.ResourceFlows); + Assert.Empty(result.RequiredInputs); + } +} diff --git a/tests/FactorioCalc.Tests/FactorioCalc.Tests.csproj b/tests/FactorioCalc.Tests/FactorioCalc.Tests.csproj new file mode 100644 index 0000000..058d97d --- /dev/null +++ b/tests/FactorioCalc.Tests/FactorioCalc.Tests.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + True + + + + + + + + + + + diff --git a/tests/FactorioCalc.Tests/SolverTests.cs b/tests/FactorioCalc.Tests/SolverTests.cs new file mode 100644 index 0000000..ca3bf13 --- /dev/null +++ b/tests/FactorioCalc.Tests/SolverTests.cs @@ -0,0 +1,219 @@ +using FactorioCalc.Domain; +using FactorioCalc.Solver; +using Xunit; + +namespace FactorioCalc.Tests; + +public class SolverTests +{ + private IRecipeRepository CreateRepository() + { + var resources = new Dictionary + { + { 1, new Resource(1, "Iron Ore") }, + { 7, new Resource(7, "Iron Plate") }, + { 9, new Resource(9, "Steel Plate") }, + { 5, new Resource(5, "Coal") }, + { 8, new Resource(8, "Copper Plate") }, + { 2, new Resource(2, "Copper Ore") }, + { 10, new Resource(10, "Copper Cable") }, + { 11, new Resource(11, "Electronic Circuit") }, + }; + + var machines = new Dictionary + { + { 3, new Machine(3, "Smelter", 0.5, 3.0, 2, new[] { "smelting" }) }, + { 5, new Machine(5, "Assembler 1", 0.5, 3.0, 2, new[] { "basic-crafting" }) }, + { 6, new Machine(6, "Assembler 2", 0.5, 4.0, 2, new[] { "basic-crafting" }) }, + }; + + // Iron Plate: 1 Iron Ore -> 1 Iron Plate (3s) + // Steel Plate: 2 Iron Plate + 1 Coal -> 1 Steel Plate (5s) + // Copper Cable: 1 Copper Plate -> 5 Copper Cable (0.5s) + // Electronic Circuit: 2 Copper Cable + 1 Iron Plate -> 1 Electronic Circuit (3s) + var recipes = new Dictionary + { + { + 1, new Recipe(1, "Iron Plate", "smelting", 3.0, "smelting", + new[] { new Ingredient(1, 1) }, + new[] { new Product(7, 1) }) + }, + { + 2, new Recipe(2, "Steel Plate", "smelting", 5.0, "smelting", + new[] { new Ingredient(7, 2), new Ingredient(5, 1) }, + new[] { new Product(9, 1) }) + }, + { + 3, new Recipe(3, "Copper Cable", "basic-crafting", 0.5, "basic-crafting", + new[] { new Ingredient(8, 1) }, + new[] { new Product(10, 5) }) + }, + { + 4, new Recipe(4, "Electronic Circuit", "basic-crafting", 3.0, "basic-crafting", + new[] { new Ingredient(10, 2), new Ingredient(7, 1) }, + new[] { new Product(11, 1) }) + }, + }; + + var modules = new Dictionary(); + + return new TestRepository(recipes, resources, machines, modules); + } + + [Fact] + public void Solve_SingleTarget_ReturnsExecution() + { + var repo = CreateRepository(); + var solver = new ProductionSolver(repo); + + // Target: 10 Iron Plate/sec + var targets = new[] { new ProductionTarget(7, 10) }; + var result = solver.Solve(targets); + + Assert.Single(result.Executions); + var exec = result.Executions.First(); + Assert.Equal(1, exec.RecipeId); // Iron Plate recipe + Assert.Equal(3, exec.MachineId); // Smelter + } + + [Fact] + public void Solve_ChainedRecipe_ResolveDependencies() + { + var repo = CreateRepository(); + var solver = new ProductionSolver(repo); + + // Target: 2 Steel Plate/sec -> needs Iron Plate -> needs Iron Ore + var targets = new[] { new ProductionTarget(9, 2) }; + var result = solver.Solve(targets); + + // Should have Steel Plate + Iron Plate executions + Assert.Equal(2, result.Executions.Count); + + var steelExec = result.Executions.First(e => e.RecipeId == 2); + Assert.NotNull(steelExec); + } + + [Fact] + public void Solve_ElectronicCircuit_FullChain() + { + var repo = CreateRepository(); + var solver = new ProductionSolver(repo); + + // Target: 10 Electronic Circuit/sec -> needs Copper Cable + Iron Plate + var targets = new[] { new ProductionTarget(11, 10) }; + var result = solver.Solve(targets); + + // Should have EC + Copper Cable + Iron Plate executions + Assert.Equal(3, result.Executions.Count); + } + + [Fact] + public void Solve_WithSpeedModules_IncreasesMachineCount() + { + var repo = CreateRepository(); + var solver = new ProductionSolver(repo); + + var speedModule = new Module(1, "Speed Module 1", ModuleType.Speed, 0.10, 0, -0.05); + var targets = new[] { new ProductionTarget(7, 10) }; + + var resultNoModules = solver.Solve(targets); + var resultWithModules = solver.SolveWithModules(targets, new[] { speedModule }); + + // With speed modules, the rate per machine increases, so we might need fewer machines + // or the same number with higher throughput + Assert.NotEmpty(resultWithModules.Executions); + } + + [Fact] + public void Solve_WithProductivityModules_PrefersProductivityRecipe() + { + // This test verifies that when productivity modules are present, + // the solver prefers productivity recipes if available + var resources = new Dictionary + { + { 1, new Resource(1, "Iron Ore") }, + { 7, new Resource(7, "Iron Plate") }, + }; + var machines = new Dictionary + { + { 3, new Machine(3, "Smelter", 0.5, 3.0, 2, new[] { "smelting" }) }, + }; + var prodModule = new Module(4, "Productivity Module 1", ModuleType.Productivity, -0.10, 0.10, -0.05); + + var recipes = new Dictionary + { + { + 1, new Recipe(1, "Iron Plate", "smelting", 3.0, "smelting", + new[] { new Ingredient(1, 1) }, + new[] { new Product(7, 1) }) + }, + { + 2, new Recipe(2, "Iron Plate (Productivity)", "smelting", 4.0, "smelting", + new[] { new Ingredient(1, 2) }, + new[] { new Product(7, 2) }) + }, + }; + + var modules = new Dictionary { { 4, prodModule } }; + var repo = new TestRepository(recipes, resources, machines, modules); + var solver = new ProductionSolver(repo); + + var targets = new[] { new ProductionTarget(7, 10) }; + var result = solver.SolveWithModules(targets, new[] { prodModule }); + + // Should prefer the productivity recipe + var exec = result.Executions.First(); + Assert.Equal(2, exec.RecipeId); // Productivity recipe + } + + [Fact] + public void Solve_RawResource_NoExecution() + { + var repo = CreateRepository(); + var solver = new ProductionSolver(repo); + + // Iron Ore has no recipe — it's a raw resource + var targets = new[] { new ProductionTarget(1, 100) }; + var result = solver.Solve(targets); + + Assert.Empty(result.Executions); + } + + [Fact] + public void Solve_RequiredInputs_ContainsRawResources() + { + var repo = CreateRepository(); + var solver = new ProductionSolver(repo); + + // Steel Plate needs Iron Ore and Coal (both raw) + var targets = new[] { new ProductionTarget(9, 1) }; + var result = solver.Solve(targets); + + // Iron Ore (id=1) and Coal (id=5) should be in required inputs + Assert.Contains(1, result.RequiredInputs.Keys); + Assert.Contains(5, result.RequiredInputs.Keys); + } +} + +/// +/// In-memory test repository. +/// +public sealed class TestRepository : IRecipeRepository +{ + public IReadOnlyDictionary Recipes { get; } + public IReadOnlyDictionary Resources { get; } + public IReadOnlyDictionary Machines { get; } + public IReadOnlyDictionary Modules { get; } + + public TestRepository( + IReadOnlyDictionary recipes, + IReadOnlyDictionary resources, + IReadOnlyDictionary machines, + IReadOnlyDictionary modules) + { + Recipes = recipes; + Resources = resources; + Machines = machines; + Modules = modules; + } +}