Implements REQ-MAT-OUTPUT-GROUP. A recipe had two shapes -- outputs produced together, or outputs of which exactly one happened -- and every rule over them was written twice, selected by `building == ReprocessingPlant`: sizing a buffer, deciding whether a cycle fits, resolving what a cycle makes, costing an item. RecipeDef now holds output groups, each a weight and a list of items, and a cycle yields exactly one group. One group is the ordinary recipe, so the old two cases are the same shape with one and with several, and all four rules collapse to one expression apiece with no building-type test left. rollReprocessingOutput becomes rollOutputGroup, where a single group returns without drawing or testing eligibility. That early-out is load-bearing twice over. Drawing there would consume entropy for every ordinary recipe and shift every later random outcome; and eligibility must not apply either, since implicit unlocking is demand-derived, so an ordinary recipe's output can be producible while nothing yet calls for it -- testing it would stop the building producing rather than gate a drop. Past the early-out a group is eligible only when all of its items are unlocked, being produced whole. Threat follows the recipe's shape rather than the building, and the per-unit value now divides by the group's amount as well as its odds. That moves no number today: every item resolved through this path has amount 1, which is why the threat expectations are untouched. Config keeps `outputs = [...]` as the single-group form, so only the two reprocessing recipes change shape. The recipe summary gains "/" between groups and keeps "+" within one, which also fixes the plant reading as though a cycle produced all of its items at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
237 lines
7.8 KiB
C++
237 lines
7.8 KiB
C++
#include "ProductionRules.h"
|
|
|
|
#include <algorithm>
|
|
#include <map>
|
|
|
|
#include "BuildingType.h"
|
|
#include "ItemType.h"
|
|
#include "ModulesConfig.h"
|
|
#include "ShipsConfig.h"
|
|
|
|
const RecipeDef* getSelectedRecipe(const GameConfig& config, const Building& b)
|
|
{
|
|
if (b.recipeId.empty())
|
|
{
|
|
return nullptr;
|
|
}
|
|
return config.recipes.findRecipeDef(b.recipeId, b.type);
|
|
}
|
|
|
|
const RecipeDef* findAutoRecipeFor(const GameConfig& config, BuildingType type,
|
|
const ItemType& item)
|
|
{
|
|
if (!isAutoRecipeBuildingType(type))
|
|
{
|
|
return nullptr;
|
|
}
|
|
|
|
// Config order decides where a material feeds more than one recipe of the type, so
|
|
// the same offer always picks the same recipe (REQ-BLD-AUTO-RECIPE).
|
|
for (const RecipeDef& recipe : config.recipes.recipes)
|
|
{
|
|
if (recipe.building != type) { continue; }
|
|
for (const RecipeIngredient& ing : recipe.inputs)
|
|
{
|
|
if (ItemType{ing.item} == item)
|
|
{
|
|
return &recipe;
|
|
}
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe)
|
|
{
|
|
for (const RecipeIngredient& ing : recipe.inputs)
|
|
{
|
|
const std::map<ItemType, int>::const_iterator it =
|
|
b.inputBuffer.counts.find(ItemType{ing.item});
|
|
const int have = (it != b.inputBuffer.counts.end()) ? it->second : 0;
|
|
if (have < ing.amount)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
std::map<std::string, int>
|
|
computeShipyardRequiredMaterials(const GameConfig& config, const Building& b)
|
|
{
|
|
return computeShipyardRequiredMaterials(config, b.recipeId, b.shipLayout);
|
|
}
|
|
|
|
std::map<std::string, int>
|
|
computeShipyardRequiredMaterials(const GameConfig& config,
|
|
const std::string& recipeId,
|
|
const std::optional<ShipLayoutConfig>& shipLayout)
|
|
{
|
|
std::map<std::string, int> requiredMaterials;
|
|
const ShipDef* shipDef = config.ships.findShipDef(recipeId);
|
|
if (!shipDef)
|
|
{
|
|
return requiredMaterials;
|
|
}
|
|
for (const RecipeIngredient& ing : shipDef->schematic.materials)
|
|
{
|
|
requiredMaterials[ing.item] += ing.amount;
|
|
}
|
|
if (shipLayout.has_value())
|
|
{
|
|
for (const PlacedModule& pm : shipLayout->placedModules)
|
|
{
|
|
const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId);
|
|
if (!modDef)
|
|
{
|
|
continue;
|
|
}
|
|
for (const RecipeIngredient& ing : modDef->materials)
|
|
{
|
|
requiredMaterials[ing.item] += ing.amount;
|
|
}
|
|
}
|
|
}
|
|
return requiredMaterials;
|
|
}
|
|
|
|
double computeShipyardProductionTimeSeconds(
|
|
const GameConfig& config, const std::string& recipeId,
|
|
const std::optional<ShipLayoutConfig>& shipLayout)
|
|
{
|
|
const ShipDef* shipDef = config.ships.findShipDef(recipeId);
|
|
if (!shipDef)
|
|
{
|
|
return 0.0;
|
|
}
|
|
|
|
double seconds = shipDef->schematic.productionTimeSeconds;
|
|
if (shipLayout.has_value())
|
|
{
|
|
for (const PlacedModule& pm : shipLayout->placedModules)
|
|
{
|
|
const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId);
|
|
if (!modDef)
|
|
{
|
|
continue;
|
|
}
|
|
seconds += modDef->productionTimeSeconds;
|
|
}
|
|
}
|
|
return seconds;
|
|
}
|
|
|
|
bool hasInputsToStart(const GameConfig& config, const Building& b)
|
|
{
|
|
if (b.type == BuildingType::Shipyard)
|
|
{
|
|
const std::map<std::string, int> required =
|
|
computeShipyardRequiredMaterials(config, b);
|
|
for (const std::pair<const std::string, int>& req : required)
|
|
{
|
|
const std::map<ItemType, int>::const_iterator it =
|
|
b.inputBuffer.counts.find(ItemType{req.first});
|
|
const int have = (it != b.inputBuffer.counts.end()) ? it->second : 0;
|
|
if (have < req.second)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Recipe buildings: startable if the selected recipe's inputs are satisfied. A Miner
|
|
// recipe has no inputs, so an idle Miner is always startable here and its only idle
|
|
// reason is an output buffer without room for the next cycle.
|
|
const RecipeDef* recipe = getSelectedRecipe(config, b);
|
|
return recipe != nullptr && recipeInputsAvailable(b, *recipe);
|
|
}
|
|
|
|
bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount)
|
|
{
|
|
const std::map<ItemType, int>::const_iterator capIt = b.outputBuffer.caps.find(type);
|
|
const int cap = (capIt != b.outputBuffer.caps.end()) ? capIt->second : 0;
|
|
return b.getOutputItemCount(type) + itemCount <= cap;
|
|
}
|
|
|
|
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe)
|
|
{
|
|
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
|
{
|
|
// A group's items come together, so an item listed twice in one is produced in the
|
|
// sum of those amounts and judged once, as a sum.
|
|
std::map<ItemType, int> perCycle;
|
|
for (const RecipeOutput& out : group.items)
|
|
{
|
|
perCycle[ItemType{out.item}] += out.amount;
|
|
}
|
|
for (const std::pair<const ItemType, int>& entry : perCycle)
|
|
{
|
|
if (!outputBufferHasRoom(b, entry.first, entry.second))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool canStartCycle(const GameConfig& config, const Building& b)
|
|
{
|
|
// A shipyard's completed cycle spawns a ship instead of filling an output buffer
|
|
// (REQ-BLD-SHIPYARD), so holding the materials is the whole condition.
|
|
if (b.type == BuildingType::Shipyard)
|
|
{
|
|
return hasInputsToStart(config, b);
|
|
}
|
|
|
|
const RecipeDef* recipe = getSelectedRecipe(config, b);
|
|
return recipe != nullptr && recipeInputsAvailable(b, *recipe)
|
|
&& recipeOutputsFit(b, *recipe);
|
|
}
|
|
|
|
std::optional<ProductionStatus>
|
|
getProductionStatus(const GameConfig& config, const Building& building)
|
|
{
|
|
// Salvage Bay has no recipe or production cycle (REQ-BLD-SALVAGE-BAY): it is
|
|
// "producing" while it holds scrap to push out, and starved when empty.
|
|
if (building.type == BuildingType::SalvageBay)
|
|
{
|
|
return building.getOutputItemCount() >= 1 ? ProductionStatus::Producing
|
|
: ProductionStatus::Starved;
|
|
}
|
|
|
|
// Only the five recipe/cycle production types show a status light besides the
|
|
// Salvage Bay; belts, splitters, tunnels, HQ, and stations show none.
|
|
if (!isProductionBuildingType(building.type))
|
|
{
|
|
return std::nullopt;
|
|
}
|
|
|
|
// Every production building can be unconfigured, an auto-recipe building included:
|
|
// it holds no recipe until one is offered to it, and the player can hand it back to
|
|
// automatic selection (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT).
|
|
if (building.recipeId.empty())
|
|
{
|
|
return ProductionStatus::Unconfigured;
|
|
}
|
|
|
|
if (building.production.has_value())
|
|
{
|
|
return ProductionStatus::Producing;
|
|
}
|
|
|
|
// Idle, but blocked by neither condition: the building is only between cycles and
|
|
// the simulation starts the next one on a following tick. A building running back
|
|
// to back sits here for exactly one tick per cycle, since tickProduction never
|
|
// starts a cycle in the tick one completed, so this must read as producing rather
|
|
// than blink (REQ-UI-STATUS-LIGHT, REQ-MAT-CYCLE).
|
|
if (canStartCycle(config, building))
|
|
{
|
|
return ProductionStatus::Producing;
|
|
}
|
|
|
|
// Idle for a reason: a missing input (red) takes precedence over an output buffer
|
|
// with no room for the next cycle's output (yellow).
|
|
return hasInputsToStart(config, building) ? ProductionStatus::Blocked
|
|
: ProductionStatus::Starved;
|
|
}
|