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:
+24
@@ -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/
|
||||
@@ -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 <path>` | Путь к JSON с рецептами |
|
||||
| `-m, --modules <name>` | Модуль для применения (можно повторять) |
|
||||
| `-j, --json <path>` | Экспорт в JSON |
|
||||
| `-e, --excel <path>` | Экспорт в 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-а.
|
||||
@@ -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 } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentException>(() => new Resource(1, ""));
|
||||
Assert.Throws<ArgumentException>(() => new Resource(1, " "));
|
||||
Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => new Machine(1, "X", 0, 0, 0, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
[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<Module>(), 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<ArgumentException>(() =>
|
||||
new RecipeExecution(1, 5, 0, 10, Array.Empty<Module>(), 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<RecipeExecution>(),
|
||||
new Dictionary<int, double>(),
|
||||
new Dictionary<int, double>());
|
||||
|
||||
Assert.Empty(result.Executions);
|
||||
Assert.Empty(result.ResourceFlows);
|
||||
Assert.Empty(result.RequiredInputs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsTestProject>True</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\FactorioCalc.Domain\FactorioCalc.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\FactorioCalc.Solver\FactorioCalc.Solver.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<int, Resource>
|
||||
{
|
||||
{ 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<int, Machine>
|
||||
{
|
||||
{ 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<int, Recipe>
|
||||
{
|
||||
{
|
||||
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<int, Module>();
|
||||
|
||||
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<int, Resource>
|
||||
{
|
||||
{ 1, new Resource(1, "Iron Ore") },
|
||||
{ 7, new Resource(7, "Iron Plate") },
|
||||
};
|
||||
var machines = new Dictionary<int, Machine>
|
||||
{
|
||||
{ 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<int, Recipe>
|
||||
{
|
||||
{
|
||||
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<int, Module> { { 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory test repository.
|
||||
/// </summary>
|
||||
public sealed class TestRepository : 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 TestRepository(
|
||||
IReadOnlyDictionary<int, Recipe> recipes,
|
||||
IReadOnlyDictionary<int, Resource> resources,
|
||||
IReadOnlyDictionary<int, Machine> machines,
|
||||
IReadOnlyDictionary<int, Module> modules)
|
||||
{
|
||||
Recipes = recipes;
|
||||
Resources = resources;
|
||||
Machines = machines;
|
||||
Modules = modules;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user