show threat rate in debug output

This commit is contained in:
2026-06-14 13:11:35 +02:00
parent 123c544423
commit 1ea1cc59fb
10 changed files with 217 additions and 11 deletions

View File

@@ -867,6 +867,44 @@ std::vector<ConstructionSite> 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::BeltTileInfo> BuildingSystem::allBeltTiles() const
{
std::vector<BeltTileInfo> result;

View File

@@ -79,6 +79,14 @@ public:
const ConstructionSite* findSite(BuildingId id) const;
std::vector<Building> allBuildings() const;
std::vector<ConstructionSite> 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<BeltTileInfo> allBeltTiles() const;
bool isTileOccupied(QPoint tile) const;

View File

@@ -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<double>(m_buildingSystem->productionBuildingCount());
}
double Simulation::currentFactoryProductionThreatRate() const
{
return static_cast<double>(m_buildingSystem->activeProductionBuildingCount());
}
int Simulation::bossWaveCounter() const
{
return m_waveSystem->bossWaveCounter();

View File

@@ -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;

View File

@@ -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<double>(m_bossWaveCounter);
return std::max(0.0, m_config.world.waves.threatRateFormula.evaluate(x));
}
int WaveSystem::generation() const
{
return m_generation;

View File

@@ -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;

View File

@@ -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<ShipLayoutConfig>&) {},
[](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<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(bs.productionBuildingCount() == 1);
runTicks(bs, belts, static_cast<int>(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<ShipLayoutConfig>&) {},
[](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<int>(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<int>(secondsToTicks(1.0)) + 1, tick);
const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(static_cast<int>(b->outputBuffer.items.size()) == 2);
REQUIRE_FALSE(b->production.has_value());
REQUIRE(bs.activeProductionBuildingCount() == 0);
}
// ---------------------------------------------------------------------------
// Belt pull → input buffer
// ---------------------------------------------------------------------------

View File

@@ -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<int>(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();

View File

@@ -15,6 +15,7 @@
#include <QPainter>
#include <QPen>
#include <QPolygonF>
#include <QStringList>
#include <QTimer>
#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)