diff --git a/docs/requirements.md b/docs/requirements.md index 57d4d15..f42cda3 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -397,6 +397,9 @@ The screen is divided into two columns: a main column (75% width) containing the - REQ-UI-DEBUG-OVERLAY: While debug draw mode is active (REQ-UI-DEBUG-DRAW), a text overlay is drawn in the upper left corner of the game world view. The overlay has a semi-transparent black background sized to fit its content. It displays the following lines of text: - `Accumulated Threat Level: ` — where `` is the current accumulated threat level (see REQ-WAV-THREAT-RATE). - `Time until Wave: ` — where `` is the remaining time in seconds on the normal-wave inter-wave gap timer (see REQ-WAV-GAP). During a quiet window the gap timer is frozen; the displayed value reflects that frozen state. + - `Threat Accumulation Rate: threat/s` — the rate at which the accumulated threat level is currently increasing (see REQ-WAV-THREAT-RATE). During a quiet window (REQ-WAV-QUIET), this is 0, reflecting that accumulation is currently paused. + - `Max Factory Production: threat/s` — the threat-equivalent of the factory's total possible production: 1 threat/second for each completed (operational, not under construction) miner, smelter, assembler, reprocessing plant, and shipyard. One second of production equals one threat (see REQ-MOD-THREAT). + - `Current Factory Production: threat/s` — the threat-equivalent of the factory's current production: 1 threat/second for each completed miner, smelter, assembler, reprocessing plant, or shipyard that currently has an active production cycle (see REQ-MAT-CYCLE; for shipyards, an in-progress production cycle per REQ-BLD-SHIPYARD). ### Escape Menu diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 7939c4a..07aa866 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -867,6 +867,44 @@ std::vector BuildingSystem::allSites() const m_constructionQueue.end()); } +namespace +{ +bool isProductionBuildingType(BuildingType type) +{ + switch (type) + { + case BuildingType::Miner: + case BuildingType::Smelter: + case BuildingType::Assembler: + case BuildingType::ReprocessingPlant: + case BuildingType::Shipyard: + return true; + default: + return false; + } +} +} // namespace + +int BuildingSystem::productionBuildingCount() const +{ + int count = 0; + for (const Building& b : m_buildings) + { + if (isProductionBuildingType(b.type)) { ++count; } + } + return count; +} + +int BuildingSystem::activeProductionBuildingCount() const +{ + int count = 0; + for (const Building& b : m_buildings) + { + if (isProductionBuildingType(b.type) && b.production.has_value()) { ++count; } + } + return count; +} + std::vector BuildingSystem::allBeltTiles() const { std::vector result; diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index a93f98b..2dd3bc2 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -79,6 +79,14 @@ public: const ConstructionSite* findSite(BuildingId id) const; std::vector allBuildings() const; std::vector allSites() const; + + // REQ-UI-DEBUG-OVERLAY "Max Factory Production": count of completed + // (operational) Miner/Smelter/Assembler/ReprocessingPlant/Shipyard buildings. + int productionBuildingCount() const; + + // REQ-UI-DEBUG-OVERLAY "Current Factory Production": subset of the above + // that currently has an active production cycle. + int activeProductionBuildingCount() const; std::vector allBeltTiles() const; bool isTileOccupied(QPoint tile) const; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 99bda9b..132464c 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -863,6 +863,21 @@ double Simulation::threatLevel() const return m_waveSystem->threatLevel(); } +double Simulation::threatAccumulationRate() const +{ + return m_waveSystem->threatAccumulationRate(); +} + +double Simulation::maxFactoryProductionThreatRate() const +{ + return static_cast(m_buildingSystem->productionBuildingCount()); +} + +double Simulation::currentFactoryProductionThreatRate() const +{ + return static_cast(m_buildingSystem->activeProductionBuildingCount()); +} + int Simulation::bossWaveCounter() const { return m_waveSystem->bossWaveCounter(); diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 8b3f363..a4d74c9 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -67,6 +67,9 @@ public: int buildingBlocksStock() const; bool isGameOver() const; double threatLevel() const; + double threatAccumulationRate() const; + double maxFactoryProductionThreatRate() const; + double currentFactoryProductionThreatRate() const; int bossWaveCounter() const; Tick bossCountdownTicks() const; Tick normalGapRemainingTicks() const; diff --git a/src/lib/sim/WaveSystem.cpp b/src/lib/sim/WaveSystem.cpp index f2a6c1c..529b71c 100644 --- a/src/lib/sim/WaveSystem.cpp +++ b/src/lib/sim/WaveSystem.cpp @@ -103,6 +103,16 @@ double WaveSystem::threatLevel() const return m_threatLevel; } +double WaveSystem::threatAccumulationRate() const +{ + if (isInQuietWindow()) + { + return 0.0; + } + const double x = static_cast(m_bossWaveCounter); + return std::max(0.0, m_config.world.waves.threatRateFormula.evaluate(x)); +} + int WaveSystem::generation() const { return m_generation; diff --git a/src/lib/sim/WaveSystem.h b/src/lib/sim/WaveSystem.h index aceb630..fdae99b 100644 --- a/src/lib/sim/WaveSystem.h +++ b/src/lib/sim/WaveSystem.h @@ -36,6 +36,11 @@ public: double threatLevel() const; + // Current rate at which threatLevel() is increasing, in threat/second + // (REQ-WAV-THREAT-RATE). 0 during a quiet window (REQ-WAV-QUIET) or when + // the rate formula evaluates to a negative value. + double threatAccumulationRate() const; + // Current enemy-station generation level (0 for initial set, // incremented by 1 after each push — REQ-PSH-STATION-STATS). int generation() const; diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index d69bf36..a68187f 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -337,6 +337,85 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]") REQUIRE_FALSE(b->production.has_value()); } +// --------------------------------------------------------------------------- +// REQ-UI-DEBUG-OVERLAY production counts +// --------------------------------------------------------------------------- + +TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites", "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeed_tps); + int stock = 0; + std::mt19937 rng(0); + BuildingId nextBuildingId = 1; + BuildingSystem bs(cfg, belts, + [&nextBuildingId]() { return nextBuildingId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D, const std::optional&) {}, + [](const std::string&) -> bool { return true; }, + rng); + + const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(10, 0), Rotation::East, 0); + (void)smelterId; + + Tick tick = 0; + // Both still under construction. + REQUIRE(bs.productionBuildingCount() == 0); + + // The queue builds one at a time: miner (10s) completes at tick 300, then + // the smelter (15s) starts and completes at tick 300 + 450 = 750. + runTicks(bs, belts, static_cast(secondsToTicks(10.0)) + 1, tick); + REQUIRE(bs.productionBuildingCount() == 1); + + runTicks(bs, belts, static_cast(secondsToTicks(15.0)), tick); + REQUIRE(bs.productionBuildingCount() == 2); + + // Neither has a recipe selected, so neither has an active cycle. + REQUIRE(bs.activeProductionBuildingCount() == 0); + + bs.setRecipe(minerId, "mine_iron_ore"); + runTicks(bs, belts, 1, tick); + REQUIRE(bs.activeProductionBuildingCount() == 1); +} + +TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle state", + "[building]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeed_tps); + int stock = 0; + std::mt19937 rng(0); + BuildingId nextBuildingId = 1; + BuildingSystem bs(cfg, belts, + [&nextBuildingId]() { return nextBuildingId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D, const std::optional&) {}, + [](const std::string&) -> bool { return true; }, + rng); + + const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); + bs.setRecipe(id, "mine_iron_ore"); + + Tick tick = 0; + // Not yet operational while under construction. + REQUIRE(bs.activeProductionBuildingCount() == 0); + + // Construction completes at tick 300; cycle 1 starts the same tick (completesAt=330). + runTicks(bs, belts, static_cast(secondsToTicks(10.0)) + 1, tick); + REQUIRE(bs.activeProductionBuildingCount() == 1); + + // Run cycles 1 and 2 to completion (1s each); cycle 3 stalls once the + // output buffer (capacity 2) is full (REQ-MAT-OUTPUT-BUFFER). + runTicks(bs, belts, 2 * static_cast(secondsToTicks(1.0)) + 1, tick); + + const Building* b = bs.findBuilding(id); + REQUIRE(b != nullptr); + REQUIRE(static_cast(b->outputBuffer.items.size()) == 2); + REQUIRE_FALSE(b->production.has_value()); + REQUIRE(bs.activeProductionBuildingCount() == 0); +} + // --------------------------------------------------------------------------- // Belt pull → input buffer // --------------------------------------------------------------------------- diff --git a/src/test/WaveSystemTest.cpp b/src/test/WaveSystemTest.cpp index da48037..afc1244 100644 --- a/src/test/WaveSystemTest.cpp +++ b/src/test/WaveSystemTest.cpp @@ -51,6 +51,35 @@ TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]") REQUIRE(ws.threatLevel() == Approx(1.0)); } +TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside quiet windows", + "[wave]") +{ + const GameConfig cfg = loadConfig(); + std::mt19937 rng(42); + WaveSystem ws(cfg, rng); + + // threat_rate_formula = "x", boss wave counter starts at 1 → rate = 1 threat/s. + REQUIRE(ws.threatAccumulationRate() == Approx(1.0)); +} + +TEST_CASE("WaveSystem: threatAccumulationRate is 0 during a quiet window", "[wave]") +{ + GameConfig cfg = loadConfig(); + // Start with the boss countdown already at the pre-boss quiet threshold. + cfg.world.waves.bossCountdownSeconds = cfg.world.waves.bossQuietBeforeSeconds; + std::mt19937 rng(42); + WaveSystem ws(cfg, rng); + + REQUIRE(ws.threatAccumulationRate() == Approx(0.0)); + + const double before = ws.threatLevel(); + for (int i = 0; i < static_cast(secondsToTicks(1.0)); ++i) + { + ws.tickThreatAccumulation(); + } + REQUIRE(ws.threatLevel() == Approx(before)); +} + TEST_CASE("WaveSystem: generation starts at 0 and increments on station destruction", "[wave]") { const GameConfig cfg = loadConfig(); diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 973a24f..b5bb1c2 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include "BeltSystem.h" @@ -924,10 +925,18 @@ void GameWorldView::drawDebugOverlay(QPainter& painter) { painter.resetTransform(); - const QString line1 = tr("Accumulated Threat Level: %1") - .arg(m_sim->threatLevel(), 0, 'f', 1); - const QString line2 = tr("Time until Wave: %1s") - .arg(ticksToSeconds(m_sim->normalGapRemainingTicks()), 0, 'f', 1); + const QStringList lines = { + tr("Accumulated Threat Level: %1") + .arg(m_sim->threatLevel(), 0, 'f', 1), + tr("Time until Wave: %1s") + .arg(ticksToSeconds(m_sim->normalGapRemainingTicks()), 0, 'f', 1), + tr("Threat Accumulation Rate: %1 threat/s") + .arg(m_sim->threatAccumulationRate(), 0, 'f', 1), + tr("Max Factory Production: %1 threat/s") + .arg(m_sim->maxFactoryProductionThreatRate(), 0, 'f', 1), + tr("Current Factory Production: %1 threat/s") + .arg(m_sim->currentFactoryProductionThreatRate(), 0, 'f', 1), + }; QFont font = painter.font(); font.setPointSize(m_visuals->toast.fontSize); @@ -937,19 +946,26 @@ void GameWorldView::drawDebugOverlay(QPainter& painter) const int lineH = fm.height(); const int padding = 8; const int spacing = 4; - const int textW = std::max(fm.horizontalAdvance(line1), - fm.horizontalAdvance(line2)); + + int textW = 0; + for (const QString& line : lines) + { + textW = std::max(textW, fm.horizontalAdvance(line)); + } const int bgW = textW + padding * 2; - const int bgH = lineH * 2 + spacing + padding * 2; + const int bgH = lineH * lines.size() + spacing * (lines.size() - 1) + padding * 2; const QRect bgRect(padding, padding, bgW, bgH); painter.fillRect(bgRect, QColor(0, 0, 0, 160)); painter.setPen(Qt::white); - const QRect textRect1(padding * 2, padding + padding, textW, lineH); - const QRect textRect2(padding * 2, textRect1.bottom() + spacing, textW, lineH); - painter.drawText(textRect1, Qt::AlignLeft | Qt::AlignVCenter, line1); - painter.drawText(textRect2, Qt::AlignLeft | Qt::AlignVCenter, line2); + int y = padding * 2; + for (const QString& line : lines) + { + const QRect textRect(padding * 2, y, textW, lineH); + painter.drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, line); + y += lineH + spacing; + } } void GameWorldView::drawBeams(QPainter& painter)