Files
dota_factory/src/test/BlueprintTest.cpp

668 lines
24 KiB
C++

#include "catch.hpp"
#include <algorithm>
#include <climits>
#include <vector>
#include <QPoint>
#include "Blueprint.h"
#include "Building.h"
#include "BuildingsConfig.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "BuildingId.h"
#include "Rotation.h"
#include "Simulation.h"
#include "SurfaceMask.h"
#include "Tick.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<QPoint> bodyCells;
BuildingType type;
Rotation rotation;
std::string recipeId; // empty = none
};
static Blueprint buildBlueprint(const std::vector<BuildingSpec>& 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;
bb.recipeId = s.recipeId;
bp.buildings.push_back(bb);
}
return bp;
}
// Apply one CW/CCW rotation to every building in the constellation, mirroring
// the corrected Q / E handling in GameWorldView::keyPressEvent.
// Each building's anchor is recomputed from the rotated body cells so that
// multi-tile footprints stay correctly aligned after rotation.
static void applyRotationCW(Blueprint& bp, const GameConfig& cfg)
{
for (BlueprintBuilding& bb : bp.buildings)
{
const BuildingDef* def = nullptr;
for (const BuildingDef& d : cfg.buildings.buildings)
{
if (d.type == bb.type) { def = &d; break; }
}
if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint abs = bb.offset + cell;
minX = std::min(minX, -abs.y());
minY = std::min(minY, abs.x());
}
bb.offset = QPoint(minX, minY);
bb.rotation = rotCW(bb.rotation);
}
}
static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg)
{
for (BlueprintBuilding& bb : bp.buildings)
{
const BuildingDef* def = nullptr;
for (const BuildingDef& d : cfg.buildings.buildings)
{
if (d.type == bb.type) { def = &d; break; }
}
if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint abs = bb.offset + cell;
minX = std::min(minX, abs.y());
minY = std::min(minY, -abs.x());
}
bb.offset = QPoint(minX, minY);
bb.rotation = rotCCW(bb.rotation);
}
}
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter:
// building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false
// are silently excluded before the bounding-box center and offsets are computed.
static Blueprint buildBlueprintFiltered(const std::vector<BuildingSpec>& specs,
const GameConfig& cfg)
{
std::vector<BuildingSpec> filtered;
for (const BuildingSpec& s : specs)
{
for (const BuildingDef& def : cfg.buildings.buildings)
{
if (def.type == s.type)
{
if (def.playerPlaceable) { filtered.push_back(s); }
break;
}
}
// If the type is not in buildings (e.g. Hq, defence stations), it is skipped.
}
return buildBlueprint(filtered);
}
// ---------------------------------------------------------------------------
// 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<BuildingSpec> 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]")
{
const GameConfig cfg = loadConfig();
// 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, cfg);
// 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]")
{
const GameConfig cfg = loadConfig();
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, cfg);
// 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]")
{
const GameConfig cfg = loadConfig();
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, cfg); }
REQUIRE(bp.buildings[0].offset == originalOffset);
REQUIRE(bp.buildings[0].rotation == originalRotation);
}
TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[blueprint]")
{
const GameConfig cfg = loadConfig();
// 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, cfg);
// 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);
}
// ---------------------------------------------------------------------------
// Regression: multi-tile building rotation (the anchor-offset-only bug)
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[blueprint]")
{
const GameConfig cfg = loadConfig();
// East miner: anchor (0,0), body cells (0,0),(1,0),(0,1).
// Output port indicator '>' at (1,1) → port tile (1,1), direction East.
// Belt placed at (1,1) — the port exit tile.
// Constellation body cells: {(0,0),(1,0),(0,1),(1,1)}.
// Integer center: ((0+1)/2, (0+1)/2) = (0,0).
// Miner offset (0,0), belt offset (1,1).
Blueprint bp;
bp.name = "test";
BlueprintBuilding miner;
miner.type = BuildingType::Miner;
miner.rotation = Rotation::East;
miner.offset = QPoint(0, 0);
BlueprintBuilding belt;
belt.type = BuildingType::Belt;
belt.rotation = Rotation::East;
belt.offset = QPoint(1, 1); // port exit tile of East miner relative to its anchor
bp.buildings = { miner, belt };
applyRotationCW(bp, cfg);
// Miner body cells (0,0),(1,0),(0,1) rotated CW: (0,0),(0,1),(-1,0).
// New miner anchor = min(-1..0, 0..1) = (-1, 0).
REQUIRE(bp.buildings[0].offset == QPoint(-1, 0));
REQUIRE(bp.buildings[0].rotation == Rotation::South);
// Belt body cell (1,1) rotated CW: (-1, 1). New belt anchor = (-1, 1).
REQUIRE(bp.buildings[1].offset == QPoint(-1, 1));
REQUIRE(bp.buildings[1].rotation == Rotation::South);
// South miner port tile relative to its anchor is (0,1) (the 'v' indicator in ["AA","vA"]).
// Miner anchor in constellation = (-1,0). Port exit = (-1,0)+(0,1) = (-1,1) = belt anchor. ✓
REQUIRE(bp.buildings[1].offset == bp.buildings[0].offset + QPoint(0, 1));
}
TEST_CASE("Blueprint: CCW rotation keeps belt adjacent to miner output port", "[blueprint]")
{
const GameConfig cfg = loadConfig();
Blueprint bp;
bp.name = "test";
BlueprintBuilding miner;
miner.type = BuildingType::Miner;
miner.rotation = Rotation::East;
miner.offset = QPoint(0, 0);
BlueprintBuilding belt;
belt.type = BuildingType::Belt;
belt.rotation = Rotation::East;
belt.offset = QPoint(1, 1);
bp.buildings = { miner, belt };
applyRotationCCW(bp, cfg);
// Miner body cells (0,0),(1,0),(0,1) rotated CCW: (0,0),(0,-1),(1,0).
// New miner anchor = min(0..1, -1..0) = (0, -1).
REQUIRE(bp.buildings[0].offset == QPoint(0, -1));
REQUIRE(bp.buildings[0].rotation == Rotation::North);
// Belt body cell (1,1) rotated CCW: (1,-1). New belt anchor = (1,-1).
REQUIRE(bp.buildings[1].offset == QPoint(1, -1));
REQUIRE(bp.buildings[1].rotation == Rotation::North);
// North miner port tile relative to its anchor is (1,0) (the '^' indicator in ["A^","AA"]).
// Miner anchor in constellation = (0,-1). Port exit = (0,-1)+(1,0) = (1,-1) = belt anchor. ✓
REQUIRE(bp.buildings[1].offset == bp.buildings[0].offset + QPoint(1, 0));
}
// ---------------------------------------------------------------------------
// Player-placeable filter in blueprint creation
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint creation: non-player-placeable building alone yields empty blueprint",
"[blueprint]")
{
const GameConfig cfg = loadConfig();
// Hq has no entry in buildings.toml, so it is treated as non-player-placeable.
const BuildingSpec hq{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Hq, Rotation::East };
const Blueprint bp = buildBlueprintFiltered({ hq }, cfg);
REQUIRE(bp.buildings.empty());
}
TEST_CASE("Blueprint creation: mixed selection keeps only player-placeable buildings",
"[blueprint]")
{
const GameConfig cfg = loadConfig();
const BuildingSpec belt{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
const BuildingSpec hq { QPoint(-3, 0), {QPoint(-3, 0)}, BuildingType::Hq, Rotation::East };
const Blueprint bp = buildBlueprintFiltered({ belt, hq }, cfg);
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].type == BuildingType::Belt);
}
TEST_CASE("Blueprint creation: bounding box ignores non-player-placeable buildings",
"[blueprint]")
{
const GameConfig cfg = loadConfig();
// Belt at (-5, 0). HQ at (-3, 0) — excluded from the blueprint.
// If HQ were included: bboxX = [-5, -3], center.x = -4, belt offset = -1.
// With HQ excluded: bboxX = [-5, -5], center.x = -5, belt offset = 0.
const BuildingSpec belt{ QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East };
const BuildingSpec hq { QPoint(-3, 0), {QPoint(-3, 0)}, BuildingType::Hq, Rotation::East };
const Blueprint bp = buildBlueprintFiltered({ belt, hq }, cfg);
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].offset == QPoint(0, 0));
}
// ---------------------------------------------------------------------------
// 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 BuildingId idA = sim.tryPlaceBuilding(
BuildingType::Belt, cursor + offsetA, Rotation::East);
const BuildingId idB = sim.tryPlaceBuilding(
BuildingType::Belt, cursor + offsetB, Rotation::East);
REQUIRE(idA != kInvalidBuildingId);
REQUIRE(idB != kInvalidBuildingId);
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 kInvalidBuildingId 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 BuildingId id = sim.tryPlaceBuilding(
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
// Placement must fail and leave the stock unchanged.
REQUIRE(id == kInvalidBuildingId);
REQUIRE(sim.buildingBlocksStock() == blocksBeforeAttempt);
}
// ---------------------------------------------------------------------------
// Recipe / schematic capture and re-application
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint: recipeId is stored in BlueprintBuilding", "[blueprint]")
{
const BuildingSpec spec{
QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Miner, Rotation::East,
"mine_iron_ore"
};
const Blueprint bp = buildBlueprint({ spec });
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].recipeId == "mine_iron_ore");
}
TEST_CASE("Blueprint: building with no recipe has empty recipeId", "[blueprint]")
{
const BuildingSpec spec{
QPoint(-5, 0), {QPoint(-5, 0)}, BuildingType::Belt, Rotation::East
// recipeId defaults to ""
};
const Blueprint bp = buildBlueprint({ spec });
REQUIRE(bp.buildings[0].recipeId.empty());
}
TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "[blueprint]")
{
Simulation sim(loadConfig());
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
sim.buildings().setRecipe(id, "mine_iron_ore");
const ConstructionSite* site = sim.buildings().findSite(id);
REQUIRE(site != nullptr);
REQUIRE(site->recipeId == "mine_iron_ore");
}
TEST_CASE("Blueprint placement: recipe transfers to building after construction completes",
"[blueprint]")
{
Simulation sim(loadConfig());
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
sim.buildings().setRecipe(id, "mine_copper_ore");
// Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300.
// Run 301 ticks (0..300) to process the completion tick.
for (int i = 0; i <= static_cast<int>(secondsToTicks(10.0)); ++i)
{
sim.tick();
}
const Building* b = sim.buildings().findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(b->recipeId == "mine_copper_ore");
}
TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start", "[blueprint]")
{
// "interceptor" has available_from_start = true in the test config.
// This confirms the guard in placeBlueprintAtTile passes for start-unlocked schematics.
Simulation sim(loadConfig());
REQUIRE(sim.isSchematicUnlocked("interceptor"));
}
TEST_CASE("Blueprint placement: repair_ship schematic is locked at game start", "[blueprint]")
{
// "repair_ship" has available_from_start = false in the test config.
// This confirms the guard in placeBlueprintAtTile blocks locked schematics,
// leaving the shipyard's schematic unset.
Simulation sim(loadConfig());
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
}