extract the production rules as free functions over config and building

gatherCandidateRecipes, recipeInputsAvailable, computeShipyardRequiredMaterials,
hasInputsToStart and getProductionStatus read no factory state — they answer
"what can this building produce, and can it start" from the config and the
Building alone. They move to ProductionRules.h as free functions, and the
ProductionStatus enum goes with them since it is that group's return type.

Two of the five are pure in their arguments; the other three need GameConfig
through gatherCandidateRecipes and computeShipyardRequiredMaterials, so config is
a parameter rather than the group being split across two headers.

Only two callers outside BuildingSystem existed — the status light in
GameWorldView and one test lambda — so this is nearly all internal.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
This commit is contained in:
2026-08-04 22:17:01 +02:00
parent d1b688f45e
commit 3272431353
7 changed files with 199 additions and 158 deletions

View File

@@ -7,6 +7,7 @@
#include <set>
#include "FactoryQueries.h"
#include "ProductionRules.h"
#include "PortGeometry.h"
#include "StateChecksum.h"
#include "SurfaceMask.h"
@@ -1008,7 +1009,7 @@ void BuildingSystem::tickProduction(Tick currentTick)
// are satisfied (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). Other buildings
// try only their selected recipe.
const std::vector<const RecipeDef*> candidates =
gatherCandidateRecipes(building);
gatherCandidateRecipes(m_config, building);
for (const RecipeDef* recipe : candidates)
{
@@ -1112,7 +1113,7 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
// Build combined materials list (base + modules).
const std::map<std::string, int> requiredMaterials =
computeShipyardRequiredMaterials(building);
computeShipyardRequiredMaterials(m_config, building);
// Idle: check if all combined materials are available.
bool inputsOk = true;
@@ -1257,146 +1258,10 @@ void BuildingSystem::forEachIncomingItem(
// Queries
// ---------------------------------------------------------------------------
std::vector<const RecipeDef*>
BuildingSystem::gatherCandidateRecipes(const Building& b) const
{
std::vector<const RecipeDef*> candidates;
if (isAutoRecipeBuildingType(b.type))
{
for (const RecipeDef& r : m_config.recipes.recipes)
{
if (r.building == b.type && !r.inputs.empty())
{
candidates.push_back(&r);
}
}
}
else
{
const RecipeDef* recipe = m_config.recipes.findRecipeDef(b.recipeId, b.type);
if (recipe)
{
candidates.push_back(recipe);
}
}
return candidates;
}
bool BuildingSystem::recipeInputsAvailable(const Building& b,
const RecipeDef& recipe) const
{
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>
BuildingSystem::computeShipyardRequiredMaterials(const Building& b) const
{
std::map<std::string, int> requiredMaterials;
const ShipDef* shipDef = m_config.ships.findShipDef(b.recipeId);
if (!shipDef)
{
return requiredMaterials;
}
for (const RecipeIngredient& ing : shipDef->schematic.materials)
{
requiredMaterials[ing.item] += ing.amount;
}
if (b.shipLayout.has_value())
{
for (const PlacedModule& pm : b.shipLayout->placedModules)
{
const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
if (!modDef)
{
continue;
}
for (const RecipeIngredient& ing : modDef->materials)
{
requiredMaterials[ing.item] += ing.amount;
}
}
}
return requiredMaterials;
}
bool BuildingSystem::hasInputsToStart(const Building& b) const
{
if (b.type == BuildingType::Shipyard)
{
const std::map<std::string, int> required =
computeShipyardRequiredMaterials(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 any candidate recipe's inputs are satisfied.
// A Miner recipe has no inputs, so an idle Miner is always startable and its
// only idle reason is a full output buffer.
for (const RecipeDef* recipe : gatherCandidateRecipes(b))
{
if (recipeInputsAvailable(b, *recipe))
{
return true;
}
}
return false;
}
std::optional<ProductionStatus>
BuildingSystem::getProductionStatus(const Building& building) const
{
// 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;
}
// Grey only applies to player-configured types; auto-recipe buildings
// (Smelter, Reprocessing Plant) always run an implicit recipe.
if (!isAutoRecipeBuildingType(building.type) && building.recipeId.empty())
{
return ProductionStatus::Unconfigured;
}
if (building.production.has_value())
{
return ProductionStatus::Producing;
}
// Idle: missing inputs (red) take precedence over a full output buffer
// (yellow). If inputs are present yet the building is idle, the only remaining
// reason it could not start a cycle is a full output buffer (REQ-MAT-CYCLE).
return hasInputsToStart(building) ? ProductionStatus::Blocked
: ProductionStatus::Starved;
}
std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::getAllBeltTiles() const
{

View File

@@ -16,6 +16,7 @@
#include "BeltSystem.h"
#include "Building.h"
#include "FactoryState.h"
#include "ProductionRules.h"
#include "BuildingType.h"
#include "BuildingId.h"
#include "GameConfig.h"
@@ -27,18 +28,6 @@
class Hasher;
// Production state of a building for the UI status light (REQ-UI-STATUS-LIGHT).
// The simulation owns the classification so it stays in sync with the
// production-cycle predicates (REQ-MAT-CYCLE); the UI maps each value to a fill
// color.
enum class ProductionStatus
{
Unconfigured, // no recipe/schematic selected (grey)
Producing, // a production cycle is active (green)
Starved, // idle: a required input is missing / Salvage Bay empty (red)
Blocked, // idle: output buffer full, inputs otherwise present (yellow)
};
// Manages building placement, construction queuing, and the per-tick
// production loop (belt→building pull, production, building→belt push).
// All types including Belt and Splitter are stored as Building instances;
@@ -150,7 +139,6 @@ public:
// nullopt for building types that show no light (belts, splitters, tunnels,
// HQ, defence stations). The Salvage Bay is a two-state special case:
// Producing while its output buffer holds scrap, Starved when empty.
std::optional<ProductionStatus> getProductionStatus(const Building& building) const;
std::vector<BeltTileInfo> getAllBeltTiles() const;
// Visits every item currently emerging from a building output port on its
@@ -245,17 +233,12 @@ private:
// building (Smelter, Reprocessing Plant) offers every recipe of its type with
// inputs; other buildings offer only their selected recipe. Shared by
// tickProduction and the status classifier (REQ-MAT-CYCLE, REQ-UI-STATUS-LIGHT).
std::vector<const RecipeDef*> gatherCandidateRecipes(const Building& b) const;
// True if every input of `recipe` is present in `b`'s input buffers in the
// required per-cycle amount (REQ-MAT-CYCLE input check).
bool recipeInputsAvailable(const Building& b,
const RecipeDef& recipe) const;
// Combined base + module materials a shipyard needs per ship (REQ-BLD-SHIPYARD).
std::map<std::string, int> computeShipyardRequiredMaterials(const Building& b) const;
// True if the building currently has all inputs/materials to start a cycle
// (ignoring output-buffer space); drives the Starved/Blocked distinction of
// the status light (REQ-UI-STATUS-LIGHT).
bool hasInputsToStart(const Building& b) const;
void initBuffers(Building& b, const RecipeDef& recipe) const;
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input

View File

@@ -15,6 +15,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/FactoryState.h
${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.h
${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
@@ -41,6 +42,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp

View File

@@ -0,0 +1,142 @@
#include "ProductionRules.h"
#include "BuildingType.h"
#include "ItemType.h"
#include "ModulesConfig.h"
#include "ShipsConfig.h"
std::vector<const RecipeDef*>
gatherCandidateRecipes(const GameConfig& config, const Building& b)
{
std::vector<const RecipeDef*> candidates;
if (isAutoRecipeBuildingType(b.type))
{
for (const RecipeDef& r : config.recipes.recipes)
{
if (r.building == b.type && !r.inputs.empty())
{
candidates.push_back(&r);
}
}
}
else
{
const RecipeDef* recipe = config.recipes.findRecipeDef(b.recipeId, b.type);
if (recipe)
{
candidates.push_back(recipe);
}
}
return candidates;
}
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)
{
std::map<std::string, int> requiredMaterials;
const ShipDef* shipDef = config.ships.findShipDef(b.recipeId);
if (!shipDef)
{
return requiredMaterials;
}
for (const RecipeIngredient& ing : shipDef->schematic.materials)
{
requiredMaterials[ing.item] += ing.amount;
}
if (b.shipLayout.has_value())
{
for (const PlacedModule& pm : b.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;
}
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 any candidate recipe's inputs are satisfied.
// A Miner recipe has no inputs, so an idle Miner is always startable and its
// only idle reason is a full output buffer.
for (const RecipeDef* recipe : gatherCandidateRecipes(config, b))
{
if (recipeInputsAvailable(b, *recipe))
{
return true;
}
}
return false;
}
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;
}
// Grey only applies to player-configured types; auto-recipe buildings
// (Smelter, Reprocessing Plant) always run an implicit recipe.
if (!isAutoRecipeBuildingType(building.type) && building.recipeId.empty())
{
return ProductionStatus::Unconfigured;
}
if (building.production.has_value())
{
return ProductionStatus::Producing;
}
// Idle: missing inputs (red) take precedence over a full output buffer
// (yellow). If inputs are present yet the building is idle, the only remaining
// reason it could not start a cycle is a full output buffer (REQ-MAT-CYCLE).
return hasInputsToStart(config, building) ? ProductionStatus::Blocked
: ProductionStatus::Starved;
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include <map>
#include <optional>
#include <string>
#include <vector>
#include "Building.h"
#include "GameConfig.h"
#include "RecipesConfig.h"
// Production state of a building for the UI status light (REQ-UI-STATUS-LIGHT).
// The simulation owns the classification so it stays in sync with the
// production-cycle predicates (REQ-MAT-CYCLE); the UI maps each value to a fill
// color.
enum class ProductionStatus
{
Unconfigured, // no recipe/schematic selected (grey)
Producing, // a production cycle is active (green)
Starved, // idle: a required input is missing / Salvage Bay empty (red)
Blocked, // idle: output buffer full, inputs otherwise present (yellow)
};
// The rules deciding what a building can produce and whether it can start.
// Pure functions of the config and the building itself — they read no factory
// state, so they are free functions rather than BuildingSystem members.
// Recipes this building could run: every recipe of its type for an auto-recipe
// building (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), otherwise just its selected one.
std::vector<const RecipeDef*> gatherCandidateRecipes(const GameConfig& config,
const Building& b);
// True when the building's input buffer holds every ingredient the recipe needs.
bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe);
// Total materials a shipyard needs for its schematic plus its placed modules
// (REQ-BLD-SHIPYARD), keyed by item id.
std::map<std::string, int> computeShipyardRequiredMaterials(const GameConfig& config,
const Building& b);
// True when a production cycle could start right now, ignoring output-buffer space.
bool hasInputsToStart(const GameConfig& config, const Building& b);
// Status light for a building, or nullopt for types that show none — belts,
// splitters, tunnels, HQ and defence stations (REQ-UI-STATUS-LIGHT).
std::optional<ProductionStatus> getProductionStatus(const GameConfig& config,
const Building& building);

View File

@@ -1,5 +1,6 @@
#include "catch.hpp"
#include "FactoryQueries.h"
#include "ProductionRules.h"
#include <map>
#include <random>
@@ -1562,7 +1563,7 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
}
REQUIRE_FALSE(shipId.empty());
const auto statusOf = [&f](const Building& b) { return f.bs.getProductionStatus(b); };
const auto statusOf = [&f](const Building& b) { return getProductionStatus(f.cfg, b); };
SECTION("non-production buildings show no status light")
{

View File

@@ -1,5 +1,6 @@
#include "GameWorldView.h"
#include "FactoryQueries.h"
#include "ProductionRules.h"
#include <algorithm>
#include <cctype>
@@ -1341,7 +1342,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
// building footprints are rectangular, so a corner of the axis-aligned
// bounding box is the true corner.
if (const std::optional<ProductionStatus> status =
m_sim->getBuildings().getProductionStatus(b))
getProductionStatus(m_sim->getConfig(), b))
{
const float px = getTilePx();
const float r = px * 0.18f;