From 1e2135dc5bfb1b743c4391713809c3417ebf763e Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Sun, 26 Apr 2026 22:19:46 +0200 Subject: [PATCH] add blueprint tests --- src/{ui => lib/core}/Blueprint.h | 0 src/lib/core/CMakeLists.txt | 1 + src/test/BlueprintTest.cpp | 406 +++++++++++++++++++++++++++++++ src/test/CMakeLists.txt | 1 + src/ui/CMakeLists.txt | 1 - 5 files changed, 408 insertions(+), 1 deletion(-) rename src/{ui => lib/core}/Blueprint.h (100%) create mode 100644 src/test/BlueprintTest.cpp diff --git a/src/ui/Blueprint.h b/src/lib/core/Blueprint.h similarity index 100% rename from src/ui/Blueprint.h rename to src/lib/core/Blueprint.h diff --git a/src/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt index a05b251..cf8aaff 100644 --- a/src/lib/core/CMakeLists.txt +++ b/src/lib/core/CMakeLists.txt @@ -4,6 +4,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/EntityId.h ${CMAKE_CURRENT_SOURCE_DIR}/Rotation.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.h + ${CMAKE_CURRENT_SOURCE_DIR}/Blueprint.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h ${CMAKE_CURRENT_SOURCE_DIR}/Item.h ${CMAKE_CURRENT_SOURCE_DIR}/Port.h diff --git a/src/test/BlueprintTest.cpp b/src/test/BlueprintTest.cpp new file mode 100644 index 0000000..9156e0c --- /dev/null +++ b/src/test/BlueprintTest.cpp @@ -0,0 +1,406 @@ +#include "catch.hpp" + +#include +#include +#include + +#include + +#include "Blueprint.h" +#include "BuildingSystem.h" +#include "BuildingType.h" +#include "ConfigLoader.h" +#include "EntityId.h" +#include "Rotation.h" +#include "Simulation.h" + +// --------------------------------------------------------------------------- +// Helpers that mirror the production implementations under test. +// --------------------------------------------------------------------------- + +// Mirror of the CW / CCW offset transforms in GameWorldView::keyPressEvent. +static QPoint rotateCW(QPoint p) { return QPoint(-p.y(), p.x()); } +static QPoint rotateCCW(QPoint p) { return QPoint( p.y(), -p.x()); } + +// Mirror of the Rotation cycling in GameWorldView (anonymous-namespace helpers). +static Rotation rotCW(Rotation r) +{ + switch (r) + { + case Rotation::North: return Rotation::East; + case Rotation::East: return Rotation::South; + case Rotation::South: return Rotation::West; + case Rotation::West: return Rotation::North; + } + return Rotation::East; +} + +static Rotation rotCCW(Rotation r) +{ + switch (r) + { + case Rotation::North: return Rotation::West; + case Rotation::East: return Rotation::North; + case Rotation::South: return Rotation::East; + case Rotation::West: return Rotation::South; + } + return Rotation::East; +} + +// Mirror of BlueprintPanel::createBlueprintFromSelection: given per-building +// (anchor, bodyCells, type, rotation), compute Blueprint with floor-division +// bounding-box center and per-building tile offsets. +struct BuildingSpec +{ + QPoint anchor; + std::vector bodyCells; + BuildingType type; + Rotation rotation; +}; + +static Blueprint buildBlueprint(const std::vector& specs) +{ + int minX = INT_MAX, maxX = INT_MIN; + int minY = INT_MAX, maxY = INT_MIN; + for (const BuildingSpec& s : specs) + { + for (const QPoint& cell : s.bodyCells) + { + minX = std::min(minX, cell.x()); + maxX = std::max(maxX, cell.x()); + minY = std::min(minY, cell.y()); + maxY = std::max(maxY, cell.y()); + } + } + const QPoint center((minX + maxX) / 2, (minY + maxY) / 2); + + Blueprint bp; + for (const BuildingSpec& s : specs) + { + BlueprintBuilding bb; + bb.type = s.type; + bb.rotation = s.rotation; + bb.offset = s.anchor - center; + bp.buildings.push_back(bb); + } + return bp; +} + +// Apply one CW/CCW rotation to every building in the constellation, mirroring +// the Q / E handling in GameWorldView::keyPressEvent. +static void applyRotationCW(Blueprint& bp) +{ + for (BlueprintBuilding& bb : bp.buildings) + { + bb.offset = rotateCW(bb.offset); + bb.rotation = rotCW(bb.rotation); + } +} + +static void applyRotationCCW(Blueprint& bp) +{ + for (BlueprintBuilding& bb : bp.buildings) + { + bb.offset = rotateCCW(bb.offset); + bb.rotation = rotCCW(bb.rotation); + } +} + +static GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR); +} + +// --------------------------------------------------------------------------- +// Offset computation +// --------------------------------------------------------------------------- + +TEST_CASE("Blueprint: single 1x1 building gets zero offset", "[blueprint]") +{ + // Body = one tile at (-5, 3). Center = (-5, 3). Offset = (0, 0). + const BuildingSpec spec{ QPoint(-5, 3), {QPoint(-5, 3)}, BuildingType::Belt, Rotation::East }; + const Blueprint bp = buildBlueprint({ spec }); + + REQUIRE(bp.buildings.size() == 1); + REQUIRE(bp.buildings[0].offset == QPoint(0, 0)); +} + +TEST_CASE("Blueprint: two 1x1 buildings with odd span get symmetric offsets", "[blueprint]") +{ + // Anchors at (-6, 0) and (-4, 0). bboxX = [-6, -4], center.x = -5. + // Offsets: -6 - (-5) = -1, -4 - (-5) = +1. + const BuildingSpec left { QPoint(-6, 0), {QPoint(-6, 0)}, BuildingType::Belt, Rotation::East }; + const BuildingSpec right{ QPoint(-4, 0), {QPoint(-4, 0)}, BuildingType::Belt, Rotation::East }; + const Blueprint bp = buildBlueprint({ left, right }); + + REQUIRE(bp.buildings[0].offset == QPoint(-1, 0)); + REQUIRE(bp.buildings[1].offset == QPoint( 1, 0)); +} + +TEST_CASE("Blueprint: even span uses C++ integer truncation for center", "[blueprint]") +{ + // Anchors at (-5, 0) and (-4, 0). bboxX = [-5, -4], sum = -9. + // center.x = -9 / 2 = -4 (C++ truncates toward zero, i.e. rounds up for negatives). + // Offsets: -5 - (-4) = -1, -4 - (-4) = 0. + const BuildingSpec left { QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East }; + const BuildingSpec right{ QPoint(-4, 0), {QPoint(-4, 0)}, BuildingType::Belt, Rotation::East }; + const Blueprint bp = buildBlueprint({ left, right }); + + REQUIRE(bp.buildings[0].offset == QPoint(-1, 0)); + REQUIRE(bp.buildings[1].offset == QPoint( 0, 0)); +} + +TEST_CASE("Blueprint: bounding box is computed from body cells, not anchors alone", "[blueprint]") +{ + // A 2x1 building: anchor (-6, 0), body cells (-6, 0) and (-5, 0). + // A 1x1 building: anchor (-3, 0), body cell (-3, 0). + // All body cells: {(-6,0),(-5,0),(-3,0)}. bboxX = [-6, -3], sum = -9. + // center.x = -9 / 2 = -4 (C++ truncation). + // Wide building anchor offset: -6 - (-4) = -2. + // Small building anchor offset: -3 - (-4) = +1. + const BuildingSpec wide{ + QPoint(-6, 0), + { QPoint(-6, 0), QPoint(-5, 0) }, + BuildingType::Belt, + Rotation::East + }; + const BuildingSpec small{ + QPoint(-3, 0), + { QPoint(-3, 0) }, + BuildingType::Belt, + Rotation::East + }; + const Blueprint bp = buildBlueprint({ wide, small }); + + REQUIRE(bp.buildings[0].offset == QPoint(-2, 0)); + REQUIRE(bp.buildings[1].offset == QPoint( 1, 0)); +} + +TEST_CASE("Blueprint: 2-D bounding box with buildings on both axes", "[blueprint]") +{ + // Four 1x1 buildings at corners of a 4x2 rectangle (-7,0),(-5,0),(-7,2),(-5,2). + // bboxX = [-7, -5], center.x = -6. bboxY = [0, 2], center.y = 1. + // Offsets: (-1,-1), (1,-1), (-1,1), (1,1). + const auto belt = BuildingType::Belt; + const auto east = Rotation::East; + std::vector specs = { + { QPoint(-7, 0), {QPoint(-7, 0)}, belt, east }, + { QPoint(-5, 0), {QPoint(-5, 0)}, belt, east }, + { QPoint(-7, 2), {QPoint(-7, 2)}, belt, east }, + { QPoint(-5, 2), {QPoint(-5, 2)}, belt, east }, + }; + const Blueprint bp = buildBlueprint(specs); + + REQUIRE(bp.buildings[0].offset == QPoint(-1, -1)); + REQUIRE(bp.buildings[1].offset == QPoint( 1, -1)); + REQUIRE(bp.buildings[2].offset == QPoint(-1, 1)); + REQUIRE(bp.buildings[3].offset == QPoint( 1, 1)); +} + +// --------------------------------------------------------------------------- +// Offset rotation math +// --------------------------------------------------------------------------- + +TEST_CASE("Blueprint: CW rotation in screen space maps right→down, down→left", "[blueprint]") +{ + // Screen space has Y growing downward. CW means: right→down, down→left, left→up, up→right. + REQUIRE(rotateCW(QPoint( 1, 0)) == QPoint( 0, 1)); + REQUIRE(rotateCW(QPoint( 0, 1)) == QPoint(-1, 0)); + REQUIRE(rotateCW(QPoint(-1, 0)) == QPoint( 0, -1)); + REQUIRE(rotateCW(QPoint( 0, -1)) == QPoint( 1, 0)); +} + +TEST_CASE("Blueprint: CCW rotation is the inverse of CW rotation", "[blueprint]") +{ + REQUIRE(rotateCCW(QPoint( 1, 0)) == QPoint( 0, -1)); + REQUIRE(rotateCCW(QPoint( 0, -1)) == QPoint(-1, 0)); + REQUIRE(rotateCCW(QPoint(-1, 0)) == QPoint( 0, 1)); + REQUIRE(rotateCCW(QPoint( 0, 1)) == QPoint( 1, 0)); +} + +TEST_CASE("Blueprint: four CW rotations restore any offset to its original", "[blueprint]") +{ + const QPoint original(-3, 5); + QPoint p = original; + for (int i = 0; i < 4; ++i) { p = rotateCW(p); } + REQUIRE(p == original); +} + +TEST_CASE("Blueprint: CW followed by CCW is identity", "[blueprint]") +{ + const QPoint original(2, -7); + REQUIRE(rotateCCW(rotateCW(original)) == original); + REQUIRE(rotateCW(rotateCCW(original)) == original); +} + +TEST_CASE("Blueprint: non-axis-aligned offset rotates correctly", "[blueprint]") +{ + // (2, 3) → CW → (-3, 2) → CW → (-2, -3) → CW → (3, -2) → CW → (2, 3) + QPoint p(2, 3); + p = rotateCW(p); REQUIRE(p == QPoint(-3, 2)); + p = rotateCW(p); REQUIRE(p == QPoint(-2, -3)); + p = rotateCW(p); REQUIRE(p == QPoint( 3, -2)); + p = rotateCW(p); REQUIRE(p == QPoint( 2, 3)); +} + +// --------------------------------------------------------------------------- +// Constellation rotation: offsets AND building rotations both update +// --------------------------------------------------------------------------- + +TEST_CASE("Blueprint: CW constellation rotation updates offset and building rotation", "[blueprint]") +{ + // Building one tile to the right, facing East. + Blueprint bp; + bp.name = "test"; + BlueprintBuilding bb; + bb.type = BuildingType::Belt; + bb.rotation = Rotation::East; + bb.offset = QPoint(1, 0); + bp.buildings.push_back(bb); + + applyRotationCW(bp); + + // Offset: right → down. + REQUIRE(bp.buildings[0].offset == QPoint(0, 1)); + // Building rotation: East → South. + REQUIRE(bp.buildings[0].rotation == Rotation::South); +} + +TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rotation", "[blueprint]") +{ + Blueprint bp; + bp.name = "test"; + BlueprintBuilding bb; + bb.type = BuildingType::Belt; + bb.rotation = Rotation::East; + bb.offset = QPoint(1, 0); + bp.buildings.push_back(bb); + + applyRotationCCW(bp); + + // Offset: right → up. + REQUIRE(bp.buildings[0].offset == QPoint(0, -1)); + // Building rotation: East → North. + REQUIRE(bp.buildings[0].rotation == Rotation::North); +} + +TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "[blueprint]") +{ + Blueprint bp; + bp.name = "test"; + BlueprintBuilding bb; + bb.type = BuildingType::Belt; + bb.rotation = Rotation::East; + bb.offset = QPoint(2, -1); + bp.buildings.push_back(bb); + + const QPoint originalOffset = bb.offset; + const Rotation originalRotation = bb.rotation; + + for (int i = 0; i < 4; ++i) { applyRotationCW(bp); } + + REQUIRE(bp.buildings[0].offset == originalOffset); + REQUIRE(bp.buildings[0].rotation == originalRotation); +} + +TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[blueprint]") +{ + // Two buildings left and right of center; after CW they should be above and below. + Blueprint bp; + bp.name = "test"; + BlueprintBuilding left, right; + left.type = right.type = BuildingType::Belt; + left.rotation = right.rotation = Rotation::East; + left.offset = QPoint(-1, 0); + right.offset = QPoint( 1, 0); + bp.buildings = { left, right }; + + applyRotationCW(bp); + + // left (-1, 0) → CW → (0, -1) (above center) + // right ( 1, 0) → CW → (0, 1) (below center) + REQUIRE(bp.buildings[0].offset == QPoint(0, -1)); + REQUIRE(bp.buildings[1].offset == QPoint(0, 1)); + REQUIRE(bp.buildings[0].rotation == Rotation::South); + REQUIRE(bp.buildings[1].rotation == Rotation::South); +} + +// --------------------------------------------------------------------------- +// Simulation-level: blueprint placement places buildings at correct tiles +// --------------------------------------------------------------------------- + +TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor", "[blueprint]") +{ + // Simulate placing a two-belt blueprint with offsets (-1, 0) and (+1, 0) + // at cursor tile (-5, 0). Expected anchors: (-6, 0) and (-4, 0). + // (Belt surface_mask ["A>"] — body at relative (0,0), port at (1,0).) + Simulation sim(loadConfig()); + + const QPoint cursor(-5, 0); + const QPoint offsetA(-1, 0); + const QPoint offsetB( 1, 0); + + const EntityId idA = sim.tryPlaceBuilding( + BuildingType::Belt, cursor + offsetA, Rotation::East); + const EntityId idB = sim.tryPlaceBuilding( + BuildingType::Belt, cursor + offsetB, Rotation::East); + + REQUIRE(idA != kInvalidEntityId); + REQUIRE(idB != kInvalidEntityId); + REQUIRE(sim.buildings().isTileOccupied(cursor + offsetA)); // (-6, 0) + REQUIRE(sim.buildings().isTileOccupied(cursor + offsetB)); // (-4, 0) + REQUIRE_FALSE(sim.buildings().isTileOccupied(cursor)); // center not occupied +} + +TEST_CASE("Blueprint placement: cost is deducted for each building in sequence", "[blueprint]") +{ + Simulation sim(loadConfig()); + + // Find belt cost from config (belt cost = 2 in test config). + int beltCost = 0; + for (const BuildingDef& def : sim.config().buildings.buildings) + { + if (def.type == BuildingType::Belt) { beltCost = def.cost; break; } + } + REQUIRE(beltCost > 0); + + const int startBlocks = sim.buildingBlocksStock(); + REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks + + sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-6, 0), Rotation::East); + REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost); + + sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-4, 0), Rotation::East); + REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost); +} + +TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidEntityId and deducts nothing", + "[blueprint]") +{ + Simulation sim(loadConfig()); + + // Find miner cost (15 in test config) — expensive enough to exhaust a small stock. + int minerCost = 0; + for (const BuildingDef& def : sim.config().buildings.buildings) + { + if (def.type == BuildingType::Miner) { minerCost = def.cost; break; } + } + REQUIRE(minerCost > 0); + + // Drain the stock by placing miners until we no longer have enough. + // Non-overlapping columns: miner body is 2 wide, so step by 2. + int col = -2; + while (sim.buildingBlocksStock() >= minerCost) + { + sim.tryPlaceBuilding(BuildingType::Miner, QPoint(col, 0), Rotation::East); + col -= 2; + } + + const int blocksBeforeAttempt = sim.buildingBlocksStock(); + const EntityId id = sim.tryPlaceBuilding( + BuildingType::Miner, QPoint(col - 2, 0), Rotation::East); + + // Placement must fail and leave the stock unchanged. + REQUIRE(id == kInvalidEntityId); + REQUIRE(sim.buildingBlocksStock() == blocksBeforeAttempt); +} diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 271570a..ec5d8ac 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -15,4 +15,5 @@ add_files( WaveSystemTest.cpp CombatSystemTest.cpp ShipyardTest.cpp + BlueprintTest.cpp ) diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 5443ab3..627ad93 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -7,7 +7,6 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h - ${CMAKE_CURRENT_SOURCE_DIR}/Blueprint.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h PARENT_SCOPE )