Add building status light

This commit is contained in:
2026-07-20 22:19:58 +02:00
parent c7ecce6ac4
commit 1cbc695bc5
8 changed files with 385 additions and 57 deletions

View File

@@ -951,43 +951,13 @@ void BuildingSystem::tickProduction(Tick currentTick)
// recipe of their type in config order, running the first whose inputs
// are satisfied (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). Other buildings
// try only their selected recipe.
std::vector<const RecipeDef*> candidates;
if (autoRecipe)
{
for (const RecipeDef& r : m_config.recipes.recipes)
{
if (r.building == building.type && !r.inputs.empty())
{
candidates.push_back(&r);
}
}
}
else
{
const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
if (recipe)
{
candidates.push_back(recipe);
}
}
const std::vector<const RecipeDef*> candidates =
gatherCandidateRecipes(building);
for (const RecipeDef* recipe : candidates)
{
// 1. All required inputs present?
bool inputsOk = true;
for (const RecipeIngredient& ing : recipe->inputs)
{
const ItemType type{ing.item};
const std::map<ItemType, int>::const_iterator it =
building.inputBuffer.counts.find(type);
const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0;
if (have < ing.amount)
{
inputsOk = false;
break;
}
}
if (!inputsOk)
if (!recipeInputsAvailable(building, *recipe))
{
continue;
}
@@ -1082,26 +1052,8 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
}
// Build combined materials list (base + modules).
std::map<std::string, int> requiredMaterials;
for (const RecipeIngredient& ing : shipDef->schematic.materials)
{
requiredMaterials[ing.item] += ing.amount;
}
if (building.shipLayout.has_value())
{
for (const PlacedModule& pm : building.shipLayout->placedModules)
{
const ModuleDef* modDef = findModuleDef(pm.moduleId);
if (!modDef)
{
continue;
}
for (const RecipeIngredient& ing : modDef->materials)
{
requiredMaterials[ing.item] += ing.amount;
}
}
}
const std::map<std::string, int> requiredMaterials =
computeShipyardRequiredMaterials(building);
// Idle: check if all combined materials are available.
bool inputsOk = true;
@@ -1328,6 +1280,147 @@ int BuildingSystem::getActiveProductionBuildingCount() const
return count;
}
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 = findRecipe(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 = 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 = 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
{
std::vector<BeltTileInfo> result;

View File

@@ -26,6 +26,18 @@
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;
@@ -122,6 +134,12 @@ public:
// REQ-UI-DEBUG-OVERLAY "Current Factory Production": subset of the above
// that currently has an active production cycle.
int getActiveProductionBuildingCount() const;
// Production state for the UI status light (REQ-UI-STATUS-LIGHT). Returns
// 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;
bool isTileOccupied(QPoint tile) const;
@@ -200,6 +218,22 @@ private:
const Port& outputPort,
const Item& item);
// Candidate recipes an idle building would try this tick: an auto-recipe
// 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;
const BuildingDef* findBuildingDef(BuildingType type) const;
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
const ShipDef* findShipDef(const std::string& id) const;

View File

@@ -1308,3 +1308,116 @@ TEST_CASE("BuildingSystem: splitter filters configured on a construction site ca
REQUIRE(builtInfo->filterA == filterA);
REQUIRE(builtInfo->filterB.empty());
}
// ---------------------------------------------------------------------------
// Production status classifier (REQ-UI-STATUS-LIGHT)
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[building]")
{
PlacementFixture f;
// Pick representative config ids so the test survives content edits.
std::string minerRecipeId;
for (const RecipeDef& r : f.cfg.recipes.recipes)
{
if (r.building == BuildingType::Miner) { minerRecipeId = r.id; break; }
}
REQUIRE_FALSE(minerRecipeId.empty());
const RecipeDef* assemblerRecipe = nullptr;
for (const RecipeDef& r : f.cfg.recipes.recipes)
{
if (r.building == BuildingType::Assembler && !r.inputs.empty())
{
assemblerRecipe = &r;
break;
}
}
REQUIRE(assemblerRecipe != nullptr);
std::string shipId;
for (const ShipDef& s : f.cfg.ships.ships)
{
if (!s.schematic.materials.empty()) { shipId = s.id; break; }
}
REQUIRE_FALSE(shipId.empty());
const auto statusOf = [&f](const Building& b) { return f.bs.getProductionStatus(b); };
SECTION("non-production buildings show no status light")
{
Building belt; belt.type = BuildingType::Belt;
Building hq; hq.type = BuildingType::Hq;
REQUIRE_FALSE(statusOf(belt).has_value());
REQUIRE_FALSE(statusOf(hq).has_value());
}
SECTION("Miner: unconfigured, producing, output-blocked")
{
Building miner; miner.type = BuildingType::Miner;
REQUIRE(statusOf(miner) == ProductionStatus::Unconfigured); // no recipe -> grey
miner.recipeId = minerRecipeId;
miner.production = Production{};
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // active cycle -> green
// A miner has no inputs, so its only idle reason is a full output buffer.
miner.production = std::nullopt;
miner.outputBuffer.capacity = 2;
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
}
SECTION("Assembler: starved vs blocked, input-missing takes precedence")
{
Building assembler; assembler.type = BuildingType::Assembler;
assembler.recipeId = assemblerRecipe->id;
// Idle with inputs missing -> red.
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
// Inputs present but idle -> the only remaining reason is a full output
// buffer -> yellow.
for (const RecipeIngredient& ing : assemblerRecipe->inputs)
{
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
}
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
// Inputs missing AND output full -> red wins over yellow.
assembler.inputBuffer.counts.clear();
assembler.outputBuffer.capacity = 2;
assembler.outputBuffer.items = { makeItem("x"), makeItem("x") };
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
}
SECTION("Smelter (auto-recipe) is never grey")
{
Building smelter; smelter.type = BuildingType::Smelter;
// No player-selectable recipe and empty inputs -> red, not grey.
REQUIRE(statusOf(smelter) == ProductionStatus::Starved);
}
SECTION("Shipyard: unconfigured, then starved without materials, then producing")
{
Building yard; yard.type = BuildingType::Shipyard;
REQUIRE(statusOf(yard) == ProductionStatus::Unconfigured); // no schematic -> grey
yard.recipeId = shipId;
REQUIRE(statusOf(yard) == ProductionStatus::Starved); // no materials -> red
yard.production = Production{};
REQUIRE(statusOf(yard) == ProductionStatus::Producing); // active cycle -> green
}
SECTION("Salvage Bay: red when empty, green when holding scrap")
{
Building bay; bay.type = BuildingType::SalvageBay;
bay.outputBuffer.capacity = 20;
REQUIRE(statusOf(bay) == ProductionStatus::Starved); // empty -> red
bay.outputBuffer.items = { makeItem("scrap") };
REQUIRE(statusOf(bay) == ProductionStatus::Producing); // holding scrap -> green
}
}

View File

@@ -124,6 +124,20 @@ QPoint portBodyTile(QPoint portTile, Rotation direction)
return portTile;
}
// Fill color for a building's status light per its production state
// (REQ-UI-STATUS-LIGHT).
QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl)
{
switch (status)
{
case ProductionStatus::Unconfigured: return sl.grey;
case ProductionStatus::Producing: return sl.green;
case ProductionStatus::Starved: return sl.red;
case ProductionStatus::Blocked: return sl.yellow;
}
return sl.grey;
}
} // namespace
@@ -1039,6 +1053,35 @@ void GameWorldView::drawBuildings(QPainter& painter)
port.direction, bv.outline, /*centered*/ false);
}
// Status light: a small circle in the building's upper-right corner
// (in default/East orientation), anchored to that footprint corner and
// rotating with the building (REQ-UI-STATUS-LIGHT). All status-light
// 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))
{
const float px = getTilePx();
const float r = px * 0.18f;
const float inset = r + px * 0.12f;
// Default orientation (East) puts the light at the top-right corner;
// clockwise rotation carries it around the footprint.
QPointF center(bboxRect.right() - inset, bboxRect.top() + inset);
switch (b.rotation)
{
case Rotation::East: break;
case Rotation::South: center = QPointF(bboxRect.right() - inset,
bboxRect.bottom() - inset); break;
case Rotation::West: center = QPointF(bboxRect.left() + inset,
bboxRect.bottom() - inset); break;
case Rotation::North: center = QPointF(bboxRect.left() + inset,
bboxRect.top() + inset); break;
}
painter.setBrush(statusLightFill(*status, m_visuals->statusLight));
painter.setPen(QPen(m_visuals->statusLight.outline, 1));
painter.drawEllipse(center, r, r);
}
// HP bar below the HQ footprint; the HQ's HP lives on its proxy entity.
if (b.type == BuildingType::Hq)
{

View File

@@ -60,6 +60,17 @@ struct ToastVisuals
int fontSize;
};
// Fill colors for the building status light (REQ-UI-STATUS-LIGHT), plus its
// constant outline color.
struct StatusLightVisuals
{
QColor grey; // no recipe/schematic selected
QColor green; // producing
QColor red; // idle, input missing (or Salvage Bay empty)
QColor yellow; // idle, output buffer full
QColor outline;
};
struct VisualsConfig
{
TileVisuals asteroid;
@@ -69,7 +80,8 @@ struct VisualsConfig
std::map<std::string, ItemVisuals> items;
std::map<std::string, ShipVisuals> ships;
BeamVisuals beams;
OverlayVisuals overlays;
ToastVisuals toast;
BeamVisuals beams;
OverlayVisuals overlays;
ToastVisuals toast;
StatusLightVisuals statusLight;
};

View File

@@ -237,5 +237,15 @@ VisualsConfig VisualsLoader::load(const std::string& path)
cfg.toast.fontSize = requireInt(t, "font_size", "toast");
}
// Status light (REQ-UI-STATUS-LIGHT)
{
toml::table& sl = requireSubtable(tbl, "status_light", "root");
cfg.statusLight.grey = parseColor(requireString(sl, "grey", "status_light"), "status_light.grey");
cfg.statusLight.green = parseColor(requireString(sl, "green", "status_light"), "status_light.green");
cfg.statusLight.red = parseColor(requireString(sl, "red", "status_light"), "status_light.red");
cfg.statusLight.yellow = parseColor(requireString(sl, "yellow", "status_light"), "status_light.yellow");
cfg.statusLight.outline = parseColor(requireString(sl, "outline", "status_light"), "status_light.outline");
}
return cfg;
}