From ee71be549a41cc0668f59dd223f8d158a6900bdf Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Mon, 20 Jul 2026 21:50:08 +0200 Subject: [PATCH] Implement building status light (REQ-UI-STATUS-LIGHT) Add a per-building status light: a small circle drawn in the operational building's upper-right corner (rotating with the building) so production state is readable without selecting it. - Sim: BuildingSystem::getProductionStatus classifies each building into Unconfigured/Producing/Starved/Blocked (nullopt for non-production types); Salvage Bay is a two-state special case (Producing with scrap, Starved when empty). Extracted shared predicates (gatherCandidateRecipes, recipeInputsAvailable, computeShipyardRequiredMaterials, hasInputsToStart) so tickProduction / tickShipyardProduction and the classifier stay in sync. - UI: GameWorldView draws the circle, mapping status -> color from a new visuals.toml [status_light] section (grey/green/red/yellow + outline). - Tests: getProductionStatus cases for every state, including red-over-yellow precedence and the auto-recipe/salvage-bay specials. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc --- bin/app/data/config/visuals.toml | 14 +++ src/lib/sim/BuildingSystem.cpp | 199 +++++++++++++++++++++++-------- src/lib/sim/BuildingSystem.h | 34 ++++++ src/test/BuildingTest.cpp | 113 ++++++++++++++++++ src/ui/GameWorldView.cpp | 43 +++++++ src/ui/VisualsConfig.h | 18 ++- src/ui/VisualsLoader.cpp | 10 ++ 7 files changed, 375 insertions(+), 56 deletions(-) diff --git a/bin/app/data/config/visuals.toml b/bin/app/data/config/visuals.toml index 487fb74..43786e9 100644 --- a/bin/app/data/config/visuals.toml +++ b/bin/app/data/config/visuals.toml @@ -360,3 +360,17 @@ modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs bg = "#000000cc" fg = "#ffffff" font_size = 14 + +# ----------------------------------------------------------------------------- +# Building status light (REQ-UI-STATUS-LIGHT) +# +# Fill color per production state, drawn as a small circle in the building's +# upper-right corner, plus the constant outline color. +# ----------------------------------------------------------------------------- + +[status_light] +grey = "#808080" # no recipe/schematic selected +green = "#33cc33" # producing (Salvage Bay: holding scrap) +red = "#cc3333" # idle, input missing (Salvage Bay: empty) +yellow = "#e6c619" # idle, output buffer full +outline = "#000000" diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 2744d04..0d9a4d3 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -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 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 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::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 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 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 +BuildingSystem::gatherCandidateRecipes(const Building& b) const +{ + std::vector 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::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 +BuildingSystem::computeShipyardRequiredMaterials(const Building& b) const +{ + std::map 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 required = + computeShipyardRequiredMaterials(b); + for (const std::pair& req : required) + { + const std::map::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 +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::getAllBeltTiles() const { std::vector result; diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 6e272aa..64d31af 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -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 getProductionStatus(const Building& building) const; std::vector 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 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 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; diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 0a53cba..4004f58 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -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 + } +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index a9601b5..e665d4e 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -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 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) { diff --git a/src/ui/VisualsConfig.h b/src/ui/VisualsConfig.h index 8d9a666..cd1efdd 100644 --- a/src/ui/VisualsConfig.h +++ b/src/ui/VisualsConfig.h @@ -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 items; std::map ships; - BeamVisuals beams; - OverlayVisuals overlays; - ToastVisuals toast; + BeamVisuals beams; + OverlayVisuals overlays; + ToastVisuals toast; + StatusLightVisuals statusLight; }; diff --git a/src/ui/VisualsLoader.cpp b/src/ui/VisualsLoader.cpp index 2d956bd..4229637 100644 --- a/src/ui/VisualsLoader.cpp +++ b/src/ui/VisualsLoader.cpp @@ -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; }